diff --git a/.drone.jsonnet b/.drone.jsonnet index 823d9e35b5..2510896cb2 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -38,9 +38,6 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/ pull: 'always', environment: { ANDROID_HOME: '/usr/lib/android-sdk' }, commands: [ - 'apt-get update --allow-releaseinfo-change', - 'apt-get install -y ninja-build openjdk-17-jdk', - 'update-java-alternatives -s java-1.17.0-openjdk-amd64', './gradlew testPlayDebugUnitTestCoverageReport' ], } @@ -70,7 +67,10 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/ type: 'docker', name: 'Debug APK Build', platform: { arch: 'amd64' }, - trigger: { event: { exclude: [ 'pull_request' ] } }, + trigger: { + event: ['push'], + branch: ['master', 'dev', 'release/*', 'fix-ci-*'] + }, steps: [ version_info, clone_submodules, @@ -80,10 +80,8 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/ pull: 'always', environment: { SSH_KEY: { from_secret: 'SSH_KEY' }, ANDROID_HOME: '/usr/lib/android-sdk' }, commands: [ - 'apt-get update --allow-releaseinfo-change', - 'apt-get install -y ninja-build openjdk-17-jdk', - 'update-java-alternatives -s java-1.17.0-openjdk-amd64', - './gradlew assemblePlayDebug', + './gradlew assemblePlayQa', + './gradlew assemblePlayAutomaticQa', './scripts/drone-static-upload.sh' ], } diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..ecdb9f86c4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: gradle + directory: "/" + schedule: + interval: weekly + target-branch: "dev" + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + target-branch: "dev" diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml new file mode 100644 index 0000000000..5f180f354e --- /dev/null +++ b/.github/workflows/build_and_test.yml @@ -0,0 +1,69 @@ +name: Build and test + +on: + push: + branches: [ "dev", "master" ] + pull_request: + types: [opened, synchronize, reopened] + + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build_and_test: + runs-on: ubuntu-latest + strategy: + fail-fast: false # Continue with other matrix items if one fails + matrix: + variant: [ 'play', 'website', 'huawei', 'fdroid' ] + build_type: [ 'qa' ] + include: + - variant: 'huawei' + extra_build_command_options: '-Phuawei=1' + - variant: 'play' + run_test: true + steps: + - uses: actions/checkout@v5 + with: + submodules: 'recursive' + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + .gradle + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle.properties') }}-${{ matrix.variant }} + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Build with gradle + id: build + run: ./gradlew assemble${{ matrix.variant }}${{ matrix.build_type }} ${{ matrix.extra_build_command_options }} --stacktrace + + - name: Run unit tests + if: ${{ matrix.run_test == true }} + id: test + run: ./gradlew test${{ matrix.variant }}${{ matrix.build_type }}UnitTest ${{ matrix.extra_build_command_options }} + + - name: Upload build reports regardless + if: always() + uses: actions/upload-artifact@v4 + with: + name: build-reports-${{ matrix.variant }}-${{ matrix.build_type }} + path: app/build/reports + if-no-files-found: ignore + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: session-${{ matrix.variant }}-${{ matrix.build_type }} + path: app/build/outputs/apk/${{ matrix.variant }}/${{ matrix.build_type }}/*-universal*apk + if-no-files-found: error + compression-level: 0 diff --git a/.gitmodules b/.gitmodules index 784585cbf5..e69de29bb2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "libsession-util/libsession-util"] - path = libsession-util/libsession-util - url = https://github.com/session-foundation/libsession-util.git diff --git a/README.md b/README.md index e52ae35eb3..f809195358 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,11 @@ sha256sum session-$SESSION_VERSION-universal.apk grep universal.apk signature.asc ``` +## Testing +### BrowserStack + +This project is tested with BrowserStack. + ## License Copyright 2011 Whisper Systems diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index 1de3eee0d6..0000000000 --- a/app/build.gradle +++ /dev/null @@ -1,448 +0,0 @@ -plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' - id 'org.jetbrains.kotlin.plugin.serialization' - id 'org.jetbrains.kotlin.plugin.compose' - id 'com.google.devtools.ksp' - id 'com.google.dagger.hilt.android' - id 'kotlin-parcelize' - id 'kotlinx-serialization' -} - -apply plugin: 'witness' - -configurations.configureEach { - exclude module: "commons-logging" -} - -def canonicalVersionCode = 396 -def canonicalVersionName = "1.21.0" - -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_17 - targetCompatibility JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = '17' - } - - packagingOptions { - resources { - excludes += ['LICENSE.txt', 'LICENSE', 'NOTICE', 'asm-license.txt', 'META-INF/LICENSE', 'META-INF/NOTICE', 'META-INF/proguard/androidx-annotations.pro'] - } - } - - - splits { - abi { - enable !project.hasProperty('huawei') // huawei builds do not need the split variants - reset() - include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' - universalApk true - } - } - - buildFeatures { - viewBinding true - buildConfig true - } - - composeOptions { - kotlinCompilerExtensionVersion '1.5.15' - } - - defaultConfig { - versionCode canonicalVersionCode * postFixSize - versionName canonicalVersionName - - minSdkVersion androidMinimumSdkVersion - targetSdkVersion androidTargetSdkVersion - - multiDexEnabled = true - - vectorDrawables.useSupportLibrary = true - setProperty("archivesBaseName", "session-${versionName}") - - 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 "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode" - resourceConfigurations += [] - - 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 - main { - assets.srcDirs += "$buildDir/generated/binary" - } - test { - resources.srcDirs += "$buildDir/generated/binary" - resources.srcDirs += "$projectDir/src/main/assets" - } - } - - buildTypes { - release { - minifyEnabled false - } - debug { - isDefault true - minifyEnabled false - enableUnitTestCoverage true - } - } - - signingConfigs { - play { - if (project.hasProperty('SESSION_STORE_FILE')) { - storeFile file(SESSION_STORE_FILE) - storePassword SESSION_STORE_PASSWORD - keyAlias SESSION_KEY_ALIAS - keyPassword SESSION_KEY_PASSWORD - } - } - huawei { - if (project.hasProperty('SESSION_HUAWEI_STORE_FILE')) { - storeFile file(SESSION_HUAWEI_STORE_FILE) - storePassword SESSION_HUAWEI_STORE_PASSWORD - keyAlias SESSION_HUAWEI_KEY_ALIAS - keyPassword SESSION_HUAWEI_KEY_PASSWORD - } - } - } - - 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', '\"\"' - signingConfig signingConfigs.play - } - - 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\"' - signingConfig signingConfigs.huawei - } - - website { - dimension "distribution" - ext.websiteUpdateUrl = "https://github.com/session-foundation/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.configureEach { variant -> - variant.outputs.each { output -> - def abiName = output.getFilter("ABI") ?: 'universal' - def postFix = abiPostFix.get(abiName, 0) - - def flavour = (variant.flavorName == 'huawei') ? "-huawei" : "" - - if (postFix >= postFixSize) throw new AssertionError("postFix is too large") - output.outputFileName = output.outputFileName = "session-${variant.versionName}-${abiName}${flavour}.apk" - output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix - } - } - - - testOptions { - unitTests { - includeAndroidResources = true - } - } - - def huaweiEnabled = project.properties['huawei'] != null - lint { - abortOnError true - baseline file('lint-baseline.xml') - } - - 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) - } - } - } - } - } - - tasks.register('testPlayDebugUnitTestCoverageReport', JacocoReport) { - dependsOn 'testPlayDebugUnitTest' - - reports { - xml.required = 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" - } - - - testNamespace 'network.loki.messenger.test' - lint { - abortOnError true - baseline file('lint-baseline.xml') - } -} - -apply { - from("ipToCode.gradle.kts") -} - -preBuild.dependsOn ipToCode - -dependencies { - implementation project(':content-descriptions') - - ksp("androidx.hilt:hilt-compiler:$jetpackHiltVersion") - ksp("com.google.dagger:hilt-compiler:$daggerHiltVersion") - ksp("com.github.bumptech.glide:ksp:$glideVersion") - implementation("androidx.hilt:hilt-navigation-compose:$androidxHiltVersion") - implementation("androidx.hilt:hilt-work:$androidxHiltVersion") - - implementation("com.google.dagger:hilt-android:$daggerHiltVersion") - implementation "androidx.appcompat:appcompat:$appcompatVersion" - implementation 'androidx.recyclerview:recyclerview:1.2.1' - implementation "com.google.android.material:material:$materialVersion" - implementation 'com.google.android.flexbox:flexbox:3.0.0' - implementation 'androidx.legacy:legacy-support-v13:1.0.0' - implementation 'androidx.cardview:cardview:1.0.0' - implementation "androidx.preference:preference-ktx:$preferenceVersion" - implementation 'androidx.legacy:legacy-preference-v14:1.0.0' - implementation 'androidx.gridlayout:gridlayout:1.0.0' - implementation 'androidx.exifinterface:exifinterface:1.3.4' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" - implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion" - implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" - implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion" - implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion" - implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" - implementation "androidx.paging:paging-runtime-ktx:$pagingVersion" - implementation 'androidx.activity:activity-ktx:1.9.2' - implementation 'androidx.activity:activity-compose:1.9.2' - implementation 'androidx.fragment:fragment-ktx:1.8.4' - implementation "androidx.core:core-ktx:$coreVersion" - implementation "androidx.work:work-runtime-ktx:2.7.1" - - playImplementation ("com.google.firebase:firebase-messaging:24.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 'androidx.media3:media3-exoplayer:1.4.0' - implementation 'androidx.media3:media3-ui:1.4.0' - implementation 'org.conscrypt:conscrypt-android:2.5.2' - implementation 'org.signal:aesgcmprovider:0.0.3' - implementation 'io.github.webrtc-sdk:android:125.6422.06.1' - implementation "me.leolin:ShortcutBadger:1.1.16" - implementation 'se.emilsjolander:stickylistheaders:2.7.0' - implementation 'com.jpardogo.materialtabstrip:library:1.0.9' - implementation 'org.apache.httpcomponents:httpclient-android:4.3.5' - implementation 'commons-net:commons-net:3.7.2' - implementation 'com.github.chrisbanes:PhotoView:2.1.3' - implementation "com.github.bumptech.glide:glide:$glideVersion" - implementation "com.github.bumptech.glide:compose:1.0.0-beta01" - implementation 'com.makeramen:roundedimageview:2.1.0' - implementation 'com.pnikosis:materialish-progress:1.5' - implementation 'org.greenrobot:eventbus:3.0.0' - implementation 'pl.tajchert:waitingdots:0.1.0' - implementation 'com.vanniktech:android-image-cropper:4.5.0' - implementation 'com.melnykov:floatingactionbutton:1.3.0' - implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.6.0') { - exclude group: 'com.android.support', module: 'support-annotations' - } - implementation ('com.tomergoldst.android:tooltips:1.0.6') { - exclude group: 'com.android.support', module: 'appcompat-v7' - } - implementation ('com.klinkerapps:android-smsmms:4.0.1') { - exclude group: 'com.squareup.okhttp', module: 'okhttp' - exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection' - } - implementation 'com.annimon:stream:1.1.8' - implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2' - implementation 'androidx.sqlite:sqlite-ktx:2.3.1' - implementation 'net.zetetic:sqlcipher-android:4.6.1@aar' - implementation project(":libsignal") - implementation project(":libsession") - implementation project(":libsession-util") - implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion" - implementation "com.github.session-foundation.session-android-curve-25519:curve25519-java:$curve25519Version" - implementation project(":liblazysodium") - implementation "net.java.dev.jna:jna:5.12.1@aar" - implementation "com.google.protobuf:protobuf-java:$protobufVersion" - implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion" - implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" - implementation "com.squareup.phrase:phrase:$phraseVersion" - implementation 'app.cash.copper:copper-flow:1.0.0' - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" - implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" - implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion" - implementation "com.jakewharton.rxbinding3:rxbinding:3.1.0" - implementation "com.github.ybq:Android-SpinKit:1.4.0" - implementation "com.opencsv:opencsv:4.6" - testImplementation "junit:junit:$junitVersion" - testImplementation 'org.assertj:assertj-core:3.11.1' - testImplementation "org.mockito:mockito-inline:4.11.0" - testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" - androidTestImplementation "org.mockito:mockito-android:4.11.0" - androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" - testImplementation "androidx.test:core:$testCoreVersion" - testImplementation "androidx.arch.core:core-testing:2.2.0" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" - androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" - // Core library - androidTestImplementation "androidx.test:core:$testCoreVersion" - - androidTestImplementation('com.adevinta.android:barista:4.2.0') { - exclude group: 'org.jetbrains.kotlin' - } - // AndroidJUnitRunner and JUnit Rules - androidTestImplementation 'androidx.test:runner:1.5.2' - androidTestImplementation 'androidx.test:rules:1.5.0' - - // Assertions - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.ext:truth:1.5.0' - testImplementation 'com.google.truth:truth:1.1.3' - androidTestImplementation 'com.google.truth:truth:1.1.3' - - // Espresso dependencies - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1' - androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1' - androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.5.1' - androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1' - androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1' - androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1' - androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.5.3" - debugImplementation "androidx.compose.ui:ui-test-manifest:1.5.3" - androidTestUtil 'androidx.test:orchestrator:1.4.2' - - testImplementation 'org.robolectric:robolectric:4.12.2' - testImplementation 'org.robolectric:shadows-multidex:4.12.2' - testImplementation 'org.conscrypt:conscrypt-openjdk-uber:2.5.2' // For Robolectric - testImplementation 'app.cash.turbine:turbine:1.1.0' - - // compose - Dependency composeBom = platform('androidx.compose:compose-bom:2024.09.01') - implementation composeBom - testImplementation composeBom - androidTestImplementation composeBom - - implementation "androidx.compose.ui:ui" - implementation "androidx.compose.animation:animation" - implementation "androidx.compose.ui:ui-tooling" - implementation "androidx.compose.runtime:runtime-livedata" - implementation "androidx.compose.foundation:foundation-layout" - implementation "androidx.compose.material3:material3" - - androidTestImplementation "androidx.compose.ui:ui-test-junit4-android" - debugImplementation "androidx.compose.ui:ui-test-manifest" - - // Navigation - implementation "androidx.navigation:navigation-fragment-ktx:$navVersion" - implementation "androidx.navigation:navigation-ui-ktx:$navVersion" - implementation "androidx.navigation:navigation-compose:$navVersion" - - implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha" - implementation "com.google.accompanist:accompanist-permissions:0.36.0" - implementation "com.google.accompanist:accompanist-drawablepainter:0.33.1-alpha" - - implementation "androidx.camera:camera-camera2:1.3.2" - implementation "androidx.camera:camera-lifecycle:1.3.2" - implementation "androidx.camera:camera-view:1.3.2" - - // Note: ZXing 3.5.3 is the latest stable release as of 2024/08/21 - implementation "com.google.zxing:core:$zxingVersion" -} - -static def getLastCommitTimestamp() { - new ByteArrayOutputStream().withStream { os -> - return os.toString() + "000" - } -} - -/** - * Discovers supported languages listed as under the res/values- directory. - */ -def autoResConfig() { - def files = new ArrayList() - def root = file("src/main/res") - root.eachFile { f -> files.add(f.name) } - ['en'] + files.collect { f -> f =~ /^values-([a-z]{2}(-r[A-Z]{2})?)$/ } - .findAll { matcher -> matcher.find() } - .collect { matcher -> matcher.group(1) } - .sort() -} diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000000..4002982093 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,516 @@ +import com.android.build.api.dsl.VariantDimension +import com.android.build.api.variant.FilterConfiguration +import java.io.ByteArrayOutputStream + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.plugin.serialization) + alias(libs.plugins.kotlin.plugin.compose) + alias(libs.plugins.kotlin.plugin.parcelize) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt.android) + alias(libs.plugins.dependency.analysis) + alias(libs.plugins.google.services) + alias(libs.plugins.protobuf.compiler) + + id("generate-ip-country-data") + id("rename-apk") + id("witness") +} + +val huaweiEnabled = project.properties["huawei"] != null +val hasIncludedLibSessionUtilProject: Boolean = System.getProperty("session.libsession_util.project.path", "").isNotBlank() + +configurations.configureEach { + exclude(module = "commons-logging") +} + +val canonicalVersionCode = 417 +val canonicalVersionName = "1.27.0" + +val postFixSize = 10 +val abiPostFix = mapOf( + "armeabi-v7a" to 1, + "arm64-v8a" to 2, + "x86" to 3, + "x86_64" to 4, + "universal" to 5 +) + +val getGitHash = providers + .exec { + commandLine("git", "rev-parse", "--short", "HEAD") + } + .standardOutput + .asText + .map { it.trim() } + +val firebaseEnabledVariants = listOf("play", "fdroid") +val nonPlayVariants = listOf("fdroid", "website") + if (huaweiEnabled) listOf("huawei") else emptyList() +val nonDebugBuildTypes = listOf("release", "qa", "automaticQa") + +fun VariantDimension.devNetDefaultOn(defaultOn: Boolean) { + val fqEnumClass = "org.session.libsession.utilities.Environment" + buildConfigField( + fqEnumClass, + "DEFAULT_ENVIRONMENT", + if (defaultOn) "$fqEnumClass.DEV_NET" else "$fqEnumClass.MAIN_NET" + ) +} + +fun VariantDimension.enablePermissiveNetworkSecurityConfig(permissive: Boolean) { + manifestPlaceholders["network_security_config"] = if (permissive) { + "@xml/network_security_configuration_permissive" + } else { + "@xml/network_security_configuration" + } +} + +fun VariantDimension.setAlternativeAppName(alternative: String?) { + if (alternative != null) { + manifestPlaceholders["app_name"] = alternative + } else { + manifestPlaceholders["app_name"] = "@string/app_name" + } +} + +fun VariantDimension.setAuthorityPostfix(postfix: String) { + manifestPlaceholders["authority_postfix"] = postfix + buildConfigField("String", "AUTHORITY_POSTFIX", "\"$postfix\"") +} + +kotlin { + compilerOptions { + jvmToolchain(21) + } +} + +protobuf { + protoc { + artifact = libs.protoc.get().toString() + } + + plugins { + generateProtoTasks { + all().forEach { + it.builtins { + create("java") { + } + } + } + } + } +} + +android { + namespace = "network.loki.messenger" + useLibrary("org.apache.http.legacy") + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + packaging { + resources.excludes += listOf( + "LICENSE.txt", "LICENSE", "NOTICE", "asm-license.txt", + "META-INF/LICENSE", "META-INF/NOTICE", "META-INF/proguard/androidx-annotations.pro" + ) + } + + splits { + abi { + isEnable = !huaweiEnabled + reset() + include("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + isUniversalApk = true + } + } + + buildFeatures { + viewBinding = true + buildConfig = true + } + + defaultConfig { + versionCode = canonicalVersionCode * postFixSize + versionName = canonicalVersionName + + compileSdk = libs.versions.androidCompileSdkVersion.get().toInt() + minSdk = libs.versions.androidMinSdkVersion.get().toInt() + targetSdk = libs.versions.androidTargetSdkVersion.get().toInt() + + multiDexEnabled = true + + vectorDrawables.useSupportLibrary = true + + buildConfigField("long", "BUILD_TIMESTAMP", "${getLastCommitTimestamp()}L") + buildConfigField("String", "GIT_HASH", "\"${getGitHash.get()}\"") + buildConfigField("String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\"") + buildConfigField("int", "CONTENT_PROXY_PORT", "443") + buildConfigField("String", "USER_AGENT", "\"OWA\"") + buildConfigField("int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode") + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["clearPackageData"] = "true" + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + + devNetDefaultOn(false) + enablePermissiveNetworkSecurityConfig(false) + setAlternativeAppName(null) + setAuthorityPostfix("") + } + + create("qa") { + initWith(getByName("release")) + + matchingFallbacks += "release" + + signingConfig = signingConfigs.getByName("debug") + applicationIdSuffix = ".$name" + + devNetDefaultOn(false) + enablePermissiveNetworkSecurityConfig(true) + + setAlternativeAppName("Session QA") + setAuthorityPostfix(".qa") + } + + create("automaticQa") { + initWith(getByName("qa")) + + devNetDefaultOn(true) + setAlternativeAppName("Session AQA") + } + + getByName("debug") { + isDefault = true + isMinifyEnabled = false + enableUnitTestCoverage = false + signingConfig = signingConfigs.getByName("debug") + + applicationIdSuffix = ".${name}" + enablePermissiveNetworkSecurityConfig(true) + devNetDefaultOn(false) + setAlternativeAppName("Session Debug") + setAuthorityPostfix(".debug") + } + } + + sourceSets { + getByName("test").apply { + java.srcDirs("$projectDir/src/sharedTest/java") + resources.srcDirs("$projectDir/src/main/assets") + } + + val firebaseCommonDir = "src/firebaseCommon" + firebaseEnabledVariants.forEach { variant -> + maybeCreate(variant).java.srcDirs("$firebaseCommonDir/kotlin") + } + + val nonPlayCommonDir = "src/nonPlayCommon" + nonPlayVariants.forEach { variant -> + maybeCreate(variant).apply { + java.srcDirs("$nonPlayCommonDir/kotlin") + resources.srcDirs("$nonPlayCommonDir/resources") + } + } + + val nonDebugDir = "src/nonDebug" + nonDebugBuildTypes.forEach { buildType -> + maybeCreate(buildType).apply { + java.srcDirs("$nonDebugDir/kotlin") + resources.srcDirs("$nonDebugDir/resources") + } + } + } + + + signingConfigs { + create("play") { + if (project.hasProperty("SESSION_STORE_FILE")) { + storeFile = file(project.property("SESSION_STORE_FILE")!!) + storePassword = project.property("SESSION_STORE_PASSWORD") as? String + keyAlias = project.property("SESSION_KEY_ALIAS") as? String + keyPassword = project.property("SESSION_KEY_PASSWORD") as? String + } + } + + if (huaweiEnabled) { + create("huawei") { + if (project.hasProperty("SESSION_HUAWEI_STORE_FILE")) { + storeFile = file(project.property("SESSION_HUAWEI_STORE_FILE")!!) + storePassword = project.property("SESSION_HUAWEI_STORE_PASSWORD") as? String + keyAlias = project.property("SESSION_HUAWEI_KEY_ALIAS") as? String + keyPassword = project.property("SESSION_HUAWEI_KEY_PASSWORD") as? String + } + } + } + + getByName("debug") { + storeFile = file("${rootProject.projectDir}/etc/debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + + + flavorDimensions += "distribution" + productFlavors { + create("play") { + isDefault = true + dimension = "distribution" + buildConfigField("boolean", "PLAY_STORE_DISABLED", "false") + buildConfigField("org.session.libsession.utilities.Device", "DEVICE", "org.session.libsession.utilities.Device.ANDROID") + buildConfigField("String", "PUSH_KEY_SUFFIX", "\"\"") + signingConfig = signingConfigs.getByName("play") + } + + create("fdroid") { + initWith(getByName("play")) + } + + if (huaweiEnabled) { + create("huawei") { + dimension = "distribution" + buildConfigField("boolean", "PLAY_STORE_DISABLED", "true") + buildConfigField("org.session.libsession.utilities.Device", "DEVICE", "org.session.libsession.utilities.Device.HUAWEI") + buildConfigField("String", "PUSH_KEY_SUFFIX", "\"_HUAWEI\"") + signingConfig = signingConfigs.getByName("huawei") + } + } + + create("website") { + initWith(getByName("play")) + + buildConfigField("boolean", "PLAY_STORE_DISABLED", "true") + } + + } + + testOptions { + unitTests.isIncludeAndroidResources = true + } + + lint { + abortOnError = true + baseline = file("lint-baseline.xml") + } + + tasks.register("testPlayDebugUnitTestCoverageReport") { + dependsOn("testPlayDebugUnitTest") + + reports { + xml.required.set(true) + } + + val fileFilter = emptyList() + val mainSrc = "$projectDir/src/main/java" + val buildDir = project.layout.buildDirectory.get().asFile + val kotlinDebugTree = fileTree("${buildDir}/tmp/kotlin-classes/playDebug") { + exclude(fileFilter) + } + + classDirectories.setFrom(files(kotlinDebugTree)) + sourceDirectories.setFrom(files(mainSrc)) + executionData.setFrom(file("${buildDir}/outputs/unit_test_code_coverage/playDebugUnitTest/testPlayDebugUnitTest.exec")) + } + + testNamespace = "network.loki.messenger.test" +} + +dependencies { + implementation(project(":content-descriptions")) + + ksp(libs.androidx.hilt.compiler) + ksp(libs.dagger.hilt.compiler) + ksp(libs.glide.ksp) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.hilt.work) + implementation(libs.roundedimageview) + implementation(libs.hilt.android) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.recyclerview) + implementation(libs.material) + implementation(libs.flexbox) + implementation(libs.androidx.cardview) + implementation(libs.androidx.legacy.preference.v14) + implementation(libs.androidx.preference.ktx) + implementation(libs.androidx.exifinterface) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.lifecycle.process) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.core.ktx) + + // Add firebase dependencies to specific variants + for (variant in firebaseEnabledVariants) { + val configuration = configurations.maybeCreate("${variant}Implementation") + configuration(libs.firebase.messaging) { + 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") + } + } + + val playImplementation = configurations.maybeCreate("playImplementation") + playImplementation(libs.google.play.review) + playImplementation(libs.google.play.review.ktx) + + if (huaweiEnabled) { + val huaweiImplementation = configurations.maybeCreate("huaweiImplementation") + huaweiImplementation(libs.huawei.push) + } + + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.ui) + implementation(libs.conscrypt.android) + implementation(libs.android) + implementation(libs.photoview) + implementation(libs.glide) + implementation(libs.compose) + implementation(libs.eventbus) + implementation(libs.android.image.cropper) + implementation(libs.subsampling.scale.image.view) { + exclude(group = "com.android.support", module = "support-annotations") + } + implementation(libs.tooltips) { + exclude(group = "com.android.support", module = "appcompat-v7") + } + implementation(libs.stream) + implementation(libs.androidx.sqlite.ktx) + implementation(libs.sqlcipher.android) + implementation(libs.kotlinx.serialization.json) + implementation(libs.protobuf.java) + implementation(libs.jackson.databind) + implementation(libs.okhttp) + implementation(platform(libs.ktor.bom)) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.phrase) + implementation(libs.copper.flow) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kovenant) + implementation(libs.kovenant.android) + implementation(libs.opencsv) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.rxbinding) + + if (hasIncludedLibSessionUtilProject) { + implementation( + group = libs.libsession.util.android.get().group, + name = libs.libsession.util.android.get().name, + version = "dev-snapshot" + ) + } else { + implementation(libs.libsession.util.android) + } + + implementation(libs.kryo) + testImplementation(libs.junit) + testImplementation(libs.assertj.core) + testImplementation(libs.mockito.kotlin) + androidTestImplementation(libs.mockito.core) + androidTestImplementation(libs.mockito.kotlin) + testImplementation(libs.androidx.core) + testImplementation(libs.androidx.core.testing) + testImplementation(libs.kotlinx.coroutines.testing) + androidTestImplementation(libs.kotlinx.coroutines.testing) + androidTestImplementation(libs.androidx.core) + androidTestImplementation(libs.androidx.runner) + androidTestImplementation(libs.androidx.rules) + + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.truth) + testImplementation(libs.truth) + androidTestImplementation(libs.truth) + testRuntimeOnly(libs.mockito.core) + + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.espresso.contrib) + androidTestImplementation(libs.androidx.espresso.intents) + androidTestImplementation(libs.androidx.espresso.accessibility) + androidTestImplementation(libs.androidx.espresso.web) + androidTestImplementation(libs.androidx.idling.concurrent) + androidTestImplementation(libs.androidx.espresso.idling.resource) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) + androidTestUtil(libs.androidx.orchestrator) + + testImplementation(libs.robolectric) + testImplementation(libs.robolectric.shadows.multidex) + testImplementation(libs.conscrypt.openjdk.uber) + testImplementation(libs.turbine) + + implementation(platform(libs.androidx.compose.bom)) + testImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(platform(libs.androidx.compose.bom)) + + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.animation) + implementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.foundation.layout) + implementation(libs.androidx.compose.material3) + + androidTestImplementation(libs.androidx.ui.test.junit4.android) + debugImplementation(libs.androidx.compose.ui.test.manifest) + + implementation(libs.androidx.navigation.compose) + + implementation(libs.accompanist.permissions) + + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + + implementation(libs.zxing.core) + + implementation(libs.androidx.biometric) + + debugImplementation(libs.sqlite.web.viewer) +} + +fun getLastCommitTimestamp(): String { + return ByteArrayOutputStream().use { os -> + os.toString() + "000" + } +} + +fun autoResConfig(): List { + val files = mutableListOf() + val root = file("src/main/res") + root.listFiles()?.forEach { files.add(it.name) } + return listOf("en") + files.mapNotNull { it.takeIf { f -> f.startsWith("values-") }?.substringAfter("values-") } + .sorted() +} + +// Assign version code postfix to APKs based on ABI +androidComponents { + onVariants { variant -> + variant.outputs.forEach { output -> + val abiName = output.filters.firstOrNull { it.filterType == FilterConfiguration.FilterType.ABI }?.identifier ?: "universal" + val versionCodeAdditions = checkNotNull(abiPostFix[abiName]) { "$abiName does not exist" } + output.versionCode.set(canonicalVersionCode * postFixSize + versionCodeAdditions) + } + } +} + +// Only enable google services tasks for firebase-enabled variants +androidComponents { + finalizeDsl { + tasks.named { it.contains("GoogleServices") } + .configureEach { + enabled = firebaseEnabledVariants.any { name.contains(it, true) } + } + } +} \ No newline at end of file diff --git a/app/google-services.json b/app/google-services.json index 8ad85fda32..77993eb564 100644 --- a/app/google-services.json +++ b/app/google-services.json @@ -3,7 +3,7 @@ "project_number": "43512467490", "firebase_url": "https://loki-5a81e.firebaseio.com", "project_id": "loki-5a81e", - "storage_bucket": "loki-5a81e.appspot.com" + "storage_bucket": "loki-5a81e.firebasestorage.app" }, "client": [ { @@ -13,12 +13,45 @@ "package_name": "network.loki.messenger" } }, - "oauth_client": [ + "oauth_client": [], + "api_key": [ { - "client_id": "43512467490-5hlnkf47svrlsb79h5f0hmct6afv1ep7.apps.googleusercontent.com", - "client_type": 3 + "current_key": "AIzaSyBPvEo_fyUsb3nuqhh20EwJGt_UXaVITrE" } ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:43512467490:android:bb1470d5bad72eb919ba7b", + "android_client_info": { + "package_name": "network.loki.messenger.debug" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBPvEo_fyUsb3nuqhh20EwJGt_UXaVITrE" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:43512467490:android:ccdcc593e39b3e4a19ba7b", + "android_client_info": { + "package_name": "network.loki.messenger.qa" + } + }, + "oauth_client": [], "api_key": [ { "current_key": "AIzaSyBPvEo_fyUsb3nuqhh20EwJGt_UXaVITrE" @@ -26,19 +59,7 @@ ], "services": { "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "43512467490-5hlnkf47svrlsb79h5f0hmct6afv1ep7.apps.googleusercontent.com", - "client_type": 3 - }, - { - "client_id": "43512467490-f04dj1ssk2medqq3t33odidvmi9bi9ob.apps.googleusercontent.com", - "client_type": 2, - "ios_info": { - "bundle_id": "com.loki-project.loki-messenger" - } - } - ] + "other_platform_oauth_client": [] } } } diff --git a/app/ipToCode.gradle.kts b/app/ipToCode.gradle.kts deleted file mode 100644 index 9ec2b29806..0000000000 --- a/app/ipToCode.gradle.kts +++ /dev/null @@ -1,41 +0,0 @@ -import java.io.File -import java.io.DataOutputStream -import java.io.FileOutputStream - -task("ipToCode") { - val inputFile = File("${projectDir}/geolite2_country_blocks_ipv4.csv") - - val outputDir = "${buildDir}/generated/binary" - val outputFile = File(outputDir, "geolite2_country_blocks_ipv4.bin").apply { parentFile.mkdirs() } - - outputs.file(outputFile) - - doLast { - - // Ensure the input file exists - if (!inputFile.exists()) { - throw IllegalArgumentException("Input file does not exist: ${inputFile.absolutePath}") - } - - // Create a DataOutputStream to write binary data - DataOutputStream(FileOutputStream(outputFile)).use { out -> - inputFile.useLines { lines -> - var prevCode = -1 - lines.drop(1).forEach { line -> - runCatching { - val ints = line.split(".", "/", ",") - val code = ints[5].toInt().also { if (it == prevCode) return@forEach } - val ip = ints.take(4).fold(0) { acc, s -> acc shl 8 or s.toInt() } - - out.writeInt(ip) - out.writeInt(code) - - prevCode = code - } - } - } - } - - println("Processed data written to: ${outputFile.absolutePath}") - } -} diff --git a/app/src/debug/kotlin/org/thoughtcrime/securesms/debugmenu/DatabaseInspector.kt b/app/src/debug/kotlin/org/thoughtcrime/securesms/debugmenu/DatabaseInspector.kt new file mode 100644 index 0000000000..03ab028b64 --- /dev/null +++ b/app/src/debug/kotlin/org/thoughtcrime/securesms/debugmenu/DatabaseInspector.kt @@ -0,0 +1,56 @@ +package org.thoughtcrime.securesms.debugmenu + +import dev.fanchao.sqliteviewer.StartedInstance +import dev.fanchao.sqliteviewer.model.SupportQueryable +import dev.fanchao.sqliteviewer.startDatabaseViewerServer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.util.CurrentActivityObserver +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +class DatabaseInspector @Inject constructor( + @param:ManagerScope private val coroutineScope: CoroutineScope, + private val currentActivityObserver: CurrentActivityObserver, + private val openHelper: Provider, +) { + val available: Boolean get() = true + + private val instance = MutableStateFlow(null) + + val enabled: StateFlow = instance + .flatMapLatest { st -> + st?.state?.map { + it is StartedInstance.State.Running + } ?: flowOf(false) + } + .stateIn(coroutineScope, SharingStarted.Eagerly, false) + + fun start() { + instance.update { inst -> + inst?.takeIf { it.state.value !is StartedInstance.State.Stopped } ?: startDatabaseViewerServer( + currentActivityObserver.currentActivity.value!!, + port = 3000, + queryable = SupportQueryable(openHelper.get().writableDatabase) + ) + } + } + + fun stop() { + instance.update { + it?.stop() + null + } + } +} \ No newline at end of file diff --git a/app/src/fdroid/AndroidManifest.xml b/app/src/fdroid/AndroidManifest.xml new file mode 100644 index 0000000000..b3e5ead475 --- /dev/null +++ b/app/src/fdroid/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushModule.kt b/app/src/firebaseCommon/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushModule.kt similarity index 100% rename from app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushModule.kt rename to app/src/firebaseCommon/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushModule.kt diff --git a/app/src/firebaseCommon/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushService.kt b/app/src/firebaseCommon/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushService.kt new file mode 100644 index 0000000000..03ffc104b3 --- /dev/null +++ b/app/src/firebaseCommon/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushService.kt @@ -0,0 +1,30 @@ +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.messaging.notifications.TokenFetcher +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.util.DateUtils +import javax.inject.Inject + +private const val TAG = "FirebasePushNotificationService" + +@AndroidEntryPoint +class FirebasePushService : FirebaseMessagingService() { + + @Inject lateinit var pushReceiver: PushReceiver + @Inject lateinit var handler: PushRegistrationHandler + @Inject lateinit var tokenFetcher: TokenFetcher + @Inject lateinit var dateUtils: DateUtils + + override fun onNewToken(token: String) { + Log.d(TAG, "New FCM token") + tokenFetcher.onNewToken(token) + } + + override fun onMessageReceived(message: RemoteMessage) { + Log.d(TAG, "Received a firebase push notification: $message - Priority received: ${message.priority} (Priority expected: ${message.originalPriority}) - Sent time: ${dateUtils.getLocaleFormattedDate(message.sentTime, "HH:mm:ss.SSS")}") + pushReceiver.onPushDataReceived(message.data) + } +} diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebaseTokenFetcher.kt b/app/src/firebaseCommon/kotlin/org/thoughtcrime/securesms/notifications/FirebaseTokenFetcher.kt similarity index 80% rename from app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebaseTokenFetcher.kt rename to app/src/firebaseCommon/kotlin/org/thoughtcrime/securesms/notifications/FirebaseTokenFetcher.kt index 2c4746bc2a..eac37db634 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebaseTokenFetcher.kt +++ b/app/src/firebaseCommon/kotlin/org/thoughtcrime/securesms/notifications/FirebaseTokenFetcher.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.notifications import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.tasks.await import org.session.libsession.messaging.notifications.TokenFetcher import javax.inject.Inject import javax.inject.Singleton @@ -19,4 +20,8 @@ class FirebaseTokenFetcher @Inject constructor(): TokenFetcher { override fun onNewToken(token: String) { this.token.value = token } + + override suspend fun resetToken() { + FirebaseMessaging.getInstance().deleteToken().await() + } } \ No newline at end of file diff --git a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt index 2c32cb5a29..20d8e52631 100644 --- a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt +++ b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.session.libsession.messaging.notifications.TokenFetcher +import org.session.libsignal.utilities.Log import javax.inject.Inject import javax.inject.Singleton @@ -25,13 +26,22 @@ class HuaweiTokenFetcher @Inject constructor( this.token.value = token } + override suspend fun resetToken() { + withContext(Dispatchers.Default) { + HmsInstanceId.getInstance(context).deleteToken(APP_ID, TOKEN_SCOPE) + } + } + init { GlobalScope.launch { - val instanceId = HmsInstanceId.getInstance(context) - withContext(Dispatchers.Default) { - instanceId.getToken(APP_ID, TOKEN_SCOPE) + try { + val instanceId = HmsInstanceId.getInstance(context) + withContext(Dispatchers.Default) { + instanceId.getToken(APP_ID, TOKEN_SCOPE) + } + } catch (e: Exception) { + Log.e("HuaweiTokenFetcher", "Failed to fetch token", e) } } - } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 295d20ad21..e73979c12d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,10 +3,10 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> - + @@ -31,24 +31,20 @@ - + - + + + - - - @@ -57,6 +53,7 @@ + @@ -67,7 +64,6 @@ - @@ -77,21 +73,19 @@ - - + + tools:replace="android:allowBackup,android:label"> - @@ -141,10 +135,16 @@ android:exported="false" android:label="@string/sessionMessageRequests" android:screenOrientation="portrait" /> + + android:label="@string/sessionSettings" > + + + - - - - + + @@ -268,9 +265,9 @@ android:launchMode="singleTask" android:theme="@style/NoAnimation.Theme.AppCompat.Light.DarkActionBar" /> @@ -311,23 +307,29 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" android:exported="true" android:theme="@style/Theme.Session.DayNight.NoActionBar" /> - + android:theme="@style/Theme.Session.DayNight.NoActionBar"> + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000..700fd67cde Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/libsession/src/main/java/org/session/libsession/avatars/AvatarHelper.java b/app/src/main/java/org/session/libsession/avatars/AvatarHelper.java similarity index 96% rename from libsession/src/main/java/org/session/libsession/avatars/AvatarHelper.java rename to app/src/main/java/org/session/libsession/avatars/AvatarHelper.java index cc0909a592..bac2381db5 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/AvatarHelper.java +++ b/app/src/main/java/org/session/libsession/avatars/AvatarHelper.java @@ -45,7 +45,7 @@ public static void delete(@NonNull Context context, @NonNull Address address) { File avatarDirectory = new File(context.getFilesDir(), AVATAR_DIRECTORY); avatarDirectory.mkdirs(); - return new File(avatarDirectory, new File(address.serialize()).getName()); + return new File(avatarDirectory, new File(address.toString()).getName()); } public static boolean avatarFileExists(@NonNull Context context , @NonNull Address address) { diff --git a/libsession/src/main/java/org/session/libsession/avatars/ContactColors.java b/app/src/main/java/org/session/libsession/avatars/ContactColors.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/avatars/ContactColors.java rename to app/src/main/java/org/session/libsession/avatars/ContactColors.java diff --git a/libsession/src/main/java/org/session/libsession/avatars/ContactPhoto.java b/app/src/main/java/org/session/libsession/avatars/ContactPhoto.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/avatars/ContactPhoto.java rename to app/src/main/java/org/session/libsession/avatars/ContactPhoto.java diff --git a/libsession/src/main/java/org/session/libsession/avatars/FallbackContactPhoto.java b/app/src/main/java/org/session/libsession/avatars/FallbackContactPhoto.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/avatars/FallbackContactPhoto.java rename to app/src/main/java/org/session/libsession/avatars/FallbackContactPhoto.java diff --git a/libsession/src/main/java/org/session/libsession/avatars/GroupRecordContactPhoto.java b/app/src/main/java/org/session/libsession/avatars/GroupRecordContactPhoto.java similarity index 97% rename from libsession/src/main/java/org/session/libsession/avatars/GroupRecordContactPhoto.java rename to app/src/main/java/org/session/libsession/avatars/GroupRecordContactPhoto.java index f03e1ee1e6..e033b55f23 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/GroupRecordContactPhoto.java +++ b/app/src/main/java/org/session/libsession/avatars/GroupRecordContactPhoto.java @@ -53,7 +53,7 @@ public boolean isProfilePhoto() { @Override public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { - messageDigest.update(address.serialize().getBytes()); + messageDigest.update(address.toString().getBytes()); messageDigest.update(Conversions.longToByteArray(avatarId)); } diff --git a/app/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt b/app/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt new file mode 100644 index 0000000000..bbb2f62391 --- /dev/null +++ b/app/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt @@ -0,0 +1,17 @@ +package org.session.libsession.avatars + +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import com.bumptech.glide.load.Key +import java.security.MessageDigest + +class PlaceholderAvatarPhoto( + val hashString: String, + val displayName: String, + val bitmap: BitmapDrawable + ): Key { + override fun updateDiskCacheKey(messageDigest: MessageDigest) { + messageDigest.update(hashString.encodeToByteArray()) + messageDigest.update(displayName.encodeToByteArray()) + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/avatars/ProfileContactPhoto.java b/app/src/main/java/org/session/libsession/avatars/ProfileContactPhoto.java similarity index 96% rename from libsession/src/main/java/org/session/libsession/avatars/ProfileContactPhoto.java rename to app/src/main/java/org/session/libsession/avatars/ProfileContactPhoto.java index 76a3449625..86669f9d41 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/ProfileContactPhoto.java +++ b/app/src/main/java/org/session/libsession/avatars/ProfileContactPhoto.java @@ -41,7 +41,7 @@ public boolean isProfilePhoto() { @Override public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { - messageDigest.update(address.serialize().getBytes()); + messageDigest.update(address.toString().getBytes()); messageDigest.update(avatarObject.getBytes()); } diff --git a/libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java b/app/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java similarity index 95% rename from libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java rename to app/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java index f76135e95a..bd16b0744e 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java +++ b/app/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java @@ -13,7 +13,7 @@ import com.makeramen.roundedimageview.RoundedDrawable; -import org.session.libsession.R; +import network.loki.messenger.R; import org.session.libsession.utilities.ThemeUtil; public class ResourceContactPhoto implements FallbackContactPhoto { @@ -43,7 +43,7 @@ public Drawable asDrawable(Context context, int color, boolean inverted, Float p if(padding == 0f){ foreground.setScaleType(ImageView.ScaleType.CENTER_CROP); } else { - // apply padding via a transparent border oterhwise things get misaligned + // apply padding via a transparent border otherhwise things get misaligned foreground.setScaleType(ImageView.ScaleType.FIT_CENTER); foreground.setBorderColor(Color.TRANSPARENT); foreground.setBorderWidth(padding); diff --git a/libsession/src/main/java/org/session/libsession/avatars/SystemContactPhoto.java b/app/src/main/java/org/session/libsession/avatars/SystemContactPhoto.java similarity index 97% rename from libsession/src/main/java/org/session/libsession/avatars/SystemContactPhoto.java rename to app/src/main/java/org/session/libsession/avatars/SystemContactPhoto.java index 55abfff6e1..f4d31f7055 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/SystemContactPhoto.java +++ b/app/src/main/java/org/session/libsession/avatars/SystemContactPhoto.java @@ -44,7 +44,7 @@ public boolean isProfilePhoto() { @Override public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { - messageDigest.update(address.serialize().getBytes()); + messageDigest.update(address.toString().getBytes()); messageDigest.update(contactPhotoUri.toString().getBytes()); messageDigest.update(Conversions.longToByteArray(lastModifiedTime)); } diff --git a/libsession/src/main/java/org/session/libsession/avatars/TransparentContactPhoto.java b/app/src/main/java/org/session/libsession/avatars/TransparentContactPhoto.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/avatars/TransparentContactPhoto.java rename to app/src/main/java/org/session/libsession/avatars/TransparentContactPhoto.java diff --git a/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt b/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt similarity index 79% rename from libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt rename to app/src/main/java/org/session/libsession/database/MessageDataProvider.kt index b0c3bc7780..dbe35e6f4f 100644 --- a/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt +++ b/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt @@ -12,24 +12,21 @@ import org.session.libsession.utilities.UploadResult import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.messages.SignalServiceAttachmentPointer import org.session.libsignal.messages.SignalServiceAttachmentStream +import org.thoughtcrime.securesms.database.model.MessageId import java.io.InputStream interface MessageDataProvider { - fun getMessageID(serverID: Long): Long? - /** - * @return pair of sms or mms table-specific ID and whether it is in SMS table - */ - fun getMessageID(serverId: Long, threadId: Long): Pair? + fun getMessageID(serverId: Long, threadId: Long): MessageId? fun getMessageIDs(serverIDs: List, threadID: Long): Pair, List> fun getUserMessageHashes(threadId: Long, userPubKey: String): List - fun deleteMessage(messageID: Long, isSms: Boolean) + fun deleteMessage(messageId: MessageId) fun deleteMessages(messageIDs: List, threadId: Long, isSms: Boolean) - fun markMessageAsDeleted(timestamp: Long, author: String, displayedMessage: String) - fun markMessagesAsDeleted(messages: List, isSms: Boolean, displayedMessage: String) + fun markMessageAsDeleted(messageId: MessageId, displayedMessage: String) + fun markMessagesAsDeleted(messages: List, displayedMessage: String) fun markMessagesAsDeleted(threadId: Long, serverHashes: List, displayedMessage: String) fun markUserMessagesAsDeleted(threadId: Long, until: Long, sender: String, displayedMessage: String) - fun getServerHashForMessage(messageID: Long, mms: Boolean): String? + fun getServerHashForMessage(messageID: MessageId): String? fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? fun getAttachmentStream(attachmentId: Long): SessionServiceAttachmentStream? fun getAttachmentPointer(attachmentId: Long): SessionServiceAttachmentPointer? @@ -39,15 +36,14 @@ interface MessageDataProvider { fun setAttachmentState(attachmentState: AttachmentState, attachmentId: AttachmentId, messageID: Long) fun insertAttachment(messageId: Long, attachmentId: AttachmentId, stream : InputStream) fun updateAudioAttachmentDuration(attachmentId: AttachmentId, durationMs: Long, threadId: Long) - fun isMmsOutgoing(mmsMessageId: Long): Boolean - fun isOutgoingMessage(timestamp: Long): Boolean - fun isDeletedMessage(timestamp: Long): Boolean + fun isOutgoingMessage(id: MessageId): Boolean + fun isDeletedMessage(id: MessageId): Boolean fun handleSuccessfulAttachmentUpload(attachmentId: Long, attachmentStream: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult) fun handleFailedAttachmentUpload(attachmentId: Long) fun getMessageForQuote(timestamp: Long, author: Address): Triple? fun getAttachmentsAndLinkPreviewFor(mmsId: Long): List fun getMessageBodyFor(timestamp: Long, author: String): String - fun getAttachmentIDsFor(messageID: Long): List - fun getLinkPreviewAttachmentIDFor(messageID: Long): Long? + fun getAttachmentIDsFor(mmsMessageId: Long): List + fun getLinkPreviewAttachmentIDFor(mmsMessageId: Long): Long? fun getIndividualRecipientForMms(mmsId: Long): Recipient? } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/database/ServerHashToMessageId.kt b/app/src/main/java/org/session/libsession/database/ServerHashToMessageId.kt similarity index 81% rename from libsession/src/main/java/org/session/libsession/database/ServerHashToMessageId.kt rename to app/src/main/java/org/session/libsession/database/ServerHashToMessageId.kt index 84fc643702..c32c64063c 100644 --- a/libsession/src/main/java/org/session/libsession/database/ServerHashToMessageId.kt +++ b/app/src/main/java/org/session/libsession/database/ServerHashToMessageId.kt @@ -1,5 +1,7 @@ package org.session.libsession.database +import org.thoughtcrime.securesms.database.model.MessageId + data class ServerHashToMessageId( val serverHash: String, /** @@ -8,7 +10,6 @@ data class ServerHashToMessageId( * meaning of this field. */ val sender: String, - val messageId: Long, - val isSms: Boolean, + val messageId: MessageId, val isOutgoing: Boolean, ) \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt similarity index 81% rename from libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt rename to app/src/main/java/org/session/libsession/database/StorageProtocol.kt index bd545cfebf..3937726489 100644 --- a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -2,8 +2,7 @@ package org.session.libsession.database import android.content.Context import android.net.Uri -import com.goterl.lazysodium.utils.KeyPair -import network.loki.messenger.libsession_util.util.GroupDisplayInfo +import network.loki.messenger.libsession_util.util.KeyPair import org.session.libsession.messaging.BlindedIdMapping import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.contacts.Contact @@ -12,7 +11,6 @@ import org.session.libsession.messaging.jobs.Job import org.session.libsession.messaging.jobs.MessageSendJob 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.GroupUpdated import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.visible.Attachment @@ -21,7 +19,6 @@ 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 @@ -29,14 +26,17 @@ import org.session.libsession.messaging.sending_receiving.link_preview.LinkPrevi import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.utilities.Address +import org.session.libsession.utilities.GroupDisplayInfo import org.session.libsession.utilities.GroupRecord -import org.session.libsession.utilities.recipients.MessageType import org.session.libsession.utilities.recipients.Recipient 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 org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.ReactionRecord import network.loki.messenger.libsession_util.util.Contact as LibSessionContact import network.loki.messenger.libsession_util.util.GroupMember as LibSessionGroupMember @@ -46,6 +46,7 @@ interface StorageProtocol { fun getUserPublicKey(): String? fun getUserED25519KeyPair(): KeyPair? fun getUserX25519KeyPair(): ECKeyPair + fun getUserBlindedAccountId(serverPublicKey: String): AccountId? fun getUserProfile(): Profile fun setProfilePicture(recipient: Recipient, newProfilePicture: String?, newProfileKey: ByteArray?) fun setBlocksCommunityMessageRequests(recipient: Recipient, blocksMessageRequests: Boolean) @@ -61,7 +62,6 @@ interface StorageProtocol { fun getAllPendingJobs(vararg types: String): Map fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob? fun getMessageSendJob(messageSendJobID: String): MessageSendJob? - fun getMessageReceiveJob(messageReceiveJobID: String): Job? fun getGroupAvatarDownloadJob(server: String, room: String, imageId: String?): Job? fun resumeMessageSendJobIfNeeded(messageSendJobID: String) fun isJobCanceled(job: Job): Boolean @@ -80,12 +80,11 @@ interface StorageProtocol { fun getAllOpenGroups(): Map fun updateOpenGroup(openGroup: OpenGroup) fun getOpenGroup(threadId: Long): OpenGroup? - suspend fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? + suspend fun addOpenGroup(urlAsString: String) fun onOpenGroupAdded(server: String, room: String) fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean - fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean) + fun setOpenGroupServerMessageID(messageID: MessageId, serverID: Long, threadID: Long) fun getOpenGroup(room: String, server: String): OpenGroup? - fun setGroupMemberRoles(members: List) // Open Group Public Keys fun getOpenGroupPublicKey(server: String): String? @@ -113,25 +112,17 @@ interface StorageProtocol { fun getReceivedMessageTimestamps(): Set fun addReceivedMessageTimestamp(timestamp: Long) fun removeReceivedMessageTimestamps(timestamps: Set) - /** - * Returns the IDs of the saved attachments. - */ - fun persistAttachments(messageID: Long, attachments: List): List - fun getAttachmentsForMessage(messageID: Long): List - fun getMessageIdInDatabase(timestamp: Long, author: String): Pair? // TODO: This is a weird name - fun getMessageType(timestamp: Long, author: String): MessageType? - fun updateSentTimestamp(messageID: Long, isMms: Boolean, openGroupSentTimestamp: Long, threadId: Long) - fun markAsResyncing(timestamp: Long, author: String) - fun markAsSyncing(timestamp: Long, author: String) - fun markAsSending(timestamp: Long, author: String) - fun markAsSent(timestamp: Long, author: String) - fun markAsSentToCommunity(threadID: Long, messageID: Long) - fun markUnidentified(timestamp: Long, author: String) - fun markUnidentifiedInCommunity(threadID: Long, messageID: Long) - fun markAsSyncFailed(timestamp: Long, author: String, error: Exception) - fun markAsSentFailed(timestamp: Long, author: String, error: Exception) - fun clearErrorMessage(messageID: Long) - fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) + fun getAttachmentsForMessage(mmsMessageId: Long): List + fun getMessageBy(timestamp: Long, author: String): MessageRecord? + fun updateSentTimestamp(messageId: MessageId, newTimestamp: Long) + fun markAsResyncing(messageId: MessageId) + fun markAsSyncing(messageId: MessageId) + fun markAsSending(messageId: MessageId) + fun markAsSent(messageId: MessageId) + fun markAsSyncFailed(messageId: MessageId, error: Exception) + fun markAsSentFailed(messageId: MessageId, error: Exception) + fun clearErrorMessage(messageID: MessageId) + fun setMessageServerHash(messageId: MessageId, serverHash: String) // Legacy Closed Groups fun getGroup(groupID: String): GroupRecord? @@ -150,7 +141,6 @@ interface StorageProtocol { fun removeClosedGroupPublicKey(groupPublicKey: String) fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String) - fun removeClosedGroupThread(threadID: Long) fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, sentTimestamp: Long): Long? @@ -167,16 +157,17 @@ interface StorageProtocol { // Closed Groups fun getMembers(groupPublicKey: String): List fun getClosedGroupDisplayInfo(groupAccountId: String): GroupDisplayInfo? - fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId): Long? - fun insertGroupInfoLeaving(closedGroup: AccountId): Long? - fun insertGroupInfoErrorQuit(closedGroup: AccountId): Long? + fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId) + fun insertGroupInfoLeaving(closedGroup: AccountId) + fun insertGroupInfoErrorQuit(closedGroup: AccountId) fun insertGroupInviteControlMessage( sentTimestamp: Long, senderPublicKey: String, senderName: String?, closedGroup: AccountId, groupName: String - ): Long? + ) + fun updateGroupInfoChange(messageId: Long, newType: UpdateMessageData.Kind) fun deleteGroupInfoMessages(groupId: AccountId, kind: Class) @@ -198,8 +189,10 @@ interface StorageProtocol { fun trimThread(threadID: Long, threadLimit: Int) fun trimThreadBefore(threadID: Long, timestamp: Long) fun getMessageCount(threadID: Long): Long + fun getTotalPinned(): Int fun setPinned(threadID: Long, isPinned: Boolean) fun isPinned(threadID: Long): Boolean + fun isRead(threadId: Long) : Boolean fun deleteConversation(threadID: Long) fun setThreadCreationDate(threadId: Long, newDate: Long) fun getLastLegacyRecipient(threadRecipient: String): String? @@ -209,15 +202,13 @@ interface StorageProtocol { // Contacts fun getContactWithAccountID(accountID: String): Contact? - fun getContactNameWithAccountID(accountID: String, groupId: AccountId? = null, contactContext: Contact.ContactContext = Contact.ContactContext.REGULAR): String - fun getContactNameWithAccountID(contact: Contact?, accountID: String, groupId: AccountId? = null, contactContext: Contact.ContactContext = Contact.ContactContext.REGULAR): String fun getAllContacts(): Set fun setContact(contact: Contact) + fun deleteContactAndSyncConfig(accountId: String) fun getRecipientForThread(threadId: Long): Recipient? fun getRecipientSettings(address: Address): RecipientSettings? - fun addLibSessionContacts(contacts: List, timestamp: Long?) + fun syncLibSessionContacts(contacts: List, timestamp: Long?) fun hasAutoDownloadFlagBeenSet(recipient: Recipient): Boolean - fun addContacts(contacts: List) fun shouldAutoDownloadAttachments(recipient: Recipient): Boolean fun setAutoDownloadAttachments(recipient: Recipient, shouldAutoDownloadAttachments: Boolean) @@ -229,8 +220,9 @@ interface StorageProtocol { /** * Returns the ID of the `TSIncomingMessage` that was constructed. */ - fun persist(message: VisibleMessage, quotes: QuoteModel?, linkPreview: List, groupPublicKey: String?, openGroupID: String?, attachments: List, runThreadUpdate: Boolean): Long? + fun persist(message: VisibleMessage, quotes: QuoteModel?, linkPreview: List, groupPublicKey: String?, openGroupID: String?, attachments: List, runThreadUpdate: Boolean): MessageId? fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean = false) + fun markConversationAsUnread(threadId: Long) fun getLastSeen(threadId: Long): Long fun ensureMessageHashesAreSender(hashes: Set, sender: String, closedGroupId: String): Boolean fun updateThread(threadId: Long, unarchive: Boolean) @@ -244,6 +236,7 @@ interface StorageProtocol { fun conversationHasOutgoing(userPublicKey: String): Boolean fun deleteMessagesByHash(threadId: Long, hashes: List) fun deleteMessagesByUser(threadId: Long, userSessionId: String) + fun clearAllMessages(threadId: Long): List // Last Inbox Message Id fun getLastInboxMessageId(server: String): Long? @@ -256,17 +249,31 @@ interface StorageProtocol { fun removeLastOutboxMessageId(server: String) fun getOrCreateBlindedIdMapping(blindedId: String, server: String, serverPublicKey: String, fromOutbox: Boolean = false): BlindedIdMapping - fun addReaction(reaction: Reaction, messageSender: String, notifyUnread: Boolean) - fun removeReaction(emoji: String, messageTimestamp: Long, author: String, notifyUnread: Boolean) + /** + * Add reaction to a message that has the timestamp given by [reaction]. This is less than + * ideal because timestamp it not a very good identifier for a message, but it is the best we can do + * if the swarm doesn't give us anything else. [threadId] will help narrow down the message. + */ + fun addReaction(threadId: Long, reaction: Reaction, messageSender: String, notifyUnread: Boolean) + + /** + * Add reaction to a specific message. This is preferable to the timestamp lookup. + */ + fun addReaction(messageId: MessageId, reaction: Reaction, messageSender: String) + + /** + * Add reactions into the database. If [replaceAll] is true, + * it will remove all existing reactions that belongs to the same message(s). + */ + fun addReactions(reactions: Map>, replaceAll: Boolean, notifyUnread: Boolean) + fun removeReaction(emoji: String, messageTimestamp: Long, threadId: Long, author: String, notifyUnread: Boolean) fun updateReactionIfNeeded(message: Message, sender: String, openGroupSentTimestamp: Long) - fun deleteReactions(messageId: Long, mms: Boolean) + fun deleteReactions(messageId: MessageId) fun deleteReactions(messageIds: List, mms: Boolean) fun setBlocked(recipients: Iterable, isBlocked: Boolean, fromConfigUpdate: Boolean = false) - fun setRecipientHash(recipient: Recipient, recipientHash: String?) fun blockedContacts(): List fun getExpirationConfiguration(threadId: Long): ExpirationConfiguration? fun setExpirationConfiguration(config: ExpirationConfiguration) - fun getExpiringMessages(messageIds: List = emptyList()): List> fun updateDisappearingState( messageSender: String, threadID: Long, diff --git a/app/src/main/java/org/session/libsession/database/StorageProtocolExt.kt b/app/src/main/java/org/session/libsession/database/StorageProtocolExt.kt new file mode 100644 index 0000000000..90f26d1c96 --- /dev/null +++ b/app/src/main/java/org/session/libsession/database/StorageProtocolExt.kt @@ -0,0 +1,16 @@ +package org.session.libsession.database + +import org.session.libsession.snode.OwnedSwarmAuth +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.toHexString + +val StorageProtocol.userAuth: OwnedSwarmAuth? + get() = getUserPublicKey()?.let { accountId -> + getUserED25519KeyPair()?.let { keyPair -> + OwnedSwarmAuth( + accountId = AccountId(hexString = accountId), + ed25519PublicKeyHex = keyPair.pubKey.data.toHexString(), + ed25519PrivateKey = keyPair.secretKey.data + ) + } + } diff --git a/libsession/src/main/java/org/session/libsession/messaging/BlindedIdMapping.kt b/app/src/main/java/org/session/libsession/messaging/BlindedIdMapping.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/BlindedIdMapping.kt rename to app/src/main/java/org/session/libsession/messaging/BlindedIdMapping.kt diff --git a/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt b/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt new file mode 100644 index 0000000000..f4674817d4 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt @@ -0,0 +1,52 @@ +package org.session.libsession.messaging + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager +import org.session.libsession.messaging.jobs.MessageSendJob +import org.session.libsession.messaging.notifications.TokenFetcher +import org.session.libsession.snode.SnodeClock +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.Device +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.UsernameUtils +import org.thoughtcrime.securesms.pro.ProStatusManager +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MessagingModuleConfiguration @Inject constructor( + @param:ApplicationContext val context: Context, + val storage: StorageProtocol, + val device: Device, + val messageDataProvider: MessageDataProvider, + val configFactory: ConfigFactoryProtocol, + val tokenFetcher: TokenFetcher, + val groupManagerV2: GroupManagerV2, + val clock: SnodeClock, + val preferences: TextSecurePreferences, + val deprecationManager: LegacyGroupDeprecationManager, + val usernameUtils: UsernameUtils, + val proStatusManager: ProStatusManager, + val messageSendJobFactory: MessageSendJob.Factory, +) { + + companion object { + @JvmStatic + @Deprecated("Use properly DI components instead") + val shared: MessagingModuleConfiguration + get() = context.getSystemService(MESSAGING_MODULE_SERVICE) as MessagingModuleConfiguration + + const val MESSAGING_MODULE_SERVICE: String = "MessagingModuleConfiguration_MESSAGING_MODULE_SERVICE" + + private lateinit var context: Context + + @JvmStatic + fun configure(context: Context) { + this.context = context + } + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/calls/CallMessageType.kt b/app/src/main/java/org/session/libsession/messaging/calls/CallMessageType.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/calls/CallMessageType.kt rename to app/src/main/java/org/session/libsession/messaging/calls/CallMessageType.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/contacts/Contact.kt b/app/src/main/java/org/session/libsession/messaging/contacts/Contact.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/contacts/Contact.kt rename to app/src/main/java/org/session/libsession/messaging/contacts/Contact.kt diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt new file mode 100644 index 0000000000..06313e73e4 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt @@ -0,0 +1,183 @@ +package org.session.libsession.messaging.file_server + +import android.util.Base64 +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import nl.komponents.kovenant.Promise +import nl.komponents.kovenant.functional.map +import okhttp3.Headers.Companion.toHeaders +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.utilities.await +import org.session.libsignal.utilities.ByteArraySlice +import org.session.libsignal.utilities.HTTP +import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.toHexString +import kotlin.time.Duration.Companion.milliseconds + +object FileServerApi { + + private const val FILE_SERVER_PUBLIC_KEY = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59" + const val FILE_SERVER_URL = "http://filev2.getsession.org" + const val MAX_FILE_SIZE = 10_000_000 // 10 MB + + val fileServerUrl: HttpUrl by lazy { FILE_SERVER_URL.toHttpUrl() } + + sealed class Error(message: String) : Exception(message) { + object ParsingFailed : Error("Invalid response.") + object InvalidURL : Error("Invalid URL.") + object NoEd25519KeyPair : Error("Couldn't find ed25519 key pair.") + } + + data class Request( + val verb: HTTP.Verb, + val endpoint: String, + val queryParameters: Map = mapOf(), + val parameters: Any? = null, + val headers: Map = mapOf(), + val body: ByteArray? = null, + /** + * Always `true` under normal circumstances. You might want to disable + * this when running over Lokinet. + */ + val useOnionRouting: Boolean = true + ) + + private fun createBody(body: ByteArray?, parameters: Any?): RequestBody? { + if (body != null) return RequestBody.create("application/octet-stream".toMediaType(), body) + if (parameters == null) return null + val parametersAsJSON = JsonUtil.toJson(parameters) + return RequestBody.create("application/json".toMediaType(), parametersAsJSON) + } + + + private fun send(request: Request): Promise { + val urlBuilder = fileServerUrl + .newBuilder() + .addPathSegments(request.endpoint) + if (request.verb == HTTP.Verb.GET) { + for ((key, value) in request.queryParameters) { + urlBuilder.addQueryParameter(key, value) + } + } + val requestBuilder = okhttp3.Request.Builder() + .url(urlBuilder.build()) + .headers(request.headers.toHeaders()) + when (request.verb) { + HTTP.Verb.GET -> requestBuilder.get() + HTTP.Verb.PUT -> requestBuilder.put(createBody(request.body, request.parameters)!!) + HTTP.Verb.POST -> requestBuilder.post(createBody(request.body, request.parameters)!!) + HTTP.Verb.DELETE -> requestBuilder.delete(createBody(request.body, request.parameters)) + } + return if (request.useOnionRouting) { + OnionRequestAPI.sendOnionRequest(requestBuilder.build(), FILE_SERVER_URL, FILE_SERVER_PUBLIC_KEY).map { + val body = it.body ?: throw Error.ParsingFailed + + SendResponse( + body = body, + headers = it.info["headers"] as? Map + ) + }.fail { 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.")) + } + } + + fun upload(file: ByteArray, customHeaders: Map = mapOf()): Promise { + val request = Request( + verb = HTTP.Verb.POST, + endpoint = "file", + body = file, + headers = mapOf( + "Content-Disposition" to "attachment", + "Content-Type" to "application/octet-stream" + ) + customHeaders + ) + return send(request).map { response -> + val json = JsonUtil.fromJson(response.body, Map::class.java) + val hasId = json.containsKey("id") + val id = json.getOrDefault("id", null) + Log.d("Loki-FS", "File Upload Response hasId: $hasId of type: ${id?.javaClass}") + val idLong = (id as? String)?.toLong() ?: throw Error.ParsingFailed + val ttl = json.getOrDefault("expires", null) as? Number + Log.d("Loki-FS", "File Upload Response expires (timestamp in milli): ${ttl?.let{ it.toLong() * 1000 }}") + + UploadResult( + id = idLong, + ttlTimestamp = ttl?.let{ (it.toDouble() * 1000).toLong() } // from seconds to milliseconds + ) + } + } + + fun download(file: String): Promise { + val request = Request(verb = HTTP.Verb.GET, endpoint = "file/$file") + return send(request) + } + + /** + * Returns the current version of session + * This is effectively proxying (and caching) the response from the github release + * page. + * + * Note that the value is cached and can be up to 30 minutes out of date normally, and up to 24 + * hours out of date if we cannot reach the Github API for some reason. + * + * https://github.com/session-foundation/session-file-server/blob/dev/doc/api.yaml#L119 + */ + suspend fun getClientVersion(): VersionData { + // Generate the auth signature + val secretKey = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()?.secretKey?.data + ?: throw (Error.NoEd25519KeyPair) + + val blindedKeys = BlindKeyAPI.blindVersionKeyPair(secretKey) + val timestamp = System.currentTimeMillis().milliseconds.inWholeSeconds // The current timestamp in seconds + val signature = BlindKeyAPI.blindVersionSign(secretKey, timestamp) + + // The hex encoded version-blinded public key with a 07 prefix + val blindedPkHex = "07" + blindedKeys.pubKey.data.toHexString() + + val request = Request( + verb = HTTP.Verb.GET, + endpoint = "session_version", + queryParameters = mapOf("platform" to "android"), + headers = mapOf( + "X-FS-Pubkey" to blindedPkHex, + "X-FS-Timestamp" to timestamp.toString(), + "X-FS-Signature" to Base64.encodeToString(signature, Base64.NO_WRAP) + ) + ) + + // transform the promise into a coroutine + val result = send(request).await() + + // map out the result + return JsonUtil.fromJson(result.body, Map::class.java).let { + VersionData( + statusCode = it["status_code"] as? Int ?: 0, + version = it["result"] as? String ?: "", + updated = it["updated"] as? Double ?: 0.0 + ) + } + } + + data class UploadResult( + val id: Long, + val ttlTimestamp: Long? + ) + + data class SendResponse( + val body: ByteArraySlice, + val headers: Map? + ) +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/file_server/VersionData.kt b/app/src/main/java/org/session/libsession/messaging/file_server/VersionData.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/file_server/VersionData.kt rename to app/src/main/java/org/session/libsession/messaging/file_server/VersionData.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt b/app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt similarity index 91% rename from libsession/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt rename to app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt index 9b0adf8f7d..415a4a6f51 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt +++ b/app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt @@ -2,12 +2,13 @@ package org.session.libsession.messaging.groups import android.content.Context import com.squareup.phrase.Phrase -import org.session.libsession.R +import network.loki.messenger.R import org.session.libsession.database.StorageProtocol import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_KEY +import org.session.libsession.utilities.UsernameUtils import org.session.libsession.utilities.truncateIdForDisplay /** @@ -30,9 +31,9 @@ class GroupInviteException( } } - fun format(context: Context, storage: StorageProtocol): CharSequence { + fun format(context: Context, usernameUtils: UsernameUtils): CharSequence { val getInviteeName = { accountId: String -> - storage.getContactNameWithAccountID(accountId) + usernameUtils.getContactNameWithAccountID(accountId) } val first = inviteeAccountIds.first().let(getInviteeName) diff --git a/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt b/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt similarity index 82% rename from libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt rename to app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt index 02bfd32f2c..cd48ece0a1 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt +++ b/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt @@ -1,10 +1,12 @@ package org.session.libsession.messaging.groups +import androidx.annotation.StringRes import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.groups.GroupManagerV2Impl /** * Business logic handling group v2 operations like inviting members, @@ -30,6 +32,12 @@ interface GroupManagerV2 { removeMessages: Boolean ) + /** + * Clears all messages from the group for everyone on the config side + * This does not delete the messages from the local db (this is handled by the storage class. + */ + suspend fun clearAllMessagesForEveryone(groupAccountId: AccountId, deletedHashes: List) + /** * Remove all messages from the group for the given members. * @@ -75,6 +83,7 @@ interface GroupManagerV2 { suspend fun handleKicked(groupId: AccountId) suspend fun setName(groupId: AccountId, newName: String) + suspend fun setDescription(groupId: AccountId, newName: String) /** * Send a request to the group to delete the given messages. @@ -108,4 +117,15 @@ interface GroupManagerV2 { * Should be called whenever a group invite is blocked */ fun onBlocked(groupAccountId: AccountId) + + fun getLeaveGroupConfirmationDialogData(groupId: AccountId, name: String): ConfirmDialogData? + + data class ConfirmDialogData( + val title: String, + val message: CharSequence, + @StringRes val positiveText: Int, + @StringRes val negativeText: Int, + @StringRes val positiveQaTag: Int?, + @StringRes val negativeQaTag: Int?, + ) } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/groups/GroupScope.kt b/app/src/main/java/org/session/libsession/messaging/groups/GroupScope.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/groups/GroupScope.kt rename to app/src/main/java/org/session/libsession/messaging/groups/GroupScope.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/groups/LegacyGroupDeprecationManager.kt b/app/src/main/java/org/session/libsession/messaging/groups/LegacyGroupDeprecationManager.kt similarity index 95% rename from libsession/src/main/java/org/session/libsession/messaging/groups/LegacyGroupDeprecationManager.kt rename to app/src/main/java/org/session/libsession/messaging/groups/LegacyGroupDeprecationManager.kt index e8c8635dc8..f4ced43980 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/groups/LegacyGroupDeprecationManager.kt +++ b/app/src/main/java/org/session/libsession/messaging/groups/LegacyGroupDeprecationManager.kt @@ -14,8 +14,13 @@ import org.session.libsession.utilities.TextSecurePreferences import java.time.Duration import java.time.ZoneId import java.time.ZonedDateTime +import javax.inject.Inject +import javax.inject.Singleton -class LegacyGroupDeprecationManager(private val prefs: TextSecurePreferences) { +@Singleton +class LegacyGroupDeprecationManager + @Inject constructor(private val prefs: TextSecurePreferences +) { private val mutableDeprecationStateOverride = MutableStateFlow( DeprecationState.entries.firstOrNull { it.name == prefs.deprecationStateOverride } ) diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt new file mode 100644 index 0000000000..49f3f24796 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -0,0 +1,257 @@ +package org.session.libsession.messaging.jobs + +import android.content.Context +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol +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.AttachmentState +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment +import org.session.libsession.messaging.utilities.Data +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.utilities.await +import org.session.libsession.utilities.DecodedAudio +import org.session.libsession.utilities.DownloadUtilities +import org.session.libsession.utilities.InputStreamMediaDataSource +import org.session.libsignal.streams.AttachmentCipherInputStream +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.HTTP +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.model.MessageId +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.InputStream + +class AttachmentDownloadJob @AssistedInject constructor( + @Assisted("attachmentID") val attachmentID: Long, + @Assisted val mmsMessageId: Long, + @param:ApplicationContext private val context: Context, + private val storage: StorageProtocol, + private val messageDataProvider: MessageDataProvider +) : Job { + override var delegate: JobDelegate? = null + override var id: String? = null + override var failureCount: Int = 0 + + // Error + internal sealed class Error(val description: String) : Exception(description) { + object NoAttachment : Error("No such attachment.") + object NoThread: Error("Thread no longer exists") + object NoSender: Error("Thread recipient or sender does not exist") + object DuplicateData: Error("Attachment already downloaded") + } + + // Settings + override val maxFailureCount: Int = 2 + + companion object { + 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" + + /** + * Check if the attachment in the given message is eligible for download. + * + * Note that this function only checks for the eligibility of the attachment in the sense + * of whether the download is allowed, it does not check if the download has already taken + * place. + */ + fun eligibleForDownload(threadID: Long, + storage: StorageProtocol, + messageDataProvider: MessageDataProvider, + mmsId: Long): Boolean { + val threadRecipient = storage.getRecipientForThread(threadID) ?: return false + + // if we are the sender we are always eligible + val selfSend = messageDataProvider.isOutgoingMessage(MessageId(mmsId, true)) + if (selfSend) { + return true + } + + return storage.shouldAutoDownloadAttachments(threadRecipient) + } + } + + override suspend fun execute(dispatcherName: String) { + val threadID = storage.getThreadIdForMms(mmsMessageId) + + val handleFailure: (java.lang.Exception, attachmentId: AttachmentId?) -> Unit = { exception, attachment -> + if(exception is HTTP.HTTPRequestFailedException && exception.statusCode == 404){ + attachment?.let { id -> + Log.d("AttachmentDownloadJob", "Setting attachment state = failed, have attachment") + messageDataProvider.setAttachmentState(AttachmentState.EXPIRED, id, mmsMessageId) + } ?: run { + Log.d("AttachmentDownloadJob", "Setting attachment state = failed, don't have attachment") + messageDataProvider.setAttachmentState(AttachmentState.EXPIRED, AttachmentId(attachmentID,0), mmsMessageId) + } + } else if (exception == Error.NoAttachment + || exception == Error.NoThread + || exception == Error.NoSender + || (exception is OnionRequestAPI.HTTPRequestFailedAtDestinationException && exception.statusCode == 400)) { + attachment?.let { id -> + Log.d("AttachmentDownloadJob", "Setting attachment state = failed, have attachment") + messageDataProvider.setAttachmentState(AttachmentState.FAILED, id, mmsMessageId) + } ?: run { + Log.d("AttachmentDownloadJob", "Setting attachment state = failed, don't have attachment") + messageDataProvider.setAttachmentState(AttachmentState.FAILED, AttachmentId(attachmentID,0), mmsMessageId) + } + this.handlePermanentFailure(dispatcherName, exception) + } else if (exception == Error.DuplicateData) { + attachment?.let { id -> + Log.d("AttachmentDownloadJob", "Setting attachment state = done from duplicate data") + messageDataProvider.setAttachmentState(AttachmentState.DONE, id, mmsMessageId) + } ?: run { + Log.d("AttachmentDownloadJob", "Setting attachment state = done from duplicate data") + messageDataProvider.setAttachmentState(AttachmentState.DONE, AttachmentId(attachmentID,0), mmsMessageId) + } + this.handleSuccess(dispatcherName) + } else { + if (failureCount + 1 >= maxFailureCount) { + attachment?.let { id -> + Log.d("AttachmentDownloadJob", "Setting attachment state = failed from max failure count, have attachment") + messageDataProvider.setAttachmentState(AttachmentState.FAILED, id, mmsMessageId) + } ?: run { + Log.d("AttachmentDownloadJob", "Setting attachment state = failed from max failure count, don't have attachment") + messageDataProvider.setAttachmentState(AttachmentState.FAILED, AttachmentId(attachmentID,0), mmsMessageId) + } + } + this.handleFailure(dispatcherName, exception) + } + } + + if (threadID < 0) { + handleFailure(Error.NoThread, null) + return + } + + if (!eligibleForDownload(threadID, storage, messageDataProvider, mmsMessageId)) { + handleFailure(Error.NoSender, null) + return + } + + var tempFile: File? = null + var attachment: DatabaseAttachment? = null + + try { + attachment = messageDataProvider.getDatabaseAttachment(attachmentID) + ?: return handleFailure(Error.NoAttachment, null) + if (attachment.hasData()) { + handleFailure(Error.DuplicateData, attachment.attachmentId) + return + } + messageDataProvider.setAttachmentState(AttachmentState.DOWNLOADING, attachment.attachmentId, this.mmsMessageId) + val openGroup = storage.getOpenGroup(threadID) + val downloadedData = if (openGroup == null) { + Log.d("AttachmentDownloadJob", "downloading normal attachment") + DownloadUtilities.downloadFromFileServer(attachment.url).body + } else { + Log.d("AttachmentDownloadJob", "downloading open group attachment") + val url = attachment.url.toHttpUrlOrNull()!! + val fileID = url.pathSegments.last() + OpenGroupApi.download(fileID, openGroup.room, openGroup.server).await() + } + + tempFile = createTempFile().also { file -> + FileOutputStream(file).use { + it.write(downloadedData.data, downloadedData.offset, downloadedData.len) + } + } + + Log.d("AttachmentDownloadJob", "getting input stream") + val inputStream = getInputStream(tempFile, attachment) + + Log.d("AttachmentDownloadJob", "inserting attachment") + messageDataProvider.insertAttachment(mmsMessageId, attachment.attachmentId, inputStream) + if (attachment.contentType.startsWith("audio/")) { + // process the duration + try { + InputStreamMediaDataSource(getInputStream(tempFile, attachment)).use { mediaDataSource -> + val durationMs = (DecodedAudio.create(mediaDataSource).totalDurationMicroseconds / 1000.0).toLong() + messageDataProvider.updateAudioAttachmentDuration( + attachment.attachmentId, + durationMs, + threadID + ) + } + } catch (e: Exception) { + Log.e("Loki", "Couldn't process audio attachment", e) + } + } + Log.d("AttachmentDownloadJob", "deleting tempfile") + tempFile.delete() + Log.d("AttachmentDownloadJob", "succeeding job") + handleSuccess(dispatcherName) + } catch (e: Exception) { + Log.e("AttachmentDownloadJob", "Error processing attachment download", e) + tempFile?.delete() + return handleFailure(e,attachment?.attachmentId) + } + } + + private fun getInputStream(tempFile: File, attachment: DatabaseAttachment): InputStream { + // Assume we're retrieving an attachment for an open group server if the digest is not set + return if (attachment.digest?.size ?: 0 == 0 || attachment.key.isNullOrEmpty()) { + Log.d("AttachmentDownloadJob", "getting input stream with no attachment digest") + FileInputStream(tempFile) + } else { + Log.d("AttachmentDownloadJob", "getting input stream with attachment digest") + AttachmentCipherInputStream.createForAttachment(tempFile, attachment.size, Base64.decode(attachment.key), attachment.digest) + } + } + + private fun handleSuccess(dispatcherName: String) { + Log.w("AttachmentDownloadJob", "Attachment downloaded successfully.") + delegate?.handleJobSucceeded(this, dispatcherName) + } + + private fun handlePermanentFailure(dispatcherName: String, e: Exception) { + delegate?.handleJobFailedPermanently(this, dispatcherName, e) + } + + private fun handleFailure(dispatcherName: String, e: Exception) { + delegate?.handleJobFailed(this, dispatcherName, e) + } + + private fun createTempFile(): File { + val file = File.createTempFile("push-attachment", "tmp", context.cacheDir) + file.deleteOnExit() + return file + } + + override fun serialize(): Data { + return Data.Builder() + .putLong(ATTACHMENT_ID_KEY, attachmentID) + .putLong(TS_INCOMING_MESSAGE_ID_KEY, mmsMessageId) + .build(); + } + + override fun getFactoryKey(): String { + return KEY + } + + class DeserializeFactory(private val factory: Factory) : Job.DeserializeFactory { + + override fun create(data: Data): AttachmentDownloadJob { + return factory.create( + attachmentID = data.getLong(ATTACHMENT_ID_KEY), + mmsMessageId = data.getLong(TS_INCOMING_MESSAGE_ID_KEY) + ) + } + } + + @AssistedFactory + interface Factory { + fun create( + @Assisted("attachmentID") attachmentID: Long, + mmsMessageId: Long + ): AttachmentDownloadJob + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt similarity index 81% rename from libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt rename to app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt index c08b15e260..04066e1699 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt @@ -3,9 +3,14 @@ package org.session.libsession.messaging.jobs import com.esotericsoftware.kryo.Kryo import com.esotericsoftware.kryo.io.Input import com.esotericsoftware.kryo.io.Output +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import nl.komponents.kovenant.Promise +import nl.komponents.kovenant.functional.map import okio.Buffer -import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.file_server.FileServerApi import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Message @@ -26,7 +31,15 @@ import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.PushAttachmentData import org.session.libsignal.utilities.Util -class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val message: Message, val messageSendJobID: String) : Job { +class AttachmentUploadJob @AssistedInject constructor( + @Assisted val attachmentID: Long, + @Assisted("threadID") val threadID: String, + @Assisted private val message: Message, + @Assisted private val messageSendJobID: String, + private val storage: StorageProtocol, + private val messageDataProvider: MessageDataProvider, + private val messageSendJobFactory: MessageSendJob.Factory, +) : Job { override var delegate: JobDelegate? = null override var id: String? = null override var failureCount: Int = 0 @@ -52,19 +65,18 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess override suspend fun execute(dispatcherName: String) { try { - val storage = MessagingModuleConfiguration.shared.storage - val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider val attachment = messageDataProvider.getScaledSignalAttachmentStream(attachmentID) ?: 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(dispatcherName, attachment, keyAndResult.first, keyAndResult.second) } else { - val keyAndResult = upload(attachment, FileServerApi.server, true) { - FileServerApi.upload(it) + val keyAndResult = upload(attachment, FileServerApi.FILE_SERVER_URL, true) { + FileServerApi.upload(it).map { it.id } } handleSuccess(dispatcherName, attachment, keyAndResult.first, keyAndResult.second) } @@ -95,9 +107,9 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess val outputStreamFactory = if (encrypt) AttachmentCipherOutputStreamFactory(key) else PlaintextOutputStreamFactory() // Create a digesting request body but immediately read it out to a buffer. Doing this makes // it easier to deal with inputStream and outputStreamFactory. - val pad = PushAttachmentData(attachment.contentType, inputStream, length, outputStreamFactory, attachment.listener) + val pad = PushAttachmentData(attachment.contentType, inputStream, length, outputStreamFactory) val contentType = "application/octet-stream" - val drb = DigestingRequestBody(pad.data, pad.outputStreamFactory, contentType, pad.dataSize, pad.listener) + val drb = DigestingRequestBody(pad.data, pad.outputStreamFactory, contentType, pad.dataSize) Log.d("Loki", "File size: ${length.toDouble() / 1000} kb.") val b = Buffer() drb.writeTo(b) @@ -112,26 +124,27 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess private fun handleSuccess(dispatcherName: String, attachment: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult) { Log.d(TAG, "Attachment uploaded successfully.") delegate?.handleJobSucceeded(this, dispatcherName) - val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider messageDataProvider.handleSuccessfulAttachmentUpload(attachmentID, attachment, attachmentKey, uploadResult) - if (attachment.contentType.startsWith("audio/")) { - // process the duration + + // We don't need to calculate the duration for voice notes, as they will have it set already. + if (attachment.contentType.startsWith("audio/") && !attachment.voiceNote) { try { val inputStream = messageDataProvider.getAttachmentStream(attachmentID)!!.inputStream!! InputStreamMediaDataSource(inputStream).use { mediaDataSource -> - val durationMs = (DecodedAudio.create(mediaDataSource).totalDuration / 1000.0).toLong() + val durationMS = (DecodedAudio.create(mediaDataSource).totalDurationMicroseconds / 1000.0).toLong() + Log.d(TAG, "Audio attachment duration calculated as: $durationMS ms") messageDataProvider.getDatabaseAttachment(attachmentID)?.attachmentId?.let { attachmentId -> - messageDataProvider.updateAudioAttachmentDuration(attachmentId, durationMs, threadID.toLong()) + messageDataProvider.updateAudioAttachmentDuration(attachmentId, durationMS, threadID.toLong()) } } } catch (e: Exception) { Log.e("Loki", "Couldn't process audio attachment", e) } } - val storage = MessagingModuleConfiguration.shared.storage + storage.getMessageSendJob(messageSendJobID)?.let { val destination = it.destination as? Destination.OpenGroup ?: return@let - val updatedJob = MessageSendJob( + val updatedJob = messageSendJobFactory.create( message = it.message, destination = Destination.OpenGroup( destination.roomToken, @@ -153,7 +166,7 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess private fun handlePermanentFailure(dispatcherName: String, e: Exception) { Log.w(TAG, "Attachment upload failed permanently due to error: $this.") delegate?.handleJobFailedPermanently(this, dispatcherName, e) - MessagingModuleConfiguration.shared.messageDataProvider.handleFailedAttachmentUpload(attachmentID) + messageDataProvider.handleFailedAttachmentUpload(attachmentID) failAssociatedMessageSendJob(e) } @@ -166,7 +179,6 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess } private fun failAssociatedMessageSendJob(e: Exception) { - val storage = MessagingModuleConfiguration.shared.storage val messageSendJob = storage.getMessageSendJob(messageSendJobID) MessageSender.handleFailedMessageSend(this.message, e) if (messageSendJob != null) { @@ -193,7 +205,7 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess return KEY } - class Factory: Job.Factory { + class DeserializeFactory(private val factory: Factory): Job.DeserializeFactory { override fun create(data: Data): AttachmentUploadJob? { val serializedMessage = data.getByteArray(MESSAGE_KEY) @@ -208,12 +220,22 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess return null } input.close() - return AttachmentUploadJob( - data.getLong(ATTACHMENT_ID_KEY), - data.getString(THREAD_ID_KEY)!!, - message, - data.getString(MESSAGE_SEND_JOB_ID_KEY)!! + return factory.create( + attachmentID = data.getLong(ATTACHMENT_ID_KEY), + threadID = data.getString(THREAD_ID_KEY)!!, + message = message, + messageSendJobID = data.getString(MESSAGE_SEND_JOB_ID_KEY)!! ) } } + + @AssistedFactory + interface Factory { + fun create( + attachmentID: Long, + @Assisted("threadID") threadID: String, + message: Message, + messageSendJobID: String + ): AttachmentUploadJob + } } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt similarity index 96% rename from libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt rename to app/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt index 5f7bb34ce4..09f10936e4 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt @@ -1,6 +1,5 @@ package org.session.libsession.messaging.jobs -import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.open_groups.OpenGroup @@ -58,7 +57,7 @@ class BackgroundGroupAddJob(val joinUrl: String): Job { class DuplicateGroupException: Exception("Current open groups already contains this group") - class Factory : Job.Factory { + class DeserializeFactory : Job.DeserializeFactory { override fun create(data: Data): BackgroundGroupAddJob { return BackgroundGroupAddJob( data.getString(JOIN_URL) diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt new file mode 100644 index 0000000000..b19fff08fe --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt @@ -0,0 +1,377 @@ +package org.session.libsession.messaging.jobs + +import android.content.Context +import com.google.protobuf.ByteString +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import network.loki.messenger.libsession_util.ConfigBase +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.messages.Destination +import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.Message.Companion.senderOrSync +import org.session.libsession.messaging.messages.control.CallMessage +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.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.ReceivedMessageHandler +import org.session.libsession.messaging.sending_receiving.VisibleMessageHandlerContext +import org.session.libsession.messaging.sending_receiving.constructReactionRecords +import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier +import org.session.libsession.messaging.utilities.Data +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.UserConfigType +import org.session.libsignal.protos.UtilProtos +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.ReactionRecord +import kotlin.math.max + +data class MessageReceiveParameters( + val data: ByteArray, + val serverHash: String? = null, + val openGroupMessageServerID: Long? = null, + val reactions: Map? = null, + val closedGroup: Destination.ClosedGroup? = null +) + +class BatchMessageReceiveJob @AssistedInject constructor( + @Assisted private val messages: List, + @Assisted val openGroupID: String?, + private val configFactory: ConfigFactoryProtocol, + private val storage: StorageProtocol, + @param:ApplicationContext private val context: Context, + private val receivedMessageHandler: ReceivedMessageHandler, + private val visibleMessageHandlerContextFactory: VisibleMessageHandlerContext.Factory, + private val messageNotifier: MessageNotifier, +) : Job { + + override var delegate: JobDelegate? = null + override var id: String? = null + override var failureCount: Int = 0 + override val maxFailureCount: Int = 1 // handled in JobQueue onJobFailed + // Failure Exceptions must be retryable if they're a MessageReceiver.Error + val failures = mutableListOf() + + companion object { + const val TAG = "BatchMessageReceiveJob" + const val KEY = "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" + private val SERVER_HASH_KEY = "serverHash" + private val OPEN_GROUP_MESSAGE_SERVER_ID_KEY = "openGroupMessageServerID" + private val OPEN_GROUP_ID_KEY = "open_group_id" + private val CLOSED_GROUP_DESTINATION_KEY = "closed_group_destination" + } + + fun recreateWithNewMessages( + newMessages: List, + ): BatchMessageReceiveJob { + return BatchMessageReceiveJob( + messages = newMessages, + openGroupID = openGroupID, + configFactory = configFactory, + storage = storage, + context = context, + receivedMessageHandler = receivedMessageHandler, + visibleMessageHandlerContextFactory = visibleMessageHandlerContextFactory, + messageNotifier = messageNotifier, + ) + } + + 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 DataExtractionNotification -> false + is MessageRequestResponse -> false + is ExpirationTimerUpdate -> 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 + } + } + } + + override suspend fun execute(dispatcherName: String) { + executeAsync(dispatcherName) + } + + private fun isHidden(message: Message): Boolean { + // if the contact is marked as hidden for 1on1 messages + // and the message's sentTimestamp is earlier than the sentTimestamp of the last config + val publicKey = storage.getUserPublicKey() + if (message.sentTimestamp == null || publicKey == null) return false + + val contactConfigTimestamp = configFactory.getConfigTimestamp(UserConfigType.CONTACTS, publicKey) + + return configFactory.withUserConfigs { configs -> + message.groupPublicKey == null && // not a group + message.openGroupServerMessageID == null && // not a community + // not marked as hidden + configs.contacts.get(message.senderOrSync)?.priority == ConfigBase.PRIORITY_HIDDEN && + // the message's sentTimestamp is earlier than the sentTimestamp of the last config + message.sentTimestamp!! < contactConfigTimestamp + } + } + + suspend fun executeAsync(dispatcherName: String) { + val threadMap = mutableMapOf>() + 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, + currentClosedGroups = currentClosedGroups, + closedGroupSessionId = messageParameters.closedGroup?.publicKey + ) + message.serverHash = serverHash + val parsedParams = ParsedMessage(messageParameters, message, proto) + + if(isHidden(message)) return@forEach + + val threadID = Message.getThreadId( + message = message, + openGroupID = openGroupID, + storage = storage, + shouldCreateThread = shouldCreateThread(parsedParams) + ) ?: NO_THREAD_MAPPING + threadMap.getOrPut(threadID) { mutableListOf() } += parsedParams + } catch (e: Exception) { + 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) + suspend fun processMessages(threadId: Long, messages: List) { + // The LinkedHashMap should preserve insertion order + val messageIds = linkedMapOf>() + val myLastSeen = storage.getLastSeen(threadId) + var updatedLastSeen = myLastSeen.takeUnless { it == -1L } ?: 0 + val handlerContext = visibleMessageHandlerContextFactory.create( + threadId = threadId, + openGroupID = openGroupID, + ) + + val communityReactions = mutableMapOf>() + + messages.forEach { (parameters, message, proto) -> + try { + when (message) { + is VisibleMessage -> { + val isUserBlindedSender = + message.sender == handlerContext.userBlindedKey + + if (message.sender == localUserPublicKey || isUserBlindedSender) { + // use sent timestamp here since that is technically the last one we have + updatedLastSeen = max(updatedLastSeen, message.sentTimestamp!!) + } + val messageId = receivedMessageHandler.handleVisibleMessage( + message = message, + proto = proto, + context = handlerContext, + runThreadUpdate = false, + runProfileUpdate = true + ) + + if (messageId != null && message.reaction == null) { + messageIds[messageId] = Pair( + (message.sender == localUserPublicKey || isUserBlindedSender), + message.hasMention + ) + } + + parameters.openGroupMessageServerID?.let { + constructReactionRecords( + openGroupMessageServerID = it, + context = handlerContext, + reactions = parameters.reactions, + out = communityReactions + ) + } + } + + is UnsendRequest -> { + val deletedMessage = receivedMessageHandler.handleUnsendRequest(message) + + // If we removed a message then ensure it isn't in the 'messageIds' + if (deletedMessage != null) { + messageIds.remove(deletedMessage) + } + } + + else -> receivedMessageHandler.handle( + message = message, + proto = proto, + threadId = threadId, + openGroupID = openGroupID, + groupv2Id = parameters.closedGroup?.publicKey?.let(::AccountId) + ) + } + } 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 + // 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 storedLastSeen = storage.getLastSeen(threadId).let { if (it == -1L) 0 else it } + updatedLastSeen = max(updatedLastSeen, storedLastSeen) + // Only call markConversationAsRead() when lastSeen actually advanced (we sent a message). + // For incoming-only batches (like reactions), skip this to preserve REACTIONS_UNREAD flags + // so the notification system can detect them. Thread updates happen separately below. + if (updatedLastSeen > 0 || storedLastSeen == 0L) { + storage.markConversationAsRead(threadId, updatedLastSeen, force = true) + } + storage.updateThread(threadId, true) + messageNotifier.updateNotification(context, threadId) + + if (communityReactions.isNotEmpty()) { + storage.addReactions(communityReactions, replaceAll = true, notifyUnread = false) + } + } + + coroutineScope { + val withoutDefault = threadMap.entries.filter { it.key != NO_THREAD_MAPPING } + val deferredThreadMap = withoutDefault.map { (threadId, messages) -> + async(Dispatchers.Default) { + processMessages(threadId, messages) + } + } + // await all thread processing + deferredThreadMap.awaitAll() + } + + val noThreadMessages = threadMap[NO_THREAD_MAPPING] ?: listOf() + if (noThreadMessages.isNotEmpty()) { + processMessages(NO_THREAD_MAPPING, noThreadMessages) + } + + if (failures.isEmpty()) { + handleSuccess(dispatcherName) + } else { + handleFailure(dispatcherName) + } + } + + private fun handleSuccess(dispatcherName: String) { + Log.i(TAG, "Completed processing of ${messages.size} messages (id: $id)") + delegate?.handleJobSucceeded(this, dispatcherName) + } + + private fun handleFailure(dispatcherName: String) { + Log.i(TAG, "Handling failure of ${failures.size} messages (${messages.size - failures.size} processed successfully) (id: $id)") + delegate?.handleJobFailed(this, dispatcherName, Exception("One or more jobs resulted in failure")) + } + + override fun serialize(): Data { + val arraySize = messages.size + val dataArrays = UtilProtos.ByteArrayList.newBuilder() + .addAllContent(messages.map(MessageReceiveParameters::data).map(ByteString::copyFrom)) + .build() + val serverHashes = messages.map { it.serverHash.orEmpty() } + val openGroupServerIds = messages.map { it.openGroupMessageServerID ?: -1L } + val closedGroups = messages.map { it.closedGroup?.publicKey.orEmpty() } + return Data.Builder() + .putInt(NUM_MESSAGES_KEY, arraySize) + .putByteArray(DATA_KEY, dataArrays.toByteArray()) + .putString(OPEN_GROUP_ID_KEY, openGroupID) + .putLongArray(OPEN_GROUP_MESSAGE_SERVER_ID_KEY, openGroupServerIds.toLongArray()) + .putStringArray(SERVER_HASH_KEY, serverHashes.toTypedArray()) + .putStringArray(CLOSED_GROUP_DESTINATION_KEY, closedGroups.toTypedArray()) + .build() + } + + override fun getFactoryKey(): String = KEY + + class DeserializeFactory(private val factory: Factory) : Job.DeserializeFactory { + override fun create(data: Data): BatchMessageReceiveJob { + val numMessages = data.getInt(NUM_MESSAGES_KEY) + val dataArrays = data.getByteArray(DATA_KEY) + val contents = + UtilProtos.ByteArrayList.parseFrom(dataArrays).contentList.map(ByteString::toByteArray) + val serverHashes = + if (data.hasStringArray(SERVER_HASH_KEY)) data.getStringArray(SERVER_HASH_KEY) else arrayOf() + val openGroupMessageServerIDs = data.getLongArray(OPEN_GROUP_MESSAGE_SERVER_ID_KEY) + val openGroupID = data.getStringOrDefault(OPEN_GROUP_ID_KEY, null) + val closedGroups = + if (data.hasStringArray(CLOSED_GROUP_DESTINATION_KEY)) data.getStringArray(CLOSED_GROUP_DESTINATION_KEY) + else arrayOf() + + val parameters = (0 until numMessages).map { index -> + val serverHash = serverHashes[index].let { if (it.isEmpty()) null else it } + val serverId = openGroupMessageServerIDs[index].let { if (it == -1L) null else it } + val closedGroup = closedGroups.getOrNull(index)?.let { + if (it.isEmpty()) null else Destination.ClosedGroup(it) + } + MessageReceiveParameters( + data = contents[index], + serverHash = serverHash, + openGroupMessageServerID = serverId, + closedGroup = closedGroup + ) + } + + return factory.create(messages = parameters, openGroupID = openGroupID) + } + } + + @AssistedFactory + interface Factory { + fun create(messages: List, openGroupID: String? = null): BatchMessageReceiveJob + } + +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt similarity index 95% rename from libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt rename to app/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt index b2831e9029..59eccad10f 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt @@ -44,7 +44,7 @@ class GroupAvatarDownloadJob(val server: String, val room: String, val imageId: } val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray()) - storage.updateProfilePicture(groupId, bytes) + storage.updateProfilePicture(groupId, bytes.copyToBytes()) storage.updateTimestampUpdated(groupId, SnodeAPI.nowWithOffset) delegate?.handleJobSucceeded(this, dispatcherName) } catch (e: Exception) { @@ -70,7 +70,7 @@ class GroupAvatarDownloadJob(val server: String, val room: String, val imageId: private const val IMAGE_ID = "imageId" } - class Factory : Job.Factory { + class DeserializeFactory : Job.DeserializeFactory { override fun create(data: Data): GroupAvatarDownloadJob { return GroupAvatarDownloadJob( diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt similarity index 81% rename from libsession/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt rename to app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt index a76101450e..c81f2111c2 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext +import network.loki.messenger.libsession_util.ED25519 import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.groups.GroupInviteException import org.session.libsession.messaging.messages.Destination @@ -14,13 +15,13 @@ import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.getGroup import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInviteMessage import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Log class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array) : Job { @@ -55,13 +56,13 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< // Make the request for this member val memberId = AccountId(memberSessionId) val (groupName, subAccount) = configs.withMutableGroupConfigs(sessionId) { configs -> - configs.groupInfo.getName() to configs.groupKeys.makeSubAccount(memberId) + configs.groupInfo.getName() to configs.groupKeys.makeSubAccount(memberSessionId) } val timestamp = SnodeAPI.nowWithOffset - val signature = SodiumUtilities.sign( - buildGroupInviteSignature(memberId, timestamp), - adminKey + val signature = ED25519.sign( + ed25519PrivateKey = adminKey.data, + message = buildGroupInviteSignature(memberId, timestamp), ) val groupInvite = GroupUpdateInviteMessage.newBuilder() @@ -100,22 +101,33 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< val groupName = configs.withGroupConfigs(sessionId) { it.groupInfo.getName() } ?: configs.getGroup(sessionId)?.name - val failures = results.filter { it.second.isFailure } + // Gather all the exceptions, while keeping track of the invitee account IDs + val failures = results.mapNotNull { (id, result) -> + result.exceptionOrNull()?.let { err -> id to err } + } + // if there are failed invites, display a message // assume job "success" even if we fail, the state of invites is tracked outside of this job if (failures.isNotEmpty()) { // show the failure toast - val storage = MessagingModuleConfiguration.shared.storage - val toaster = MessagingModuleConfiguration.shared.toaster + val (_, firstError) = failures.first() + + // Add the rest of the exceptions as suppressed + for ((_, suppressed) in failures.asSequence().drop(1)) { + firstError.addSuppressed(suppressed) + } + + Log.w("InviteContactsJob", "Failed to invite contacts", firstError) GroupInviteException( isPromotion = false, inviteeAccountIds = failures.map { it.first }, groupName = groupName.orEmpty(), - underlying = failures.first().second.exceptionOrNull()!!, - ).format(MessagingModuleConfiguration.shared.context, storage).let { + underlying = firstError, + ).format(MessagingModuleConfiguration.shared.context, + MessagingModuleConfiguration.shared.usernameUtils).let { withContext(Dispatchers.Main) { - toaster.toast(it, Toast.LENGTH_LONG) + Toast.makeText(MessagingModuleConfiguration.shared.context, it, Toast.LENGTH_LONG).show() } } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/Job.kt b/app/src/main/java/org/session/libsession/messaging/jobs/Job.kt similarity index 93% rename from libsession/src/main/java/org/session/libsession/messaging/jobs/Job.kt rename to app/src/main/java/org/session/libsession/messaging/jobs/Job.kt index 8e9bcf839c..f215d9d235 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/Job.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/Job.kt @@ -23,7 +23,7 @@ interface Job { fun getFactoryKey(): String - interface Factory { + interface DeserializeFactory { fun create(data: Data): T? } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobDelegate.kt b/app/src/main/java/org/session/libsession/messaging/jobs/JobDelegate.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/jobs/JobDelegate.kt rename to app/src/main/java/org/session/libsession/messaging/jobs/JobDelegate.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt b/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt similarity index 97% rename from libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt rename to app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt index a1f5712411..39e20b5409 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt @@ -1,7 +1,6 @@ package org.session.libsession.messaging.jobs import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED @@ -9,7 +8,6 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsignal.utilities.Log -import java.lang.RuntimeException import java.util.Timer import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger @@ -127,7 +125,6 @@ class JobQueue : JobDelegate { is InviteContactsJob, is NotifyPNServerJob, is AttachmentUploadJob, - is GroupLeavingJob, is MessageSendJob -> { txQueue.send(job) } @@ -140,7 +137,7 @@ class JobQueue : JobDelegate { is OpenGroupDeleteJob -> { openGroupQueue.send(job) } - is MessageReceiveJob, is TrimThreadJob, + is TrimThreadJob, is BatchMessageReceiveJob -> { if ((job is BatchMessageReceiveJob && !job.openGroupID.isNullOrEmpty()) || (job is TrimThreadJob && !job.openGroupId.isNullOrEmpty())) { @@ -229,7 +226,6 @@ class JobQueue : JobDelegate { val allJobTypes = listOf( AttachmentUploadJob.KEY, AttachmentDownloadJob.KEY, - MessageReceiveJob.KEY, MessageSendJob.KEY, NotifyPNServerJob.KEY, BatchMessageReceiveJob.KEY, @@ -237,7 +233,6 @@ class JobQueue : JobDelegate { BackgroundGroupAddJob.KEY, OpenGroupDeleteJob.KEY, RetrieveProfileAvatarJob.KEY, - GroupLeavingJob.KEY, InviteContactsJob.KEY, ) allJobTypes.forEach { type -> @@ -266,7 +261,7 @@ class JobQueue : JobDelegate { if (job is BatchMessageReceiveJob && job.failureCount <= 0) { val replacementParameters = job.failures.toList() if (replacementParameters.isNotEmpty()) { - val newJob = BatchMessageReceiveJob(replacementParameters, job.openGroupID) + val newJob = job.recreateWithNewMessages(replacementParameters) newJob.failureCount = job.failureCount + 1 add(newJob) } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt similarity index 76% rename from libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt rename to app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt index 77ef4446b8..44395deca3 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt @@ -3,13 +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 dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.withTimeout -import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.jobs.Job.Companion.MAX_BUFFER_SIZE_BYTES import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Message @@ -23,7 +27,15 @@ import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Log -class MessageSendJob(val message: Message, val destination: Destination, val statusCallback: SendChannel>?) : Job { +class MessageSendJob @AssistedInject constructor( + @Assisted val message: Message, + @Assisted val destination: Destination, + @Assisted val statusCallback: SendChannel>?, + private val attachmentUploadJobFactory: AttachmentUploadJob.Factory, + private val messageDataProvider: MessageDataProvider, + private val storage: StorageProtocol, + private val configFactory: ConfigFactoryProtocol, +) : Job { object AwaitingAttachmentUploadException : Exception("Awaiting attachment upload.") @@ -35,33 +47,27 @@ class MessageSendJob(val message: Message, val destination: Destination, val sta companion object { val TAG = MessageSendJob::class.simpleName - val KEY: String = "MessageSendJob" + const val KEY: String = "MessageSendJob" // Keys used for database storage - private val MESSAGE_KEY = "message" - private val DESTINATION_KEY = "destination" + private const val MESSAGE_KEY = "message" + private const val DESTINATION_KEY = "destination" } override suspend fun execute(dispatcherName: String) { - val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider + val messageId = message.id val message = message as? VisibleMessage - val storage = MessagingModuleConfiguration.shared.storage // do not attempt to send if the message is marked as deleted - message?.sentTimestamp?.let{ - if(messageDataProvider.isDeletedMessage(it)){ - return@execute - } + if (messageId != null && messageDataProvider.isDeletedMessage(messageId)) { + return } - val sentTimestamp = this.message.sentTimestamp - val sender = storage.getUserPublicKey() - if (sentTimestamp != null && sender != null) { - storage.markAsSending(sentTimestamp, sender) - } + messageId?.let(storage::markAsSending) if (message != null) { - if (!messageDataProvider.isOutgoingMessage(message.sentTimestamp!!) && message.reaction == null) return // The message has been deleted + val isOutgoing = messageId != null && messageDataProvider.isOutgoingMessage(messageId) + if (!isOutgoing && message.reaction == null) return // The message has been deleted val attachmentIDs = mutableListOf() attachmentIDs.addAll(message.attachmentIDs) message.quote?.let { it.attachmentID?.let { attachmentID -> attachmentIDs.add(attachmentID) } } @@ -72,7 +78,7 @@ class MessageSendJob(val message: Message, val destination: Destination, val sta if (storage.getAttachmentUploadJob(it.attachmentId.rowId) != null) { // Wait for it to finish } else { - val job = AttachmentUploadJob(it.attachmentId.rowId, message.threadID!!.toString(), message, id!!) + val job = attachmentUploadJobFactory.create(it.attachmentId.rowId, message.threadID!!.toString(), message, id!!) JobQueue.shared.add(job) } } @@ -81,13 +87,13 @@ class MessageSendJob(val message: Message, val destination: Destination, val sta return } // Wait for all attachments to upload before continuing } - val isSync = destination is Destination.Contact && destination.publicKey == sender + val isSync = destination is Destination.Contact && destination.publicKey == storage.getUserPublicKey() try { withTimeout(20_000L) { // Shouldn't send message to group when the group has no keys available if (destination is Destination.ClosedGroup) { - MessagingModuleConfiguration.shared.configFactory + configFactory .waitForGroupEncryptionKeys(AccountId(destination.publicKey)) } @@ -133,13 +139,11 @@ class MessageSendJob(val message: Message, val destination: Destination, val sta } private fun handleFailure(dispatcherName: String, error: Exception) { - Log.w(TAG, "Failed to send $message::class.simpleName.", error) - val message = message as? VisibleMessage - if (message != null) { - if ( - MessagingModuleConfiguration.shared.messageDataProvider.isDeletedMessage(message.sentTimestamp!!) || - !MessagingModuleConfiguration.shared.messageDataProvider.isOutgoingMessage(message.sentTimestamp!!) - ) { + Log.w(TAG, "Failed to send ${message::class.simpleName}.", error) + val messageId = message.id + if (message is VisibleMessage && messageId != null) { + if (messageDataProvider.isDeletedMessage(messageId) || + !messageDataProvider.isOutgoingMessage(messageId)) { return // The message has been deleted } } @@ -170,7 +174,7 @@ class MessageSendJob(val message: Message, val destination: Destination, val sta return KEY } - class Factory : Job.Factory { + class DeserializeFactory(private val factory: Factory) : Job.DeserializeFactory { override fun create(data: Data): MessageSendJob? { val serializedMessage = data.getByteArray(MESSAGE_KEY) @@ -198,7 +202,20 @@ class MessageSendJob(val message: Message, val destination: Destination, val sta } destinationInput.close() // Return - return MessageSendJob(message, destination, statusCallback = null) + return factory.create( + message = message, + destination = destination, + statusCallback = null + ) } } + + @AssistedFactory + interface Factory { + fun create( + message: Message, + destination: Destination, + statusCallback: SendChannel>? = null + ): MessageSendJob + } } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt similarity index 97% rename from libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt rename to app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt index 26a0cfb6e8..62e2fb94bb 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt @@ -79,7 +79,7 @@ class NotifyPNServerJob(val message: SnodeMessage) : Job { return KEY } - class Factory : Job.Factory { + class DeserializeFactory : Job.DeserializeFactory { override fun create(data: Data): NotifyPNServerJob { val serializedMessage = data.getByteArray(MESSAGE_KEY) diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt similarity index 80% rename from libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt rename to app/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt index 271549c410..2153fc5ef5 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt @@ -26,19 +26,19 @@ class OpenGroupDeleteJob(private val messageServerIds: LongArray, private val th // 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) + val (smsMessages, mmsMessages) = dataProvider.getMessageIDs(messageServerIds.toList(), threadId) // Delete the SMS messages - if (messageIds.first.isNotEmpty()) { - dataProvider.deleteMessages(messageIds.first, threadId, true) + if (smsMessages.isNotEmpty()) { + dataProvider.deleteMessages(smsMessages, threadId, true) } // Delete the MMS messages - if (messageIds.second.isNotEmpty()) { - dataProvider.deleteMessages(messageIds.second, threadId, false) + if (mmsMessages.isNotEmpty()) { + dataProvider.deleteMessages(mmsMessages, threadId, false) } - Log.d(TAG, "Deleted ${messageIds.first.size + messageIds.second.size} messages successfully") + Log.d(TAG, "Deleted ${smsMessages.size + mmsMessages.size} messages successfully") delegate?.handleJobSucceeded(this, dispatcherName) } catch (e: Exception) { @@ -55,7 +55,7 @@ class OpenGroupDeleteJob(private val messageServerIds: LongArray, private val th override fun getFactoryKey(): String = KEY - class Factory: Job.Factory { + class DeserializeFactory: Job.DeserializeFactory { override fun create(data: Data): OpenGroupDeleteJob { val messageServerIds = data.getLongArray(MESSAGE_IDS) val threadId = data.getLong(THREAD_ID) diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt new file mode 100644 index 0000000000..27475b0985 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt @@ -0,0 +1,166 @@ +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.AESGCM +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.DownloadUtilities.downloadFromFileServer +import org.session.libsession.utilities.ProfilePictureUtilities +import org.session.libsession.utilities.TextSecurePreferences.Companion.setProfileAvatarId +import org.session.libsession.utilities.TextSecurePreferences.Companion.setProfilePictureURL +import org.session.libsession.utilities.Util.equals +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.exceptions.NonRetryableException +import org.session.libsignal.utilities.HTTP +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Util.SECURE_RANDOM +import java.io.FileOutputStream +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.concurrent.ConcurrentSkipListSet + +class RetrieveProfileAvatarJob( + private val profileAvatar: String?, val recipientAddress: Address, + private val profileKey: ByteArray? +): 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" + private const val PROFILE_KEY = "profileKey" + + val errorUrls = ConcurrentSkipListSet() + + } + + override suspend fun execute(dispatcherName: String) { + val delegate = delegate ?: return Log.w(TAG, "RetrieveProfileAvatarJob has no delegate method to work with!") + if (profileAvatar != null && 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) + + 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.toString()) + + if (recipient.isLocalNumber) { + setProfileAvatarId(context, SECURE_RANDOM.nextInt()) + setProfilePictureURL(context, null) + } + + AvatarHelper.delete(context, recipient.address) + storage.setProfilePicture(recipient, null, null) + return + } + + + try { + val response = downloadFromFileServer(profileAvatar) + val downloaded = response.body + + // if we are getting the avatar for the current user + // use the opportunity to set the expiry for the avatar + // which we can get from the response's headers + if(recipient.isLocalNumber && response.headers != null){ + ProfilePictureUtilities.updateAvatarExpiryTimestamp( + context, + parseHttpDate(response.headers["expires"]) + ) + } + + val decrypted = AESGCM.decrypt( + downloaded.data, + offset = downloaded.offset, + len = downloaded.len, + symmetricKey = profileKey + ) + + FileOutputStream(AvatarHelper.getAvatarFile(context, recipient.address)).use { out -> + out.write(decrypted) + } + + if (recipient.isLocalNumber) { + setProfileAvatarId(context, SECURE_RANDOM.nextInt()) + setProfilePictureURL(context, profileAvatar) + } + + storage.setProfilePicture(recipient, profileAvatar, profileKey) + } + catch (e: NonRetryableException){ + Log.e("Loki", "Failed to download profile avatar from non-retryable error", e) + errorUrls += profileAvatar + return delegate.handleJobFailedPermanently(this, dispatcherName, e) + } + catch (e: Exception) { + if(e is HTTP.HTTPRequestFailedException && e.statusCode == 404){ + Log.e("Loki", "Failed to download profile avatar from non-retryable error", e) + errorUrls += profileAvatar + return delegate.handleJobFailedPermanently(this, dispatcherName, e) + } else { + Log.e("Loki", "Failed to download profile avatar", e) + if (failureCount + 1 >= maxFailureCount) { + errorUrls += profileAvatar + } + return delegate.handleJobFailed(this, dispatcherName, e) + } + } + return delegate.handleJobSucceeded(this, dispatcherName) + } + + fun parseHttpDate(dateString: String?): Long? { + if(dateString == null) return null + return ZonedDateTime + .parse(dateString, DateTimeFormatter.RFC_1123_DATE_TIME) + .toInstant() + .toEpochMilli() + } + + override fun serialize(): Data { + val data = Data.Builder() + .putString(PROFILE_AVATAR_KEY, profileAvatar) + .putString(RECEIPIENT_ADDRESS_KEY, recipientAddress.toString()) + + if (profileKey != null) { + data.putByteArray(PROFILE_KEY, profileKey) + } + + return data.build() + } + + override fun getFactoryKey(): String { + return KEY + } + + class DeserializeFactory: Job.DeserializeFactory { + 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)) + val profileKey = data.getByteArray(PROFILE_KEY) + return RetrieveProfileAvatarJob(profileAvatar, recipientAddress, profileKey) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobInstantiator.kt b/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobInstantiator.kt new file mode 100644 index 0000000000..4a163001dd --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobInstantiator.kt @@ -0,0 +1,18 @@ +package org.session.libsession.messaging.jobs + +import org.session.libsession.messaging.utilities.Data +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SessionJobInstantiator @Inject constructor(factories: SessionJobManagerFactories) { + private val jobFactories by lazy { factories.getSessionJobFactories() } + + fun instantiate(jobFactoryKey: String, data: Data): Job? { + if (jobFactories.containsKey(jobFactoryKey)) { + return jobFactories[jobFactoryKey]?.create(data) + } else { + return null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt b/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt new file mode 100644 index 0000000000..6680f93238 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt @@ -0,0 +1,25 @@ +package org.session.libsession.messaging.jobs + +import javax.inject.Inject + +class SessionJobManagerFactories @Inject constructor( + private val attachmentDownloadJobFactory: AttachmentDownloadJob.Factory, + private val attachmentUploadJobFactory: AttachmentUploadJob.Factory, + private val batchFactory: BatchMessageReceiveJob.Factory, + private val messageSendJobFactory: MessageSendJob.Factory, +) { + + fun getSessionJobFactories(): Map> { + return mapOf( + AttachmentDownloadJob.KEY to AttachmentDownloadJob.DeserializeFactory(attachmentDownloadJobFactory), + AttachmentUploadJob.KEY to AttachmentUploadJob.DeserializeFactory(attachmentUploadJobFactory), + MessageSendJob.KEY to MessageSendJob.DeserializeFactory(messageSendJobFactory), + NotifyPNServerJob.KEY to NotifyPNServerJob.DeserializeFactory(), + TrimThreadJob.KEY to TrimThreadJob.DeserializeFactory(), + BatchMessageReceiveJob.KEY to BatchMessageReceiveJob.DeserializeFactory(batchFactory), + GroupAvatarDownloadJob.KEY to GroupAvatarDownloadJob.DeserializeFactory(), + BackgroundGroupAddJob.KEY to BackgroundGroupAddJob.DeserializeFactory(), + OpenGroupDeleteJob.KEY to OpenGroupDeleteJob.DeserializeFactory(), + ) + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt similarity index 96% rename from libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt rename to app/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt index cc388b0376..5550d0eded 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt @@ -43,7 +43,7 @@ class TrimThreadJob(val threadId: Long, val openGroupId: String?) : Job { override fun getFactoryKey(): String = "TrimThreadJob" - class Factory : Job.Factory { + class DeserializeFactory : Job.DeserializeFactory { override fun create(data: Data): TrimThreadJob { return TrimThreadJob(data.getLong(THREAD_ID), data.getStringOrDefault(OPEN_GROUP_ID, null)) diff --git a/libsession/src/main/java/org/session/libsession/messaging/mentions/Mention.kt b/app/src/main/java/org/session/libsession/messaging/mentions/Mention.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/mentions/Mention.kt rename to app/src/main/java/org/session/libsession/messaging/mentions/Mention.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/mentions/MentionsManager.kt b/app/src/main/java/org/session/libsession/messaging/mentions/MentionsManager.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/mentions/MentionsManager.kt rename to app/src/main/java/org/session/libsession/messaging/mentions/MentionsManager.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt b/app/src/main/java/org/session/libsession/messaging/messages/Destination.kt similarity index 96% rename from libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt rename to app/src/main/java/org/session/libsession/messaging/messages/Destination.kt index 7c40616dec..1e4d66badc 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/Destination.kt @@ -54,7 +54,7 @@ sealed class Destination { } ?: throw Exception("Missing open group for thread with ID: $threadID.") } address.isCommunityInbox -> { - val groupInboxId = GroupUtil.getDecodedGroupID(address.serialize()).split("!") + val groupInboxId = GroupUtil.getDecodedGroupID(address.toString()).split("!") OpenGroupInbox( groupInboxId.dropLast(2).joinToString("!"), groupInboxId.dropLast(1).last(), @@ -62,7 +62,7 @@ sealed class Destination { ) } address.isGroupV2 -> { - ClosedGroup(address.serialize()) + ClosedGroup(address.toString()) } else -> { throw Exception("TODO: Handle legacy closed groups.") diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/ExpirationConfiguration.kt b/app/src/main/java/org/session/libsession/messaging/messages/ExpirationConfiguration.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/messages/ExpirationConfiguration.kt rename to app/src/main/java/org/session/libsession/messaging/messages/ExpirationConfiguration.kt diff --git a/app/src/main/java/org/session/libsession/messaging/messages/MarkAsDeletedMessage.kt b/app/src/main/java/org/session/libsession/messaging/messages/MarkAsDeletedMessage.kt new file mode 100644 index 0000000000..6126cb7c7d --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/messages/MarkAsDeletedMessage.kt @@ -0,0 +1,8 @@ +package org.session.libsession.messaging.messages + +import org.thoughtcrime.securesms.database.model.MessageId + +data class MarkAsDeletedMessage( + val messageId: MessageId, + val isOutgoing: Boolean +) diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt b/app/src/main/java/org/session/libsession/messaging/messages/Message.kt similarity index 95% rename from libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt rename to app/src/main/java/org/session/libsession/messaging/messages/Message.kt index de05b78585..366b49d24a 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/Message.kt @@ -8,15 +8,17 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.snode.SnodeMessage import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType +import org.thoughtcrime.securesms.database.model.MessageId abstract class Message { - var id: Long? = null + var id: MessageId? = null // Message ID in the database. Not all messages will be saved to db. var threadID: Long? = null var sentTimestamp: Long? = null 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 diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt rename to app/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ControlMessage.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/ControlMessage.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/messages/control/ControlMessage.kt rename to app/src/main/java/org/session/libsession/messaging/messages/control/ControlMessage.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt rename to app/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt rename to app/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/GroupUpdated.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/GroupUpdated.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/messages/control/GroupUpdated.kt rename to app/src/main/java/org/session/libsession/messaging/messages/control/GroupUpdated.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt rename to app/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt rename to app/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt rename to app/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt b/app/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt rename to app/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingEncryptedMessage.java b/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingEncryptedMessage.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingEncryptedMessage.java rename to app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingEncryptedMessage.java diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingGroupMessage.java b/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingGroupMessage.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingGroupMessage.java rename to app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingGroupMessage.java diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java b/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java similarity index 89% rename from libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java rename to app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java index d9dc9c1f14..c35c6c7c38 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java +++ b/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java @@ -1,5 +1,6 @@ package org.session.libsession.messaging.messages.signal; +import org.jspecify.annotations.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.attachments.PointerAttachment; @@ -13,6 +14,7 @@ import org.session.libsignal.messages.SignalServiceGroup; import org.session.libsignal.utilities.Hex; import org.session.libsignal.utilities.guava.Optional; +import org.thoughtcrime.securesms.database.model.content.MessageContent; import java.util.Collections; import java.util.LinkedList; @@ -28,10 +30,10 @@ public class IncomingMediaMessage { 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; + @Nullable + private final MessageContent messageContent; private final DataExtractionNotificationInfoMessage dataExtractionNotification; private final QuoteModel quote; @@ -45,18 +47,18 @@ public IncomingMediaMessage(Address from, int subscriptionId, long expiresIn, long expireStartedAt, - boolean expirationUpdate, - boolean unidentified, boolean messageRequestResponse, boolean hasMention, Optional body, Optional group, Optional> attachments, + @Nullable MessageContent messageContent, Optional quote, Optional> sharedContacts, Optional> linkPreviews, Optional dataExtractionNotification) { + this.messageContent = messageContent; this.push = true; this.from = from; this.sentTimeMillis = sentTimeMillis; @@ -64,10 +66,8 @@ public IncomingMediaMessage(Address from, 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; @@ -98,9 +98,9 @@ public static IncomingMediaMessage from(VisibleMessage message, Optional quote, Optional> linkPreviews) { - 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()); + return new IncomingMediaMessage(from, message.getSentTimestamp(), -1, expiresIn, expireStartedAt, + false, message.getHasMention(), Optional.fromNullable(message.getText()), + group, Optional.fromNullable(attachments), null, quote, Optional.absent(), linkPreviews, Optional.absent()); } public int getSubscriptionId() { @@ -123,12 +123,12 @@ public Address getGroupId() { return groupId; } - public boolean isPushMessage() { - return push; + public @Nullable MessageContent getMessageContent() { + return messageContent; } - public boolean isExpirationUpdate() { - return expirationUpdate; + public boolean isPushMessage() { + return push; } public long getSentTimeMillis() { @@ -177,10 +177,6 @@ public List getLinkPreviews() { return linkPreviews; } - public boolean isUnidentified() { - return unidentified; - } - public boolean isMessageRequestResponse() { return messageRequestResponse; } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java b/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java similarity index 98% rename from libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java rename to app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java index 00ad24a149..67b91b103a 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java +++ b/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java @@ -235,9 +235,8 @@ public boolean isUnidentified() { public boolean hasMention() { return hasMention; } - public boolean isCallInfo() { - int callMessageTypeLength = CallMessageType.values().length; - return callType >= 0 && callType < callMessageTypeLength; + public boolean isUnreadCallMessage() { + return callType == CallMessageType.CALL_MISSED.ordinal() || callType == CallMessageType.CALL_FIRST_MISSED.ordinal(); } @Nullable diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingGroupMediaMessage.java b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingGroupMediaMessage.java similarity index 90% rename from libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingGroupMediaMessage.java rename to app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingGroupMediaMessage.java index 86db70f435..8c30df5866 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingGroupMediaMessage.java +++ b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingGroupMediaMessage.java @@ -9,6 +9,7 @@ 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 org.thoughtcrime.securesms.database.model.content.MessageContent; import java.util.LinkedList; import java.util.List; @@ -28,12 +29,13 @@ public OutgoingGroupMediaMessage(@NonNull Recipient recipient, boolean updateMessage, @Nullable QuoteModel quote, @NonNull List contacts, - @NonNull List previews) + @NonNull List previews, + @Nullable MessageContent messageContent) { super(recipient, body, new LinkedList() {{if (avatar != null) add(avatar);}}, sentTime, - DistributionTypes.CONVERSATION, expireIn, expireStartedAt, quote, contacts, previews); + DistributionTypes.CONVERSATION, expireIn, expireStartedAt, quote, contacts, previews, messageContent); this.groupID = groupId; this.isUpdateMessage = updateMessage; diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java similarity index 87% rename from libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java rename to app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java index 85dc0cc6a2..0655855df1 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java +++ b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java @@ -12,11 +12,19 @@ import org.session.libsession.utilities.IdentityKeyMismatch; import org.session.libsession.utilities.NetworkFailure; import org.session.libsession.utilities.recipients.Recipient; +import org.thoughtcrime.securesms.database.model.content.MessageContent; import java.util.Collections; import java.util.LinkedList; import java.util.List; +/** + * Represents an outgoing mms message. Note this class is only used for saving messages + * into the database. We will still use {@link org.session.libsession.messaging.messages.Message} + * as a model when sending the message to the network. + *
+ * See {@link OutgoingTextMessage} for the sms table counterpart. + */ public class OutgoingMediaMessage { private final Recipient recipient; @@ -28,6 +36,8 @@ public class OutgoingMediaMessage { private final long expiresIn; private final long expireStartedAt; private final QuoteModel outgoingQuote; + @Nullable + private final MessageContent messageContent; private final List networkFailures = new LinkedList<>(); private final List identityKeyMismatches = new LinkedList<>(); @@ -42,7 +52,8 @@ public OutgoingMediaMessage(Recipient recipient, String message, @NonNull List contacts, @NonNull List linkPreviews, @NonNull List networkFailures, - @NonNull List identityKeyMismatches) + @NonNull List identityKeyMismatches, + @Nullable MessageContent messageContent) { this.recipient = recipient; this.body = message; @@ -53,6 +64,7 @@ public OutgoingMediaMessage(Recipient recipient, String message, this.expiresIn = expiresIn; this.expireStartedAt = expireStartedAt; this.outgoingQuote = outgoingQuote; + this.messageContent = messageContent; this.contacts.addAll(contacts); this.linkPreviews.addAll(linkPreviews); @@ -70,6 +82,7 @@ public OutgoingMediaMessage(OutgoingMediaMessage that) { this.expiresIn = that.expiresIn; this.expireStartedAt = that.expireStartedAt; this.outgoingQuote = that.outgoingQuote; + this.messageContent = that.messageContent; this.identityKeyMismatches.addAll(that.identityKeyMismatches); this.networkFailures.addAll(that.networkFailures); @@ -91,7 +104,12 @@ public static OutgoingMediaMessage from(VisibleMessage message, } return new OutgoingMediaMessage(recipient, message.getText(), attachments, message.getSentTimestamp(), -1, expiresInMillis, expireStartedAt, DistributionTypes.DEFAULT, outgoingQuote, - Collections.emptyList(), previews, Collections.emptyList(), Collections.emptyList()); + Collections.emptyList(), previews, Collections.emptyList(), Collections.emptyList(), null); + } + + @Nullable + public MessageContent getMessageContent() { + return messageContent; } public Recipient getRecipient() { @@ -114,8 +132,6 @@ public boolean isGroup() { return false; } - public boolean isExpirationUpdate() { return false; } - public long getSentTimeMillis() { return sentTimeMillis; } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingSecureMediaMessage.java b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingSecureMediaMessage.java similarity index 86% rename from libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingSecureMediaMessage.java rename to app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingSecureMediaMessage.java index e93c3c5986..daff026ca4 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingSecureMediaMessage.java +++ b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingSecureMediaMessage.java @@ -8,6 +8,7 @@ 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 org.thoughtcrime.securesms.database.model.content.MessageContent; import java.util.Collections; import java.util.List; @@ -22,9 +23,10 @@ public OutgoingSecureMediaMessage(Recipient recipient, String body, long expireStartedAt, @Nullable QuoteModel quote, @NonNull List contacts, - @NonNull List previews) + @NonNull List previews, + @Nullable MessageContent messageContent) { - super(recipient, body, attachments, sentTimeMillis, -1, expiresIn, expireStartedAt, distributionType, quote, contacts, previews, Collections.emptyList(), Collections.emptyList()); + super(recipient, body, attachments, sentTimeMillis, -1, expiresIn, expireStartedAt, distributionType, quote, contacts, previews, Collections.emptyList(), Collections.emptyList(), messageContent); } public OutgoingSecureMediaMessage(OutgoingMediaMessage base) { diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.java b/app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.java rename to app/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.java diff --git a/app/src/main/java/org/session/libsession/messaging/messages/visible/Attachment.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/Attachment.kt new file mode 100644 index 0000000000..4d3f43f36f --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/messages/visible/Attachment.kt @@ -0,0 +1,122 @@ +package org.session.libsession.messaging.messages.visible + +import android.util.Log +import android.util.Size +import android.webkit.MimeTypeMap +import com.google.protobuf.ByteString +import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment +import org.session.libsignal.messages.SignalServiceAttachmentPointer +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.guava.Optional +import java.io.File +import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment + +class Attachment { + var filename: String? = null + var contentType: String? = null + var key: ByteArray? = null + var digest: ByteArray? = null + var kind: Kind? = null + var caption: String? = null + var size: Size? = null + var sizeInBytes: Int? = 0 + var url: String? = null + + companion object { + + fun fromProto(proto: SignalServiceProtos.AttachmentPointer): Attachment { + val result = Attachment() + + // Note: For legacy Session Android clients this filename will be null and we'll synthesise an appropriate filename + // further down the stack + result.filename = proto.fileName + + fun inferContentType(): String { + val fileName = result.filename + val fileExtension = File(fileName).extension + val mimeTypeMap = MimeTypeMap.getSingleton() + return mimeTypeMap.getMimeTypeFromExtension(fileExtension) ?: "application/octet-stream" + } + result.contentType = proto.contentType ?: inferContentType() + + // If we were given a null filename from a legacy client but we at least have a content type (i.e., mime type) + // then the best we can do is synthesise a filename based on the content type and when we received the file. + if (result.filename.isNullOrEmpty() && !result.contentType.isNullOrEmpty()) { + Log.d("", "*** GOT an empty filename") + //result.filename = generateFilenameFromReceivedTypeForLegacyClients(result.contentType!!) + //todo: Found this part with the code commented out... This 'if' is now doing nothing at all, is that normal? Shouldn't we indeed set a filename here or is that handled further down the line? (can't explore this now so I'm leaving a todo) + } + + result.key = proto.key.toByteArray() + result.digest = proto.digest.toByteArray() + val kind: Kind = if (proto.hasFlags() && proto.flags.and(SignalServiceProtos.AttachmentPointer.Flags.VOICE_MESSAGE_VALUE) > 0) { + Kind.VOICE_MESSAGE + } else { + Kind.GENERIC + } + result.kind = kind + result.caption = if (proto.hasCaption()) proto.caption else null + val size: Size = if (proto.hasWidth() && proto.width > 0 && proto.hasHeight() && proto.height > 0) { + Size(proto.width, proto.height) + } else { + Size(0,0) + } + result.size = size + result.sizeInBytes = if (proto.size > 0) proto.size else null + result.url = proto.url + + return result + } + + fun createAttachmentPointer(attachment: SignalServiceAttachmentPointer): SignalServiceProtos.AttachmentPointer? { + val builder = SignalServiceProtos.AttachmentPointer.newBuilder() + .setContentType(attachment.contentType) + .setId(attachment.id.toString().toLongOrNull() ?: 0L) + .setKey(ByteString.copyFrom(attachment.key)) + .setDigest(ByteString.copyFrom(attachment.digest.get())) + .setSize(attachment.size.get()) + .setUrl(attachment.url) + + // Filenames are now mandatory for picked/shared files, Giphy GIFs, and captured photos. + // The images associated with LinkPreviews don't have a "given name" so we'll use the + // attachment ID as the filename. It's not possible to save these preview images or see + // the filename, so what the filename IS isn't important, only that a filename exists. + builder.fileName = attachment.filename ?: attachment.id.toString() + + if (attachment.preview.isPresent) { builder.thumbnail = ByteString.copyFrom(attachment.preview.get()) } + if (attachment.width > 0) { builder.width = attachment.width } + if (attachment.height > 0) { builder.height = attachment.height } + if (attachment.voiceNote) { builder.flags = SignalServiceProtos.AttachmentPointer.Flags.VOICE_MESSAGE_VALUE } + if (attachment.caption.isPresent) { builder.caption = attachment.caption.get() } + + return builder.build() + } + } + + enum class Kind { + VOICE_MESSAGE, + GENERIC + } + + fun isValid(): Boolean { + // key and digest can be nil for open group attachments + return (contentType != null && kind != null && size != null && sizeInBytes != null && url != null) + } + + fun toProto(): SignalServiceProtos.AttachmentPointer? { + TODO("Not implemented") + } + + fun toSignalAttachment(): SignalAttachment? { + if (!isValid()) return null + return PointerAttachment.forAttachment((this)) + } + + fun toSignalPointer(): SignalServiceAttachmentPointer? { + if (!isValid()) return null + return SignalServiceAttachmentPointer(0, contentType, key, Optional.fromNullable(sizeInBytes), null, + size?.width ?: 0, size?.height ?: 0, Optional.fromNullable(digest), filename, + kind == Kind.VOICE_MESSAGE, Optional.fromNullable(caption), url) + } + +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/visible/LinkPreview.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/LinkPreview.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/messages/visible/LinkPreview.kt rename to app/src/main/java/org/session/libsession/messaging/messages/visible/LinkPreview.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/visible/OpenGroupInvitation.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/OpenGroupInvitation.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/messages/visible/OpenGroupInvitation.kt rename to app/src/main/java/org/session/libsession/messaging/messages/visible/OpenGroupInvitation.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/visible/ParsedMessage.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/ParsedMessage.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/messages/visible/ParsedMessage.kt rename to app/src/main/java/org/session/libsession/messaging/messages/visible/ParsedMessage.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/visible/Profile.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/Profile.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/messages/visible/Profile.kt rename to app/src/main/java/org/session/libsession/messaging/messages/visible/Profile.kt diff --git a/app/src/main/java/org/session/libsession/messaging/messages/visible/Quote.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/Quote.kt new file mode 100644 index 0000000000..0b247c59cf --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/messages/visible/Quote.kt @@ -0,0 +1,86 @@ +package org.session.libsession.messaging.messages.visible + +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment +import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel as SignalQuote +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.Log + +class Quote() { + var timestamp: Long? = 0 + var publicKey: String? = null + var text: String? = null + var attachmentID: Long? = null + + fun isValid(): Boolean = timestamp != null && publicKey != null + + companion object { + const val TAG = "Quote" + + fun fromProto(proto: SignalServiceProtos.DataMessage.Quote): Quote? { + val timestamp = proto.id + val publicKey = proto.author + val text = proto.text + return Quote(timestamp, publicKey, text, null) + } + + fun from(signalQuote: SignalQuote?): Quote? { + if (signalQuote == null) { return null } + val attachmentID = (signalQuote.attachments?.firstOrNull() as? DatabaseAttachment)?.attachmentId?.rowId + return Quote(signalQuote.id, signalQuote.author.toString(), "", attachmentID) + } + } + + internal constructor(timestamp: Long, publicKey: String, text: String?, attachmentID: Long?) : this() { + this.timestamp = timestamp + this.publicKey = publicKey + this.text = text + this.attachmentID = attachmentID + } + + fun toProto(): SignalServiceProtos.DataMessage.Quote? { + val timestamp = timestamp + val publicKey = publicKey + if (timestamp == null || publicKey == null) { + Log.w(TAG, "Couldn't construct quote proto from: $this") + return null + } + val quoteProto = SignalServiceProtos.DataMessage.Quote.newBuilder() + quoteProto.id = timestamp + quoteProto.author = publicKey + text?.let { quoteProto.text = it } + addAttachmentsIfNeeded(quoteProto) + + // Build + try { + return quoteProto.build() + } catch (e: Exception) { + Log.w(TAG, "Couldn't construct quote proto from: $this", e) + return null + } + } + + private fun addAttachmentsIfNeeded(quoteProto: SignalServiceProtos.DataMessage.Quote.Builder) { + val attachmentID = attachmentID ?: return Log.w(TAG, "Cannot add attachment with null attachmentID - bailing.") + + val database = MessagingModuleConfiguration.shared.messageDataProvider + + val pointer = database.getSignalAttachmentPointer(attachmentID) + if (pointer == null) { return Log.w(TAG, "Ignoring invalid attachment for quoted message.") } + + if (pointer.url.isNullOrEmpty()) { + return Log.w(TAG,"Cannot send a message before all associated attachments have been uploaded - bailing.") + } + + val quotedAttachmentProto = SignalServiceProtos.DataMessage.Quote.QuotedAttachment.newBuilder() + quotedAttachmentProto.contentType = pointer.contentType + quotedAttachmentProto.fileName = pointer.filename + quotedAttachmentProto.thumbnail = Attachment.createAttachmentPointer(pointer) + + try { + quoteProto.addAttachments(quotedAttachmentProto.build()) + } catch (e: Exception) { + Log.w(TAG, "Couldn't construct quoted attachment proto from: $this", e) + } + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/visible/Reaction.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/Reaction.kt similarity index 87% rename from libsession/src/main/java/org/session/libsession/messaging/messages/visible/Reaction.kt rename to app/src/main/java/org/session/libsession/messaging/messages/visible/Reaction.kt index 2a34e883bd..6e6327b248 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/visible/Reaction.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/visible/Reaction.kt @@ -6,8 +6,6 @@ import org.session.libsignal.utilities.Log class Reaction() { var timestamp: Long? = 0 - var localId: Long? = 0 - var isMms: Boolean? = false var publicKey: String? = null var emoji: String? = null var react: Boolean? = true @@ -24,24 +22,22 @@ class Reaction() { companion object { const val TAG = "Quote" - fun fromProto(proto: SignalServiceProtos.DataMessage.Reaction): Reaction? { + fun fromProto(proto: SignalServiceProtos.DataMessage.Reaction): Reaction { val react = proto.action == Action.REACT return Reaction(publicKey = proto.author, emoji = proto.emoji, react = react, timestamp = proto.id, count = 1) } - fun from(timestamp: Long, author: String, emoji: String, react: Boolean): Reaction? { + fun from(timestamp: Long, author: String, emoji: String, react: Boolean): Reaction { return Reaction(author, emoji, react, timestamp) } } - internal constructor(publicKey: String, emoji: String, react: Boolean, timestamp: Long? = 0, localId: Long? = 0, isMms: Boolean? = false, serverId: String? = null, count: Long? = 0, index: Long? = 0) : this() { + internal constructor(publicKey: String, emoji: String, react: Boolean, timestamp: Long? = 0, serverId: String? = null, count: Long? = 0, index: Long? = 0) : this() { this.timestamp = timestamp this.publicKey = publicKey this.emoji = emoji this.react = react this.serverId = serverId - this.localId = localId - this.isMms = isMms this.count = count this.index = index } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt b/app/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt similarity index 96% rename from libsession/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt rename to app/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt index 9a143dca27..42059c1c50 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt @@ -1,13 +1,10 @@ package org.session.libsession.messaging.messages.visible -import com.goterl.lazysodium.BuildConfig +import network.loki.messenger.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 diff --git a/app/src/main/java/org/session/libsession/messaging/notifications/TokenFetcher.kt b/app/src/main/java/org/session/libsession/messaging/notifications/TokenFetcher.kt new file mode 100644 index 0000000000..2d0d15abcd --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/notifications/TokenFetcher.kt @@ -0,0 +1,17 @@ +package org.session.libsession.messaging.notifications + +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent + +interface TokenFetcher: OnAppStartupComponent { + suspend fun fetch(): String { + return token.filterNotNull().first() + } + + val token: StateFlow + + fun onNewToken(token: String) + suspend fun resetToken() +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/Endpoint.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/Endpoint.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/open_groups/Endpoint.kt rename to app/src/main/java/org/session/libsession/messaging/open_groups/Endpoint.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt rename to app/src/main/java/org/session/libsession/messaging/open_groups/GroupMember.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroup.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroup.kt similarity index 82% rename from libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroup.kt rename to app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroup.kt index 7743cd8176..889bf125b9 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroup.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroup.kt @@ -11,16 +11,18 @@ data class OpenGroup( val room: String, val id: String, val name: String, + val description: String?, val publicKey: String, val imageId: String?, val infoUpdates: Int, val canWrite: Boolean, ) { - constructor(server: String, room: String, publicKey: String, name: String, imageId: String?, canWrite: Boolean, infoUpdates: Int) : this( + constructor(server: String, room: String, publicKey: String, name: String, imageId: String?, canWrite: Boolean, infoUpdates: Int, description: String?) : this( server = server, room = room, id = "$server.$room", name = name, + description = description, publicKey = publicKey, imageId = imageId, infoUpdates = infoUpdates, @@ -36,11 +38,18 @@ data class OpenGroup( 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 description = json.get("description") + ?.takeUnless { it.isNull } + ?.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 - OpenGroup(server = server, room = room, name = displayName, publicKey = publicKey, imageId = imageId, canWrite = canWrite, infoUpdates = infoUpdates) + OpenGroup( + server = server, room = room, name = displayName, publicKey = publicKey, + imageId = imageId, canWrite = canWrite, infoUpdates = infoUpdates, + description = description + ) } catch (e: Exception) { Log.w("Loki", "Couldn't parse open group from JSON: $jsonAsString.", e); null @@ -63,6 +72,7 @@ data class OpenGroup( "server" to server, "publicKey" to publicKey, "displayName" to name, + "description" to description, "imageId" to imageId, "infoUpdates" to infoUpdates.toString(), "canWrite" to canWrite.toString() diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt similarity index 79% rename from libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt rename to app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index 5e93a01018..fcaf682020 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -6,11 +6,11 @@ import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.PropertyNamingStrategy import com.fasterxml.jackson.databind.annotation.JsonNaming import com.fasterxml.jackson.databind.type.TypeFactory -import com.goterl.lazysodium.interfaces.GenericHash -import com.goterl.lazysodium.interfaces.Sign import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.launch +import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.Hash +import network.loki.messenger.libsession_util.util.BlindKeyAPI import nl.komponents.kovenant.Promise import nl.komponents.kovenant.functional.map import okhttp3.Headers.Companion.toHeaders @@ -18,9 +18,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller.Companion.maxInactivityPeriod -import org.session.libsession.messaging.utilities.SodiumUtilities -import org.session.libsession.messaging.utilities.SodiumUtilities.sodium +import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller.Companion.MAX_INACTIVITIY_PERIOD_MILLS import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionResponse import org.session.libsession.snode.SnodeAPI @@ -28,8 +26,8 @@ import org.session.libsession.snode.utilities.asyncPromise import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.Base64.decode import org.session.libsignal.utilities.Base64.encodeBytes +import org.session.libsignal.utilities.ByteArraySlice import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.HTTP.Verb.DELETE import org.session.libsignal.utilities.HTTP.Verb.GET @@ -39,15 +37,13 @@ import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.removingIdPrefixIfNeeded -import org.whispersystems.curve25519.Curve25519 +import java.security.SecureRandom import java.util.concurrent.TimeUnit import kotlin.collections.component1 import kotlin.collections.component2 import kotlin.collections.set object OpenGroupApi { - private val curve = Curve25519.getInstance(Curve25519.BEST) val defaultRooms = MutableSharedFlow>(replay = 1) private val hasPerformedInitialPoll = mutableMapOf() private var hasUpdatedLastOpenDate = false @@ -79,7 +75,7 @@ object OpenGroupApi { object NoEd25519KeyPair : Error("Couldn't find ed25519 key pair.") } - data class DefaultGroup(val id: String, val name: String, val image: ByteArray?) { + data class DefaultGroup(val id: String, val name: String, val image: ByteArraySlice?) { val joinURL: String get() = "$defaultServer/$id?public_key=$defaultServerPublicKey" } @@ -290,19 +286,19 @@ object OpenGroupApi { return RequestBody.create("application/json".toMediaType(), parametersAsJSON) } - private fun getResponseBody(request: Request): Promise { - return send(request).map { response -> + private fun getResponseBody(request: Request, signRequest: Boolean = true): Promise { + return send(request, signRequest = signRequest).map { response -> response.body ?: throw Error.ParsingFailed } } - private fun getResponseBodyJson(request: Request): Promise, Exception> { - return send(request).map { + private fun getResponseBodyJson(request: Request, signRequest: Boolean = true): Promise, Exception> { + return send(request, signRequest = signRequest).map { JsonUtil.fromJson(it.body, Map::class.java) } } - private fun send(request: Request): Promise { + private fun send(request: Request, signRequest: Boolean): Promise { request.server.toHttpUrlOrNull() ?: return Promise.ofFail(Error.InvalidURL) val urlBuilder = StringBuilder("${request.server}/${request.endpoint.value}") if (request.verb == GET && request.queryParameters.isNotEmpty()) { @@ -312,79 +308,76 @@ object OpenGroupApi { } } fun execute(): Promise { - val serverCapabilities = MessagingModuleConfiguration.shared.storage.getServerCapabilities(request.server) - val publicKey = + val serverPublicKey = MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(request.server) ?: return Promise.ofFail(Error.NoPublicKey) - val ed25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() - ?: return Promise.ofFail(Error.NoEd25519KeyPair) val urlRequest = urlBuilder.toString() - val headers = request.headers.toMutableMap() - val nonce = sodium.nonce(16) - val timestamp = TimeUnit.MILLISECONDS.toSeconds(SnodeAPI.nowWithOffset) - var pubKey = "" - var signature = ByteArray(Sign.BYTES) - var bodyHash = ByteArray(0) - if (request.parameters != null) { - val parameterBytes = JsonUtil.toJson(request.parameters).toByteArray() - val parameterHash = ByteArray(GenericHash.BYTES_MAX) - if (sodium.cryptoGenericHash( - parameterHash, - parameterHash.size, - parameterBytes, - parameterBytes.size.toLong() - ) - ) { - bodyHash = parameterHash - } - } else if (request.body != null) { - val byteHash = ByteArray(GenericHash.BYTES_MAX) - if (sodium.cryptoGenericHash( - byteHash, - byteHash.size, - request.body, - request.body.size.toLong() - ) - ) { - bodyHash = byteHash + + val headers = if (signRequest) { + val serverCapabilities = MessagingModuleConfiguration.shared.storage.getServerCapabilities(request.server) + + val ed25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() + ?: return Promise.ofFail(Error.NoEd25519KeyPair) + + val headers = request.headers.toMutableMap() + val nonce = ByteArray(16).also { SecureRandom().nextBytes(it) } + val timestamp = TimeUnit.MILLISECONDS.toSeconds(SnodeAPI.nowWithOffset) + val bodyHash = if (request.parameters != null) { + val parameterBytes = JsonUtil.toJson(request.parameters).toByteArray() + Hash.hash64(parameterBytes) + } else if (request.body != null) { + Hash.hash64(request.body) + } else { + byteArrayOf() } - } - val messageBytes = Hex.fromStringCondensed(publicKey) - .plus(nonce) - .plus("$timestamp".toByteArray(Charsets.US_ASCII)) - .plus(request.verb.rawValue.toByteArray()) - .plus("/${request.endpoint.value}".toByteArray()) - .plus(bodyHash) - if (serverCapabilities.isEmpty() || serverCapabilities.contains(Capability.BLIND.name.lowercase())) { - SodiumUtilities.blindedKeyPair(publicKey, ed25519KeyPair)?.let { keyPair -> + + val messageBytes = Hex.fromStringCondensed(serverPublicKey) + .plus(nonce) + .plus("$timestamp".toByteArray(Charsets.US_ASCII)) + .plus(request.verb.rawValue.toByteArray()) + .plus("/${request.endpoint.value}".toByteArray()) + .plus(bodyHash) + + val signature: ByteArray + val pubKey: String + + if (serverCapabilities.isEmpty() || serverCapabilities.contains(Capability.BLIND.name.lowercase())) { pubKey = AccountId( IdPrefix.BLINDED, - keyPair.publicKey.asBytes + BlindKeyAPI.blind15KeyPair( + ed25519SecretKey = ed25519KeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(serverPublicKey) + ).pubKey.data + ).hexString + + try { + signature = BlindKeyAPI.blind15Sign( + ed25519SecretKey = ed25519KeyPair.secretKey.data, + serverPubKey = serverPublicKey, + message = messageBytes + ) + } catch (e: Exception) { + throw Error.SigningFailed + } + } else { + pubKey = AccountId( + IdPrefix.UN_BLINDED, + ed25519KeyPair.pubKey.data ).hexString - signature = SodiumUtilities.sogsSignature( - messageBytes, - ed25519KeyPair.secretKey.asBytes, - keyPair.secretKey.asBytes, - keyPair.publicKey.asBytes - ) ?: return Promise.ofFail(Error.SigningFailed) - } ?: return Promise.ofFail(Error.SigningFailed) + signature = ED25519.sign( + ed25519PrivateKey = ed25519KeyPair.secretKey.data, + message = messageBytes + ) + } + headers["X-SOGS-Nonce"] = encodeBytes(nonce) + headers["X-SOGS-Timestamp"] = "$timestamp" + headers["X-SOGS-Pubkey"] = pubKey + headers["X-SOGS-Signature"] = encodeBytes(signature) + headers } else { - pubKey = AccountId( - IdPrefix.UN_BLINDED, - ed25519KeyPair.publicKey.asBytes - ).hexString - sodium.cryptoSignDetached( - signature, - messageBytes, - messageBytes.size.toLong(), - ed25519KeyPair.secretKey.asBytes - ) + request.headers } - headers["X-SOGS-Nonce"] = encodeBytes(nonce) - headers["X-SOGS-Timestamp"] = "$timestamp" - headers["X-SOGS-Pubkey"] = pubKey - headers["X-SOGS-Signature"] = encodeBytes(signature) val requestBuilder = okhttp3.Request.Builder() .url(urlRequest) @@ -399,7 +392,7 @@ object OpenGroupApi { requestBuilder.header("Room", request.room) } return if (request.useOnionRouting) { - OnionRequestAPI.sendOnionRequest(requestBuilder.build(), request.server, publicKey).fail { e -> + OnionRequestAPI.sendOnionRequest(requestBuilder.build(), request.server, serverPublicKey).fail { e -> when (e) { // No need for the stack trace for HTTP errors is HTTP.HTTPRequestFailedException -> Log.e("SOGS", "Failed onion request: ${e.message}") @@ -416,15 +409,16 @@ object OpenGroupApi { fun downloadOpenGroupProfilePicture( server: String, roomID: String, - imageId: String - ): Promise { + imageId: String, + signRequest: Boolean = true, + ): Promise { val request = Request( verb = GET, room = roomID, server = server, endpoint = Endpoint.RoomFileIndividual(roomID, imageId) ) - return getResponseBody(request) + return getResponseBody(request, signRequest = signRequest) } // region Upload/Download @@ -440,19 +434,19 @@ object OpenGroupApi { "Content-Type" to "application/octet-stream" ) ) - return getResponseBodyJson(request).map { json -> + return getResponseBodyJson(request, signRequest = true).map { json -> (json["id"] as? Number)?.toLong() ?: throw Error.ParsingFailed } } - fun download(fileId: String, room: String, server: String): Promise { + fun download(fileId: String, room: String, server: String): Promise { val request = Request( verb = GET, room = room, server = server, endpoint = Endpoint.RoomFileIndividual(room, fileId) ) - return getResponseBody(request) + return getResponseBody(request, signRequest = true) } // endregion @@ -465,7 +459,7 @@ object OpenGroupApi { whisperMods: Boolean? = null, fileIds: List? = null ): Promise { - val signedMessage = message.sign(room, server, fallbackSigningType = IdPrefix.STANDARD) ?: return Promise.ofFail(Error.SigningFailed) + val signedMessage = message.sign(room, server) ?: return Promise.ofFail(Error.SigningFailed) val parameters = signedMessage.toJSON().toMutableMap() // add file IDs if there are any (from attachments) @@ -480,7 +474,7 @@ object OpenGroupApi { endpoint = Endpoint.RoomMessage(room), parameters = parameters ) - return getResponseBodyJson(request).map { json -> + return getResponseBodyJson(request, signRequest = true).map { json -> @Suppress("UNCHECKED_CAST") val rawMessage = json as? Map ?: throw Error.ParsingFailed val result = OpenGroupMessage.fromJSON(rawMessage) ?: throw Error.ParsingFailed @@ -491,67 +485,6 @@ object OpenGroupApi { } // endregion - // region Messages - fun getMessages(room: String, server: String): Promise, Exception> { - val storage = MessagingModuleConfiguration.shared.storage - val queryParameters = mutableMapOf() - storage.getLastMessageServerID(room, server)?.let { lastId -> - queryParameters += "from_server_id" to lastId.toString() - } - val request = Request( - verb = GET, - room = room, - server = server, - endpoint = Endpoint.RoomMessage(room), - queryParameters = queryParameters - ) - return getResponseBodyJson(request).map { json -> - @Suppress("UNCHECKED_CAST") val rawMessages = - json["messages"] as? List> - ?: throw Error.ParsingFailed - parseMessages(room, server, rawMessages) - } - } - - private fun parseMessages( - room: String, - server: String, - rawMessages: List> - ): List { - val messages = rawMessages.mapNotNull { json -> - json as Map - try { - val message = OpenGroupMessage.fromJSON(json) ?: return@mapNotNull null - if (message.serverID == null || message.sender.isNullOrEmpty()) return@mapNotNull null - val sender = message.sender - val data = decode(message.base64EncodedData) - val signature = decode(message.base64EncodedSignature) - val publicKey = Hex.fromStringCondensed(sender.removingIdPrefixIfNeeded()) - val isValid = curve.verifySignature(publicKey, data, signature) - if (!isValid) { - Log.d("Loki", "Ignoring message with invalid signature.") - return@mapNotNull null - } - message - } catch (e: Exception) { - null - } - } - return messages - } - - fun getReactors(room: String, server: String, messageId: Long, emoji: String): Promise, Exception> { - val request = Request( - verb = GET, - room = room, - server = server, - endpoint = Endpoint.Reactors(room, messageId, emoji) - ) - return getResponseBody(request).map { response -> - JsonUtil.fromJson(response, Map::class.java) - } - } - fun addReaction(room: String, server: String, messageId: Long, emoji: String): Promise { val request = Request( verb = PUT, @@ -561,7 +494,7 @@ object OpenGroupApi { parameters = emptyMap() ) val pendingReaction = PendingReaction(server, room, messageId, emoji, true) - return getResponseBody(request).map { response -> + return getResponseBody(request, signRequest = true).map { response -> JsonUtil.fromJson(response, AddReactionResponse::class.java).also { val index = pendingReactions.indexOf(pendingReaction) pendingReactions[index].seqNo = it.seqNo @@ -577,7 +510,7 @@ object OpenGroupApi { endpoint = Endpoint.Reaction(room, messageId, emoji) ) val pendingReaction = PendingReaction(server, room, messageId, emoji, true) - return getResponseBody(request).map { response -> + return getResponseBody(request, signRequest = true).map { response -> JsonUtil.fromJson(response, DeleteReactionResponse::class.java).also { val index = pendingReactions.indexOf(pendingReaction) pendingReactions[index].seqNo = it.seqNo @@ -592,7 +525,7 @@ object OpenGroupApi { server = server, endpoint = Endpoint.ReactionDelete(room, messageId, emoji) ) - return getResponseBody(request).map { response -> + return getResponseBody(request, signRequest = true).map { response -> JsonUtil.fromJson(response, DeleteAllReactionsResponse::class.java) } } @@ -602,7 +535,7 @@ object OpenGroupApi { @JvmStatic fun deleteMessage(serverID: Long, room: String, server: String): Promise { val request = Request(verb = DELETE, room = room, server = server, endpoint = Endpoint.RoomMessageIndividual(room, serverID)) - return send(request).map { + return send(request, signRequest = true).map { Log.d("Loki", "Message deletion successful.") } } @@ -623,7 +556,7 @@ object OpenGroupApi { endpoint = Endpoint.RoomDeleteMessages(room, storage.getUserPublicKey() ?: ""), queryParameters = queryParameters ) - return getResponseBody(request).map { response -> + return getResponseBody(request, signRequest = true).map { response -> val json = JsonUtil.fromJson(response, Map::class.java) val type = TypeFactory.defaultInstance() .constructCollectionType(List::class.java, MessageDeletion::class.java) @@ -651,7 +584,7 @@ object OpenGroupApi { endpoint = Endpoint.UserBan(publicKey), parameters = parameters ) - return send(request).map { + return send(request, signRequest = true).map { Log.d("Loki", "Banned user: $publicKey from: $server.$room.") } } @@ -683,7 +616,7 @@ object OpenGroupApi { fun unban(publicKey: String, room: String, server: String): Promise { val request = Request(verb = DELETE, room = room, server = server, endpoint = Endpoint.UserUnban(publicKey)) - return send(request).map { + return send(request, signRequest = true).map { Log.d("Loki", "Unbanned user: $publicKey from: $server.$room") } } @@ -699,7 +632,7 @@ object OpenGroupApi { val context = MessagingModuleConfiguration.shared.context val timeSinceLastOpen = this.timeSinceLastOpen val shouldRetrieveRecentMessages = (hasPerformedInitialPoll[server] != true - && timeSinceLastOpen > maxInactivityPeriod) + && timeSinceLastOpen > MAX_INACTIVITIY_PERIOD_MILLS) hasPerformedInitialPoll[server] = true if (!hasUpdatedLastOpenDate) { hasUpdatedLastOpenDate = true @@ -718,7 +651,17 @@ object OpenGroupApi { ) ) rooms.forEach { room -> - val infoUpdates = storage.getOpenGroup(room, server)?.infoUpdates ?: 0 + // we need to make sure communities have their description data, and since we were not + // tracking that property before (04/20205) we need to force existing communities to + // request their info data + val forcedDescriptionPoll = if(TextSecurePreferences.forcedCommunityDescriptionPoll(context, server+room)){ + true + } else { + TextSecurePreferences.setForcedCommunityDescriptionPoll(context, server+room, true) + false + } + + val infoUpdates = if(!forcedDescriptionPoll) 0 else storage.getOpenGroup(room, server)?.infoUpdates ?: 0 val lastMessageServerId = storage.getLastMessageServerID(room, server) ?: 0L requests.add( BatchRequestInfo( @@ -831,9 +774,10 @@ object OpenGroupApi { private fun getBatchResponseJson( request: Request, - requests: MutableList> + requests: MutableList>, + signRequest: Boolean = true ): Promise>, Exception> { - return getResponseBody(request).map { batch -> + return getResponseBody(request, signRequest = signRequest).map { batch -> val results = JsonUtil.fromJson(batch, List::class.java) ?: throw Error.ParsingFailed results.mapIndexed { idx, result -> val response = result as? Map<*, *> ?: throw Error.ParsingFailed @@ -875,7 +819,7 @@ object OpenGroupApi { } } val images = groups.associate { group -> - group.token to group.imageId?.let { downloadOpenGroupProfilePicture(defaultServer, group.token, it) } + group.token to group.imageId?.let { downloadOpenGroupProfilePicture(defaultServer, group.token, it, signRequest = false) } } groups.map { group -> val image = try { @@ -908,7 +852,7 @@ object OpenGroupApi { server = defaultServer, endpoint = Endpoint.Rooms ) - return getResponseBody(request).map { response -> + return getResponseBody(request, signRequest = false).map { response -> val rawRooms = JsonUtil.fromJson(response, List::class.java) ?: throw Error.ParsingFailed rawRooms.mapNotNull { JsonUtil.fromJson(JsonUtil.toJson(it), RoomInfo::class.java) @@ -916,17 +860,9 @@ object OpenGroupApi { } } - fun getMemberCount(room: String, server: String): Promise { - return getRoomInfo(room, server).map { info -> - val storage = MessagingModuleConfiguration.shared.storage - storage.setUserCount(room, server, info.activeUsers) - info.activeUsers - } - } - fun getCapabilities(server: String): Promise { val request = Request(verb = GET, room = null, server = server, endpoint = Endpoint.Capabilities) - return getResponseBody(request).map { response -> + return getResponseBody(request, signRequest = false).map { response -> JsonUtil.fromJson(response, Capabilities::class.java) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupInfo.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupInfo.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupInfo.kt rename to app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupInfo.kt diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt new file mode 100644 index 0000000000..3dfb4afc70 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupMessage.kt @@ -0,0 +1,92 @@ +package org.session.libsession.messaging.open_groups + +import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability +import org.session.libsignal.crypto.PushTransportDetails +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Base64.decode +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.removingIdPrefixIfNeeded +import org.session.libsignal.utilities.toHexString + +data class OpenGroupMessage( + val serverID: Long? = null, + val sender: String?, + val sentTimestamp: Long, + /** + * The serialized protobuf in base64 encoding. + */ + val base64EncodedData: String?, + /** + * When sending a message, the sender signs the serialized protobuf with their private key so that + * a receiving user can verify that the message wasn't tampered with. + */ + val base64EncodedSignature: String? = null, + val reactions: Map? = null +) { + + companion object { + fun fromJSON(json: Map): OpenGroupMessage? { + val base64EncodedData = json["data"] as? String ?: return null + val sentTimestamp = json["posted"] as? Double ?: return null + val serverID = json["id"] as? Int + val sender = json["session_id"] as? String + val base64EncodedSignature = json["signature"] as? String + return OpenGroupMessage( + serverID = serverID?.toLong(), + sender = sender, + sentTimestamp = (sentTimestamp * 1000).toLong(), + base64EncodedData = base64EncodedData, + base64EncodedSignature = base64EncodedSignature + ) + } + } + + fun sign(room: String, server: String): OpenGroupMessage? { + if (base64EncodedData.isNullOrEmpty()) return null + val userEdKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() ?: return null + val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(room, server) ?: return null + val serverCapabilities = MessagingModuleConfiguration.shared.storage.getServerCapabilities(server) + val signature = if (serverCapabilities.contains(Capability.BLIND.name.lowercase())) { + runCatching { + BlindKeyAPI.blind15Sign( + ed25519SecretKey = userEdKeyPair.secretKey.data, + serverPubKey = openGroup.publicKey, + message = decode(base64EncodedData) + ) + }.onFailure { + Log.e("OpenGroupMessage", "Failed to sign message with blind key", it) + }.getOrNull() ?: return null + } + else { + val x25519PublicKey = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair().publicKey.serialize() + if (sender != x25519PublicKey.toHexString() && !userEdKeyPair.pubKey.data.toHexString().equals(sender?.removingIdPrefixIfNeeded(), true)) return null + try { + ED25519.sign( + ed25519PrivateKey = userEdKeyPair.secretKey.data, + message = decode(base64EncodedData) + ) + } catch (e: Exception) { + Log.w("Loki", "Couldn't sign open group message.", e) + return null + } + } + return copy(base64EncodedSignature = Base64.encodeBytes(signature)) + } + + fun toJSON(): Map { + val json = mutableMapOf( "data" to base64EncodedData, "timestamp" to sentTimestamp ) + serverID?.let { json["server_id"] = it } + sender?.let { json["public_key"] = it } + base64EncodedSignature?.let { json["signature"] = it } + return json + } + + fun toProto(): SignalServiceProtos.Content { + val data = decode(base64EncodedData).let(PushTransportDetails::getStrippedPaddingMessageBody) + return SignalServiceProtos.Content.parseFrom(data) + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupUtils.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupUtils.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupUtils.kt rename to app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupUtils.kt diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt new file mode 100644 index 0000000000..4ee0cd8bc1 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt @@ -0,0 +1,76 @@ +package org.session.libsession.messaging.sending_receiving + +import network.loki.messenger.libsession_util.SessionEncrypt +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.sending_receiving.MessageReceiver.Error +import org.session.libsignal.crypto.ecc.ECKeyPair +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.hexEncodedPublicKey +import org.session.libsignal.utilities.removingIdPrefixIfNeeded + +object MessageDecrypter { + + /** + * Decrypts `ciphertext` using the Session protocol and `x25519KeyPair`. + * + * @param ciphertext the data to decrypt. + * @param x25519KeyPair the key pair to use for decryption. This could be the current user's key pair, or the key pair of a closed group. + * + * @return the padded plaintext. + */ + fun decrypt(ciphertext: ByteArray, x25519KeyPair: ECKeyPair): Pair { + val recipientX25519PrivateKey = x25519KeyPair.privateKey.serialize() + val recipientX25519PublicKey = Hex.fromStringCondensed(x25519KeyPair.hexEncodedPublicKey.removingIdPrefixIfNeeded()) + val (id, data) = SessionEncrypt.decryptIncoming( + x25519PubKey = recipientX25519PublicKey, + x25519PrivKey = recipientX25519PrivateKey, + ciphertext = ciphertext + ) + + return data.data to id + } + + fun decryptBlinded( + message: ByteArray, + isOutgoing: Boolean, + otherBlindedPublicKey: String, + serverPublicKey: String + ): Pair { + val userEdKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() + ?: throw Error.NoUserED25519KeyPair + val blindedKeyPair = BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = userEdKeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(serverPublicKey), + ) ?: throw Error.DecryptionFailed + val otherKeyBytes = + Hex.fromStringCondensed(otherBlindedPublicKey.removingIdPrefixIfNeeded()) + + val senderKeyBytes: ByteArray + val recipientKeyBytes: ByteArray + + if (isOutgoing) { + senderKeyBytes = blindedKeyPair.pubKey.data + recipientKeyBytes = otherKeyBytes + } else { + senderKeyBytes = otherKeyBytes + recipientKeyBytes = blindedKeyPair.pubKey.data + } + + try { + val (sessionId, plainText) = SessionEncrypt.decryptForBlindedRecipient( + ciphertext = message, + myEd25519Privkey = userEdKeyPair.secretKey.data, + openGroupPubkey = Hex.fromStringCondensed(serverPublicKey), + senderBlindedId = byteArrayOf(0x15) + senderKeyBytes, + recipientBlindId = byteArrayOf(0x15) + recipientKeyBytes, + ) + + return plainText.data to sessionId + } catch (e: Exception) { + Log.e("MessageDecrypter", "Failed to decrypt blinded message", e) + throw Error.DecryptionFailed + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt new file mode 100644 index 0000000000..17f16ddbfe --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt @@ -0,0 +1,55 @@ +package org.session.libsession.messaging.sending_receiving + +import network.loki.messenger.libsession_util.SessionEncrypt +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.sending_receiving.MessageSender.Error +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.removingIdPrefixIfNeeded + +object MessageEncrypter { + + /** + * Encrypts `plaintext` using the Session protocol for `hexEncodedX25519PublicKey`. + * + * @param plaintext the plaintext to encrypt. Must already be padded. + * @param recipientHexEncodedX25519PublicKey the X25519 public key to encrypt for. Could be the Account ID of a user, or the public key of a closed group. + * + * @return the encrypted message. + */ + internal fun encrypt(plaintext: ByteArray, recipientHexEncodedX25519PublicKey: String): ByteArray { + val userED25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair + val recipientX25519PublicKey = Hex.fromStringCondensed(recipientHexEncodedX25519PublicKey.removingIdPrefixIfNeeded()) + + try { + return SessionEncrypt.encryptForRecipient( + userED25519KeyPair.secretKey.data, + recipientX25519PublicKey, + plaintext + ).data + } catch (exception: Exception) { + Log.d("Loki", "Couldn't encrypt message due to error: $exception.") + throw Error.EncryptionFailed + } + } + + internal fun encryptBlinded( + plaintext: ByteArray, + recipientBlindedId: String, + serverPublicKey: String + ): ByteArray { + if (IdPrefix.fromValue(recipientBlindedId) != IdPrefix.BLINDED) throw Error.SigningFailed + val userEdKeyPair = + MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() ?: throw Error.NoUserED25519KeyPair + val recipientBlindedPublicKey = Hex.fromStringCondensed(recipientBlindedId.removingIdPrefixIfNeeded()) + + return SessionEncrypt.encryptForBlindedRecipient( + message = plaintext, + myEd25519Privkey = userEdKeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(serverPublicKey), + recipientBlindId = byteArrayOf(0x15) + recipientBlindedPublicKey + ).data + } + +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt similarity index 86% rename from libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt rename to app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt index 389663c925..2c12ff6a83 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt @@ -1,25 +1,23 @@ package org.session.libsession.messaging.sending_receiving +import network.loki.messenger.libsession_util.util.BlindKeyAPI 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.LegacyGroupControlMessage -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.GroupUpdated 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.SodiumUtilities import org.session.libsession.snode.SnodeAPI import org.session.libsignal.crypto.PushTransportDetails import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos.Envelope import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import java.util.concurrent.TimeUnit @@ -116,7 +114,7 @@ object MessageReceiver { } // Loop through all known group key pairs in reverse order (i.e. try the latest key pair first (which'll more than // likely be the one we want) but try older ones in case that didn't work) - var encryptionKeyPair = encryptionKeyPairs.removeLast() + var encryptionKeyPair = encryptionKeyPairs.removeAt(encryptionKeyPairs.lastIndex) fun decrypt() { try { val decryptionResult = MessageDecrypter.decrypt(envelopeContent.toByteArray(), encryptionKeyPair) @@ -124,7 +122,7 @@ object MessageReceiver { sender = decryptionResult.second } catch (e: Exception) { if (encryptionKeyPairs.isNotEmpty()) { - encryptionKeyPair = encryptionKeyPairs.removeLast() + encryptionKeyPair = encryptionKeyPairs.removeAt(encryptionKeyPairs.lastIndex) decrypt() } else { Log.e("Loki", "Failed to decrypt group message", e) @@ -160,21 +158,23 @@ object MessageReceiver { // Parse the message val message: Message = ReadReceipt.fromProto(proto) ?: TypingIndicator.fromProto(proto) ?: - LegacyGroupControlMessage.fromProto(proto) ?: DataExtractionNotification.fromProto(proto) ?: ExpirationTimerUpdate.fromProto(proto, closedGroupSessionId != null) ?: - ConfigurationMessage.fromProto(proto) ?: UnsendRequest.fromProto(proto) ?: MessageRequestResponse.fromProto(proto) ?: CallMessage.fromProto(proto) ?: - SharedConfigurationMessage.fromProto(proto) ?: GroupUpdated.fromProto(proto) ?: VisibleMessage.fromProto(proto) ?: throw Error.UnknownMessage // Don't process the envelope any further if the sender is blocked if (isBlocked(sender!!) && message.shouldDiscardIfBlocked()) { throw Error.SenderBlocked } - val isUserBlindedSender = sender == openGroupPublicKey?.let { SodiumUtilities.blindedKeyPair(it, MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!) }?.let { AccountId(IdPrefix.BLINDED, it.publicKey.asBytes).hexString } + val isUserBlindedSender = sender == openGroupPublicKey?.let { + BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!.secretKey.data, + serverPubKey = Hex.fromStringCondensed(it), + ) + }?.let { AccountId(IdPrefix.BLINDED, it.pubKey.data).hexString } val isUserSender = sender == userPublicKey if (isUserSender || isUserBlindedSender) { @@ -202,20 +202,11 @@ 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 (groupPublicKey != null && groupPublicKey !in (currentClosedGroups ?: emptySet()) && groupPublicKey?.startsWith(IdPrefix.GROUP.value) != true) { + if (groupPublicKey != null && groupPublicKey !in (currentClosedGroups ?: emptySet()) && IdPrefix.fromValue(groupPublicKey) != IdPrefix.GROUP) { throw Error.NoGroupThread } - if ((message is LegacyGroupControlMessage && message.kind is LegacyGroupControlMessage.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) - } + if (storage.isDuplicateMessage(envelope.timestamp)) { throw Error.DuplicateMessage } + storage.addReceivedMessageTimestamp(envelope.timestamp) // Return return Pair(message, proto) } diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt similarity index 78% rename from libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt rename to app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index cfa49429c5..a4e5a7684f 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -6,7 +6,10 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.Namespace +import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.ExpiryMode import nl.komponents.kovenant.Promise import nl.komponents.kovenant.deferred @@ -16,12 +19,9 @@ import org.session.libsession.messaging.jobs.MessageSendJob 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.LegacyGroupControlMessage -import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.control.GroupUpdated 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.Quote @@ -30,27 +30,25 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability import org.session.libsession.messaging.open_groups.OpenGroupMessage import org.session.libsession.messaging.utilities.MessageWrapper -import org.session.libsession.messaging.utilities.SodiumUtilities 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.snode.utilities.asyncPromise 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.AccountId import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix -import org.session.libsignal.utilities.Namespace +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.defaultRequiresAuth import org.session.libsignal.utilities.hasNamespaces import org.session.libsignal.utilities.hexEncodedPublicKey import java.util.concurrent.TimeUnit -import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview as SignalLinkPreview import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel as SignalQuote @@ -78,7 +76,6 @@ object MessageSender { // Convenience fun sendNonDurably(message: Message, destination: Destination, isSyncMessage: Boolean): Promise { - 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 { @@ -88,15 +85,6 @@ object MessageSender { } } - fun buildConfigMessageToSnode(destinationPubKey: String, message: SharedConfigurationMessage): SnodeMessage { - return SnodeMessage( - destinationPubKey, - Base64.encodeBytes(message.data), - ttl = message.ttl, - SnodeAPI.nowWithOffset - ) - } - // One-on-One Chats & Closed Groups @Throws(Exception::class) fun buildWrappedMessageToSnode(destination: Destination, message: Message, isSyncMessage: Boolean): SnodeMessage { @@ -128,15 +116,9 @@ object MessageSender { // • a configuration message // • a sync message // • a closed group control message of type `new` - var isNewClosedGroupControlMessage = false - if (message is LegacyGroupControlMessage && message.kind is LegacyGroupControlMessage.Kind.New) isNewClosedGroupControlMessage = - true if (isSelfSend - && message !is ConfigurationMessage && !isSyncMessage - && !isNewClosedGroupControlMessage && message !is UnsendRequest - && message !is SharedConfigurationMessage ) { throw Error.InvalidMessage } @@ -295,13 +277,12 @@ object MessageSender { isSyncMessage: Boolean ): Long? { // For ClosedGroupControlMessage or GroupUpdateMemberLeftMessage, the expiration timer doesn't apply - if (message is LegacyGroupControlMessage || ( - message is GroupUpdated && ( - message.inner.hasMemberLeftMessage() || - message.inner.hasInviteMessage() || - message.inner.hasInviteResponse() || - message.inner.hasDeleteMemberContent() || - message.inner.hasPromoteMessage()))) { + if (message is GroupUpdated && ( + message.inner.hasMemberLeftMessage() || + message.inner.hasInviteMessage() || + message.inner.hasInviteResponse() || + message.inner.hasDeleteMemberContent() || + message.inner.hasPromoteMessage())) { return null } @@ -339,17 +320,26 @@ object MessageSender { is Destination.OpenGroup -> { serverCapabilities = storage.getServerCapabilities(destination.server) storage.getOpenGroup(destination.roomToken, destination.server)?.let { - blindedPublicKey = SodiumUtilities.blindedKeyPair(it.publicKey, userEdKeyPair)?.publicKey?.asBytes + blindedPublicKey = BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = userEdKeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(it.publicKey), + )?.pubKey?.data } } is Destination.OpenGroupInbox -> { serverCapabilities = storage.getServerCapabilities(destination.server) - blindedPublicKey = SodiumUtilities.blindedKeyPair(destination.serverPublicKey, userEdKeyPair)?.publicKey?.asBytes + blindedPublicKey = BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = userEdKeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(destination.serverPublicKey), + )?.pubKey?.data } is Destination.LegacyOpenGroup -> { serverCapabilities = storage.getServerCapabilities(destination.server) storage.getOpenGroup(destination.roomToken, destination.server)?.let { - blindedPublicKey = SodiumUtilities.blindedKeyPair(it.publicKey, userEdKeyPair)?.publicKey?.asBytes + blindedPublicKey = BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = userEdKeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(it.publicKey), + )?.pubKey?.data } } else -> {} @@ -357,7 +347,7 @@ object MessageSender { val messageSender = if (serverCapabilities.contains(Capability.BLIND.name.lowercase()) && blindedPublicKey != null) { AccountId(IdPrefix.BLINDED, blindedPublicKey!!).hexString } else { - AccountId(IdPrefix.UN_BLINDED, userEdKeyPair.publicKey.asBytes).hexString + AccountId(IdPrefix.UN_BLINDED, userEdKeyPair.pubKey.data).hexString } message.sender = messageSender // Set the failure handler (need it here already for precondition failure handling) @@ -429,16 +419,13 @@ object MessageSender { // Result Handling 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(timestamp) - storage.getMessageIdInDatabase(timestamp, userPublicKey)?.let { (messageID, mms) -> + storage.addReceivedMessageTimestamp(message.sentTimestamp!!) + message.id?.let { messageId -> if (openGroupSentTimestamp != -1L && message is VisibleMessage) { storage.addReceivedMessageTimestamp(openGroupSentTimestamp) - storage.updateSentTimestamp(messageID, message.isMediaMessage(), openGroupSentTimestamp, message.threadID!!) message.sentTimestamp = openGroupSentTimestamp } @@ -446,11 +433,11 @@ object MessageSender { // 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, mms, it) + storage.setMessageServerHash(messageId, it) } // in case any errors from previous sends - storage.clearErrorMessage(messageID) + storage.clearErrorMessage(messageId) // Track the open group server message ID val messageIsAddressedToCommunity = message.openGroupServerMessageID != null && (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup) @@ -471,33 +458,24 @@ object MessageSender { } } val encoded = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray()) - val threadID = storage.getThreadId(Address.fromSerialized(encoded)) - if (threadID != null && threadID >= 0) { - storage.setOpenGroupServerMessageID(messageID, message.openGroupServerMessageID!!, threadID, !(message as VisibleMessage).isMediaMessage()) + val communityThreadID = storage.getThreadId(Address.fromSerialized(encoded)) + if (communityThreadID != null && communityThreadID >= 0) { + storage.setOpenGroupServerMessageID( + messageID = messageId, + serverID = message.openGroupServerMessageID!!, + threadID = communityThreadID + ) } } // 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) - } + storage.markAsSent(messageId) + + // Update the message sent timestamp + storage.updateSentTimestamp(messageId, message.sentTimestamp!!) // Start the disappearing messages timer if needed - SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(message, startDisappearAfterRead = true) + SSKEnvironment.shared.messageExpirationManager.onMessageSent(message) } ?: run { storage.updateReactionIfNeeded(message, message.sender?:userPublicKey, openGroupSentTimestamp) } @@ -509,41 +487,44 @@ object MessageSender { if (message is VisibleMessage) message.syncTarget = destination.publicKey if (message is ExpirationTimerUpdate) message.syncTarget = destination.publicKey - storage.markAsSyncing(timestamp, userPublicKey) + message.id?.let(storage::markAsSyncing) GlobalScope.launch { - sendToSnodeDestination(Destination.Contact(userPublicKey), message, true) + try { + sendToSnodeDestination(Destination.Contact(userPublicKey), message, true) + } catch (ec: Exception) { + Log.e("MessageSender", "Unable to send sync message", ec) + } } } } fun handleFailedMessageSend(message: Message, error: Exception, isSyncMessage: Boolean = false) { val storage = MessagingModuleConfiguration.shared.storage - val timestamp = message.sentTimestamp!! + + val messageId = message.id ?: return // no need to handle if message is marked as deleted - if(MessagingModuleConfiguration.shared.messageDataProvider.isDeletedMessage(message.sentTimestamp!!)){ + if(MessagingModuleConfiguration.shared.messageDataProvider.isDeletedMessage(messageId)){ return } - val userPublicKey = storage.getUserPublicKey()!! - - val author = message.sender ?: userPublicKey - - if (isSyncMessage) storage.markAsSyncFailed(timestamp, author, error) - else storage.markAsSentFailed(timestamp, author, error) + if (isSyncMessage) storage.markAsSyncFailed(messageId, error) + else storage.markAsSentFailed(messageId, error) } // Convenience @JvmStatic - fun send(message: VisibleMessage, address: Address, attachments: List, quote: SignalQuote?, linkPreview: SignalLinkPreview?) { + fun send(message: VisibleMessage, address: Address, quote: SignalQuote?, linkPreview: SignalLinkPreview?) { val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider - val attachmentIDs = messageDataProvider.getAttachmentIDsFor(message.id!!) - message.attachmentIDs.addAll(attachmentIDs) + val messageId = message.id + if (messageId?.mms == true) { + message.attachmentIDs.addAll(messageDataProvider.getAttachmentIDsFor(messageId.id)) + } message.quote = Quote.from(quote) message.linkPreview = LinkPreview.from(linkPreview) message.linkPreview?.let { linkPreview -> - if (linkPreview.attachmentID == null) { - messageDataProvider.getLinkPreviewAttachmentIDFor(message.id!!)?.let { attachmentID -> + if (linkPreview.attachmentID == null && messageId?.mms == true) { + messageDataProvider.getLinkPreviewAttachmentIDFor(messageId.id)?.let { attachmentID -> linkPreview.attachmentID = attachmentID message.attachmentIDs.remove(attachmentID) } @@ -559,12 +540,18 @@ object MessageSender { threadID?.let(message::applyExpiryMode) message.threadID = threadID val destination = Destination.from(address) - val job = MessageSendJob(message, destination, statusCallback) + val job = MessagingModuleConfiguration.shared.messageSendJobFactory.create(message, destination, statusCallback) JobQueue.shared.add(job) // if we are sending a 'Note to Self' make sure it is not hidden - if(address.serialize() == MessagingModuleConfiguration.shared.storage.getUserPublicKey()){ + if( message is VisibleMessage && + address.toString() == MessagingModuleConfiguration.shared.storage.getUserPublicKey() && + // only show the NTS if it is currently marked as hidden + MessagingModuleConfiguration.shared.configFactory.withUserConfigs { it.userProfile.getNtsPriority() == PRIORITY_HIDDEN } + ){ + // make sure note to self is not hidden MessagingModuleConfiguration.shared.preferences.setHasHiddenNoteToSelf(false) + // update config in case it was marked as hidden there MessagingModuleConfiguration.shared.configFactory.withMutableUserConfigs { it.userProfile.setNtsPriority(PRIORITY_VISIBLE) } @@ -577,34 +564,10 @@ object MessageSender { resultChannel.receive().getOrThrow() } - fun sendNonDurably(message: VisibleMessage, attachments: List, address: Address, isSyncMessage: Boolean): Promise { - val attachmentIDs = MessagingModuleConfiguration.shared.messageDataProvider.getAttachmentIDsFor(message.id!!) - message.attachmentIDs.addAll(attachmentIDs) - return sendNonDurably(message, address, isSyncMessage) - } - fun sendNonDurably(message: Message, address: Address, isSyncMessage: Boolean): Promise { val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) message.threadID = threadID val destination = Destination.from(address) return sendNonDurably(message, destination, isSyncMessage) } - - // Closed groups - fun createClosedGroup(device: Device, name: String, members: Collection): Promise { - return create(device, name, members) - } - - fun explicitNameChange(groupPublicKey: String, newName: String) { - return setName(groupPublicKey, newName) - } - - fun explicitAddMembers(groupPublicKey: String, membersToAdd: List) { - return addMembers(groupPublicKey, membersToAdd) - } - - fun explicitRemoveMembers(groupPublicKey: String, membersToRemove: List) { - return removeMembers(groupPublicKey, membersToRemove) - } - } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt new file mode 100644 index 0000000000..5d159b1cdf --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -0,0 +1,846 @@ +package org.session.libsession.messaging.sending_receiving + +import android.content.Context +import android.text.TextUtils +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import network.loki.messenger.R +import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.avatars.AvatarHelper +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol +import org.session.libsession.database.userAuth +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.messaging.jobs.AttachmentDownloadJob +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.DataExtractionNotification +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.control.GroupUpdated +import org.session.libsession.messaging.messages.control.MessageRequestResponse +import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.messages.control.TypingIndicator +import org.session.libsession.messaging.messages.control.UnsendRequest +import org.session.libsession.messaging.messages.visible.Attachment +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.open_groups.OpenGroup +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.MessageNotifier +import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 +import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel +import org.session.libsession.messaging.utilities.MessageAuthentication.buildDeleteMemberContentSignature +import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature +import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeSignature +import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature +import org.session.libsession.messaging.utilities.WebRtcUtils +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.GroupRecord +import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.recipients.MessageType +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.getType +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.database.ConfigDatabase +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager +import java.security.MessageDigest +import java.security.SignatureException +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.min + +internal fun MessageReceiver.isBlocked(publicKey: String): Boolean { + val context = MessagingModuleConfiguration.shared.context + val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false) + return recipient.isBlocked +} + +@Singleton +class ReceivedMessageHandler @Inject constructor( + @param:ApplicationContext private val context: Context, + private val storage: StorageProtocol, + private val readReceiptManager: ReadReceiptManager, + private val typingIndicators: SSKEnvironment.TypingIndicatorsProtocol, + private val messageDataProvider: MessageDataProvider, + private val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol, + private val notificationManager: MessageNotifier, + private val groupManagerV2: GroupManagerV2, + private val proStatusManager: ProStatusManager, + private val profileManager: SSKEnvironment.ProfileManagerProtocol, + private val visibleMessageContextFactory: VisibleMessageHandlerContext.Factory, + private val attachmentDownloadJobFactory: AttachmentDownloadJob.Factory +) { + fun handle(message: Message, proto: SignalServiceProtos.Content, threadId: Long, openGroupID: String?, groupv2Id: AccountId?) { + // Do nothing if the message was outdated + if (messageIsOutdated(message, threadId, openGroupID)) { return } + + when (message) { + is ReadReceipt -> handleReadReceipt(message) + is TypingIndicator -> handleTypingIndicator(message) + is GroupUpdated -> MessageReceiver.handleGroupUpdated(message, groupv2Id) + is ExpirationTimerUpdate -> { + // For groupsv2, there are dedicated mechanisms for handling expiration timers, and + // we want to avoid the 1-to-1 message format which is unauthenticated in a group settings. + if (groupv2Id != null) { + Log.d("MessageReceiver", "Ignoring expiration timer update for closed group") + } // also ignore it for communities since they do not support disappearing messages + else if (openGroupID != null) { + Log.d("MessageReceiver", "Ignoring expiration timer update for communities") + } else { + handleExpirationTimerUpdate(message) + } + } + is DataExtractionNotification -> handleDataExtractionNotification(message) + is UnsendRequest -> handleUnsendRequest(message) + is MessageRequestResponse -> handleMessageRequestResponse(message) + is VisibleMessage -> handleVisibleMessage( + message = message, + proto = proto, + context = visibleMessageContextFactory.create(threadId, openGroupID), + runThreadUpdate = true, + runProfileUpdate = true + ) + is CallMessage -> handleCallMessage(message) + } + } + + private fun 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 userPublicKey = storage.getUserPublicKey()!! + val threadRecipient = storage.getRecipientForThread(threadId) + val conversationVisibleInConfig = storage.conversationInConfig( + if (message.groupPublicKey == null) threadRecipient?.address?.toString() else null, + message.groupPublicKey, + openGroupID, + true + ) + val canPerformChange = storage.canPerformConfigChange( + if (threadRecipient?.address?.toString() == userPublicKey) ConfigDatabase.USER_PROFILE_VARIANT else ConfigDatabase.CONTACTS_VARIANT, + 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) + } + + private fun handleReadReceipt(message: ReadReceipt) { + readReceiptManager.processReadReceipts( + message.sender!!, + message.timestamps!!, + message.receivedTimestamp!! + ) + } + + private fun handleCallMessage(message: CallMessage) { + // TODO: refactor this out to persistence, just to help debug the flow and send/receive in synchronous testing + WebRtcUtils.SIGNAL_QUEUE.trySend(message) + } + + private fun handleTypingIndicator(message: TypingIndicator) { + when (message.kind!!) { + TypingIndicator.Kind.STARTED -> showTypingIndicatorIfNeeded(message.sender!!) + TypingIndicator.Kind.STOPPED -> hideTypingIndicatorIfNeeded(message.sender!!) + } + } + + private fun showTypingIndicatorIfNeeded(senderPublicKey: String) { + val address = Address.fromSerialized(senderPublicKey) + val threadID = storage.getThreadId(address) ?: return + typingIndicators.didReceiveTypingStartedMessage(threadID, address, 1) + } + + private fun hideTypingIndicatorIfNeeded(senderPublicKey: String) { + val address = Address.fromSerialized(senderPublicKey) + val threadID = storage.getThreadId(address) ?: return + typingIndicators.didReceiveTypingStoppedMessage(threadID, address, 1, false) + } + + private fun cancelTypingIndicatorsIfNeeded(senderPublicKey: String) { + val address = Address.fromSerialized(senderPublicKey) + val threadID = storage.getThreadId(address) ?: return + typingIndicators.didReceiveIncomingMessage(threadID, address, 1) + } + + private fun handleExpirationTimerUpdate(message: ExpirationTimerUpdate) { + messageExpirationManager.run { + insertExpirationTimerMessage(message) + onMessageReceived(message) + } + + val isLegacyGroup = message.groupPublicKey != null && message.groupPublicKey?.startsWith(IdPrefix.GROUP.value) == false + + if (isNewConfigEnabled && !isLegacyGroup) return + + try { + val threadId = Address.fromSerialized(message.groupPublicKey?.let(::doubleEncodeGroupID) ?: message.sender!!) + .let(storage::getOrCreateThreadIdFor) + + storage.setExpirationConfiguration( + ExpirationConfiguration( + threadId, + message.expiryMode, + message.sentTimestamp!! + ) + ) + } catch (e: Exception) { + Log.e("Loki", "Failed to update expiration configuration.") + } + } + + private fun handleDataExtractionNotification(message: DataExtractionNotification) { + // We don't handle data extraction messages for groups (they shouldn't be sent, but just in case we filter them here too) + if (message.groupPublicKey != null) return + 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) + else -> return + } + storage.insertDataExtractionNotificationMessage(senderPublicKey, notification, message.sentTimestamp!!) + } + + + fun handleUnsendRequest(message: UnsendRequest): MessageId? { + val userPublicKey = storage.getUserPublicKey() + val userAuth = storage.userAuth ?: return null + val isLegacyGroupAdmin: Boolean = message.groupPublicKey?.let { key -> + var admin = false + val groupID = doubleEncodeGroupID(key) + val group = storage.getGroup(groupID) + if(group != null) { + admin = group.admins.map { it.toString() }.contains(message.sender) + } + admin + } ?: false + + // First we need to determine the validity of the UnsendRequest + // It is valid if: + val requestIsValid = message.sender == message.author || // the sender is the author of the message + message.author == userPublicKey || // the sender is the current user + isLegacyGroupAdmin // sender is an admin of legacy group + + if (!requestIsValid) { return null } + + val timestamp = message.timestamp ?: return null + val author = message.author ?: return null + val messageToDelete = storage.getMessageBy(timestamp, author) ?: return null + val messageIdToDelete = messageToDelete.messageId + val messageType = messageToDelete.individualRecipient?.getType() + + // send a /delete rquest for 1on1 messages + if (messageType == MessageType.ONE_ON_ONE) { + messageDataProvider.getServerHashForMessage(messageIdToDelete)?.let { serverHash -> + GlobalScope.launch(Dispatchers.IO) { // using GlobalScope as we are slowly migrating to coroutines but we can't migrate everything at once + try { + SnodeAPI.deleteMessage(author, userAuth, listOf(serverHash)) + } catch (e: Exception) { + Log.e("Loki", "Failed to delete message", e) + } + } + } + } + + // the message is marked as deleted locally + // except for 'note to self' where the message is completely deleted + if (messageType == MessageType.NOTE_TO_SELF){ + messageDataProvider.deleteMessage(messageIdToDelete) + } else { + messageDataProvider.markMessageAsDeleted( + messageIdToDelete, + displayedMessage = context.getString(R.string.deleteMessageDeletedGlobally) + ) + } + + // delete reactions + storage.deleteReactions(messageToDelete.messageId) + + // update notification + if (!messageToDelete.isOutgoing) { + notificationManager.updateNotification(context) + } + + return messageIdToDelete + } + + private fun handleMessageRequestResponse(message: MessageRequestResponse) { + storage.insertMessageRequestResponseFromContact(message) + } + + fun handleVisibleMessage( + message: VisibleMessage, + proto: SignalServiceProtos.Content, + context: VisibleMessageHandlerContext, + runThreadUpdate: Boolean, + runProfileUpdate: Boolean + ): MessageId? { + val userPublicKey = context.storage.getUserPublicKey() + val messageSender: String? = message.sender + + // Do nothing if the message was outdated + if (messageIsOutdated(message, context.threadId, context.openGroupID)) { return null } + + // Update profile if needed + val recipient = Recipient.from(context.context, Address.fromSerialized(messageSender!!), false) + if (runProfileUpdate) { + val profile = message.profile + val isUserBlindedSender = messageSender == context.userBlindedKey + if (profile != null && userPublicKey != messageSender && !isUserBlindedSender) { + val name = profile.displayName!! + if (name.isNotEmpty() && name != recipient.rawName) { + context.profileManager.setName(context.context, recipient, name) + } + val newProfileKey = profile.profileKey + + val needsProfilePicture = !AvatarHelper.avatarFileExists(context.context, Address.fromSerialized(messageSender)) + val profileKeyValid = newProfileKey?.isNotEmpty() == true && (newProfileKey.size == 16 || newProfileKey.size == 32) && profile.profilePictureURL?.isNotEmpty() == true + val profileKeyChanged = (recipient.profileKey == null || !MessageDigest.isEqual(recipient.profileKey, newProfileKey)) + + if ((profileKeyValid && profileKeyChanged) || (profileKeyValid && needsProfilePicture)) { + context.profileManager.setProfilePicture(context.context, recipient, profile.profilePictureURL, newProfileKey) + } else if (newProfileKey == null || newProfileKey.isEmpty() || profile.profilePictureURL.isNullOrEmpty()) { + context.profileManager.setProfilePicture(context.context, recipient, null, null) + } + } + + if (userPublicKey != messageSender && !isUserBlindedSender && message.blocksMessageRequests != recipient.blocksCommunityMessageRequests) { + context.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 + } + if(disappearingState != recipient.disappearingState) { + context.storage.updateDisappearingState( + messageSender, + context.threadId, + disappearingState + ) + } + } + // Handle group invite response if new closed group + if (context.threadRecipient?.isGroupV2Recipient == true) { + GlobalScope.launch { + try { + groupManagerV2 + .handleInviteResponse( + AccountId(context.threadRecipient!!.address.toString()), + AccountId(messageSender), + approved = true + ) + } catch (e: Exception) { + Log.e("Loki", "Failed to handle invite response", e) + } + } + } + // Parse quote if needed + var quoteModel: QuoteModel? = null + var quoteMessageBody: String? = null + if (message.quote != null && proto.dataMessage.hasQuote()) { + val quote = proto.dataMessage.quote + + val author = if (quote.author == context.userBlindedKey) { + Address.fromSerialized(userPublicKey!!) + } else { + Address.fromSerialized(quote.author) + } + + 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) + } else { + QuoteModel(quote.id, author,null, true, PointerAttachment.forPointers(proto.dataMessage.quote.attachmentsList)) + } + } + // Parse link preview if needed + val linkPreviews: MutableList = mutableListOf() + if (message.linkPreview != null && proto.dataMessage.previewCount > 0) { + for (preview in proto.dataMessage.previewList) { + val thumbnail = PointerAttachment.forPointer(preview.image) + val url = Optional.fromNullable(preview.url) + val title = Optional.fromNullable(preview.title) + val hasContent = !TextUtils.isEmpty(title.or("")) || thumbnail.isPresent + if (hasContent) { + val linkPreview = LinkPreview(url.get(), title.or(""), thumbnail) + linkPreviews.add(linkPreview) + } else { + Log.w("Loki", "Discarding an invalid link preview. hasContent: $hasContent") + } + } + } + // Parse attachments if needed + 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 = context.threadRecipient?.isGroupOrCommunityRecipient == true + message.reaction?.let { reaction -> + if (reaction.react == true) { + reaction.serverId = message.openGroupServerMessageID?.toString() ?: message.serverHash.orEmpty() + reaction.dateSent = message.sentTimestamp ?: 0 + reaction.dateReceived = message.receivedTimestamp ?: 0 + context.storage.addReaction( + threadId = context.threadId, + reaction = reaction, + messageSender = messageSender, + notifyUnread = !threadIsGroup + ) + } else { + context.storage.removeReaction( + emoji = reaction.emoji!!, + messageTimestamp = reaction.timestamp!!, + threadId = context.threadId, + author = reaction.publicKey!!, + notifyUnread = threadIsGroup + ) + } + } ?: run { + // A user is mentioned if their public key is in the body of a message or one of their messages + // was quoted + + // Verify the incoming message length and truncate it if needed, before saving it to the db + val maxChars = proStatusManager.getIncomingMessageMaxLength(message) + val messageText = message.text?.take(maxChars) // truncate to max char limit for this message + message.text = messageText + message.hasMention = listOf(userPublicKey, context.userBlindedKey) + .filterNotNull() + .any { key -> + messageText?.contains("@$key") == true || key == (quoteModel?.author?.toString() ?: "") + } + + // Persist the message + message.threadID = context.threadId + + // clean up the message - For example we do not want any expiration data on messages for communities + if(message.openGroupServerMessageID != null){ + message.expiryMode = ExpiryMode.NONE + } + + val messageID = context.storage.persist(message, quoteModel, linkPreviews, message.groupPublicKey, context.openGroupID, attachments, runThreadUpdate) ?: return null + // Parse & persist attachments + // Start attachment downloads if needed + if (messageID.mms && (context.threadRecipient?.autoDownloadAttachments == true || messageSender == userPublicKey)) { + context.storage.getAttachmentsForMessage(messageID.id).iterator().forEach { attachment -> + attachment.attachmentId?.let { id -> + JobQueue.shared.add(attachmentDownloadJobFactory.create( + attachmentID = id.rowId, + mmsMessageId = messageID.id + )) + } + } + } + message.openGroupServerMessageID?.let { + context.storage.setOpenGroupServerMessageID( + messageID = messageID, + serverID = it, + threadID = context.threadId + ) + } + message.id = messageID + context.messageExpirationManager.onMessageReceived(message) + return messageID + } + return null + } + + private fun MessageReceiver.handleGroupUpdated(message: GroupUpdated, closedGroup: AccountId?) { + val inner = message.inner + if (closedGroup == null && + !inner.hasInviteMessage() && !inner.hasPromoteMessage()) { + throw NullPointerException("Message wasn't polled from a closed group!") + } + + // Update profile if needed + if (message.profile != null && !message.isSenderSelf) { + val profile = message.profile + val recipient = Recipient.from(context, Address.fromSerialized(message.sender!!), false) + if (profile.displayName?.isNotEmpty() == true) { + profileManager.setName(context, recipient, profile.displayName) + } + if (profile.profileKey?.isNotEmpty() == true && !profile.profilePictureURL.isNullOrEmpty()) { + profileManager.setProfilePicture(context, recipient, profile.profilePictureURL, profile.profileKey) + } + } + + when { + inner.hasInviteMessage() -> handleNewLibSessionClosedGroupMessage(message) + inner.hasInviteResponse() -> handleInviteResponse(message, closedGroup!!) + inner.hasPromoteMessage() -> handlePromotionMessage(message) + inner.hasInfoChangeMessage() -> handleGroupInfoChange(message, closedGroup!!) + inner.hasMemberChangeMessage() -> handleMemberChange(message, closedGroup!!) + inner.hasMemberLeftMessage() -> handleMemberLeft(message, closedGroup!!) + inner.hasMemberLeftNotificationMessage() -> handleMemberLeftNotification(message, closedGroup!!) + inner.hasDeleteMemberContent() -> handleDeleteMemberContent(message, closedGroup!!) + } + } + + private fun handleDeleteMemberContent(message: GroupUpdated, closedGroup: AccountId) { + val deleteMemberContent = message.inner.deleteMemberContent + val adminSig = if (deleteMemberContent.hasAdminSignature()) deleteMemberContent.adminSignature.toByteArray()!! else byteArrayOf() + + val hasValidAdminSignature = adminSig.isNotEmpty() && runCatching { + verifyAdminSignature( + closedGroup, + adminSig, + buildDeleteMemberContentSignature( + memberIds = deleteMemberContent.memberSessionIdsList.asSequence().map(::AccountId).asIterable(), + messageHashes = deleteMemberContent.messageHashesList, + timestamp = message.sentTimestamp!!, + ) + ) + }.isSuccess + + GlobalScope.launch { + try { + groupManagerV2.handleDeleteMemberContent( + groupId = closedGroup, + deleteMemberContent = deleteMemberContent, + timestamp = message.sentTimestamp!!, + sender = AccountId(message.sender!!), + senderIsVerifiedAdmin = hasValidAdminSignature + ) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle delete member content", e) + } + } + } + + private fun handleMemberChange(message: GroupUpdated, closedGroup: AccountId) { + val memberChange = message.inner.memberChangeMessage + val type = memberChange.type + val timestamp = message.sentTimestamp!! + verifyAdminSignature(closedGroup, + memberChange.adminSignature.toByteArray(), + buildMemberChangeSignature(type, timestamp) + ) + storage.insertGroupInfoChange(message, closedGroup) + } + + private fun handleMemberLeft(message: GroupUpdated, closedGroup: AccountId) { + GlobalScope.launch(Dispatchers.Default) { + try { + groupManagerV2.handleMemberLeftMessage( + AccountId(message.sender!!), closedGroup + ) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle member left message", e) + } + } + } + + private fun handleMemberLeftNotification(message: GroupUpdated, closedGroup: AccountId) { + storage.insertGroupInfoChange(message, closedGroup) + } + + private fun handleGroupInfoChange(message: GroupUpdated, closedGroup: AccountId) { + val inner = message.inner + val infoChanged = inner.infoChangeMessage ?: return + if (!infoChanged.hasAdminSignature()) return Log.e("GroupUpdated", "Info changed message doesn't contain admin signature") + val adminSignature = infoChanged.adminSignature + val type = infoChanged.type + val timestamp = message.sentTimestamp!! + verifyAdminSignature(closedGroup, adminSignature.toByteArray(), buildInfoChangeSignature(type, timestamp)) + + groupManagerV2.handleGroupInfoChange(message, closedGroup) + } + + private fun handlePromotionMessage(message: GroupUpdated) { + val promotion = message.inner.promoteMessage + val seed = promotion.groupIdentitySeed.toByteArray() + val sender = message.sender!! + val adminId = AccountId(sender) + GlobalScope.launch { + try { + groupManagerV2 + .handlePromotion( + groupId = AccountId(IdPrefix.GROUP, ED25519.generate(seed).pubKey.data), + groupName = promotion.name, + adminKeySeed = seed, + promoter = adminId, + promoterName = message.profile?.displayName, + promoteMessageHash = message.serverHash!!, + promoteMessageTimestamp = message.sentTimestamp!!, + ) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle promotion message", e) + } + } + } + + private fun handleInviteResponse(message: GroupUpdated, closedGroup: AccountId) { + val sender = message.sender!! + // val profile = message // maybe we do need data to be the inner so we can access profile + val storage = storage + val approved = message.inner.inviteResponse.isApproved + GlobalScope.launch { + try { + groupManagerV2.handleInviteResponse(closedGroup, AccountId(sender), approved) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle invite response", e) + } + } + } + + private fun handleNewLibSessionClosedGroupMessage(message: GroupUpdated) { + val storage = storage + val ourUserId = storage.getUserPublicKey()!! + val invite = message.inner.inviteMessage + val groupId = AccountId(invite.groupSessionId) + verifyAdminSignature( + groupSessionId = groupId, + signatureData = invite.adminSignature.toByteArray(), + messageToValidate = buildGroupInviteSignature(AccountId(ourUserId), message.sentTimestamp!!) + ) + + val sender = message.sender!! + val adminId = AccountId(sender) + GlobalScope.launch { + try { + groupManagerV2 + .handleInvitation( + groupId = groupId, + groupName = invite.name, + authData = invite.memberAuthData.toByteArray(), + inviter = adminId, + inviterName = message.profile?.displayName, + inviteMessageHash = message.serverHash!!, + inviteMessageTimestamp = message.sentTimestamp!!, + ) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle invite message", e) + } + } + } + + + /** + * Does nothing on successful signature verification, throws otherwise. + * Assumes the signer is using the ed25519 group key signing key + * @param groupSessionId the AccountId of the group to check the signature against + * @param signatureData the byte array supplied to us through a protobuf message from the admin + * @param messageToValidate the expected values used for this signature generation, often something like `INVITE||{inviteeSessionId}||{timestamp}` + * @throws SignatureException if signature cannot be verified with given parameters + */ + private fun verifyAdminSignature(groupSessionId: AccountId, signatureData: ByteArray, messageToValidate: ByteArray) { + val groupPubKey = groupSessionId.pubKeyBytes + if (!ED25519.verify(signature = signatureData, ed25519PublicKey = groupPubKey, message = messageToValidate)) { + throw SignatureException("Verification failed for signature data") + } + } + + private fun isValidGroupUpdate(group: GroupRecord, sentTimestamp: Long, senderPublicKey: String): Boolean { + val oldMembers = group.members.map { it.toString() } + // Check that the message isn't from before the group was created + if (group.formationTimestamp > sentTimestamp) { + Log.d("Loki", "Ignoring closed group update from before thread was created.") + return false + } + // Check that the sender is a member of the group (before the update) + if (senderPublicKey !in oldMembers) { + Log.d("Loki", "Ignoring closed group info message from non-member.") + return false + } + return true + } + + fun disableLocalGroupAndUnsubscribe(groupPublicKey: String, groupID: String, userPublicKey: String, delete: Boolean) { + val storage = storage + storage.removeClosedGroupPublicKey(groupPublicKey) + // Remove the key pairs + storage.removeAllClosedGroupEncryptionKeyPairs(groupPublicKey) + // Mark the group as inactive + storage.setActive(groupID, false) + storage.removeMember(groupID, Address.fromSerialized(userPublicKey)) + // Notify the PN server + PushRegistryV1.unsubscribeGroup(groupPublicKey, publicKey = userPublicKey) + + if (delete) { + storage.getThreadId(Address.fromSerialized(groupID))?.let { threadId -> + storage.cancelPendingMessageSendJobs(threadId) + storage.deleteConversation(threadId) + } + } + } + +} + + + + +// region Control Messages + + +//endregion + +private fun SignalServiceProtos.Content.ExpirationType.expiryMode(durationSeconds: Long) = takeIf { durationSeconds > 0 }?.let { + when (it) { + SignalServiceProtos.Content.ExpirationType.DELETE_AFTER_READ -> ExpiryMode.AfterRead(durationSeconds) + SignalServiceProtos.Content.ExpirationType.DELETE_AFTER_SEND, SignalServiceProtos.Content.ExpirationType.UNKNOWN -> ExpiryMode.AfterSend(durationSeconds) + else -> ExpiryMode.NONE + } +} ?: ExpiryMode.NONE + + +class VisibleMessageHandlerContext @AssistedInject constructor( + @param:ApplicationContext val context: Context, + @Assisted val threadId: Long, + @Assisted val openGroupID: String?, + val storage: StorageProtocol, + val profileManager: SSKEnvironment.ProfileManagerProtocol, + val groupManagerV2: GroupManagerV2, + val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol, + val messageDataProvider: MessageDataProvider, +) { + val openGroup: OpenGroup? by lazy { + openGroupID?.let { storage.getOpenGroup(threadId) } + } + + val userBlindedKey: String? by lazy { + openGroup?.let { + val blindedKey = BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = storage.getUserED25519KeyPair()!!.secretKey.data, + serverPubKey = Hex.fromStringCondensed(it.publicKey), + ) ?: return@let null + + AccountId( + IdPrefix.BLINDED, blindedKey.pubKey.data + ).hexString + } + } + + val userPublicKey: String? by lazy { + storage.getUserPublicKey() + } + + val threadRecipient: Recipient? by lazy { + storage.getRecipientForThread(threadId) + } + + + @AssistedFactory + interface Factory { + fun create( + threadId: Long, + openGroupID: String? + ): VisibleMessageHandlerContext + } +} + + +/** + * Constructs reaction records for a given open group message. + * + * If the open group message exists in our database, we'll construct a list of reaction records + * that is specified in the [reactions]. + * + * Note that this function does not know or check if the local message has any reactions, + * you'll be responsible for that. In simpler words, [out] only contains reactions that are given + * to this function, it will not include any existing reactions in the database. + * + * @param openGroupMessageServerID The server ID of this message + * @param context The context containing necessary data for processing reactions + * @param reactions A map of emoji to [OpenGroupApi.Reaction] objects, representing the reactions for the message + * @param out A mutable map that will be populated with [ReactionRecord]s, keyed by [MessageId] + */ +fun constructReactionRecords( + openGroupMessageServerID: Long, + context: VisibleMessageHandlerContext, + reactions: Map?, + out: MutableMap> +) { + if (reactions.isNullOrEmpty()) return + val messageId = context.messageDataProvider.getMessageID(openGroupMessageServerID, context.threadId) ?: return + + val outList = out.getOrPut(messageId) { arrayListOf() } + + for ((emoji, reaction) in reactions) { + val pendingUserReaction = OpenGroupApi.pendingReactions + .filter { it.server == context.openGroup?.server && it.room == context.openGroup?.room && it.messageId == openGroupMessageServerID && it.add } + .sortedByDescending { it.seqNo } + .any { it.emoji == emoji } + val shouldAddUserReaction = pendingUserReaction || reaction.you || reaction.reactors.contains(context.userPublicKey) + val reactorIds = reaction.reactors.filter { it != context.userBlindedKey && it != context.userPublicKey } + val count = if (reaction.you) reaction.count - 1 else reaction.count + // Add the first reaction (with the count) + reactorIds.firstOrNull()?.let { reactor -> + outList += ReactionRecord( + messageId = messageId, + author = reactor, + emoji = emoji, + serverId = openGroupMessageServerID.toString(), + count = count, + sortId = reaction.index, + ) + } + + // Add all other reactions + val maxAllowed = if (shouldAddUserReaction) 4 else 5 + val lastIndex = min(maxAllowed, reactorIds.size) + reactorIds.slice(1 until lastIndex).map { reactor -> + outList += ReactionRecord( + messageId = messageId, + author = reactor, + emoji = emoji, + serverId = openGroupMessageServerID.toString(), + count = 0, // Only want this on the first reaction + sortId = reaction.index, + ) + } + + // Add the current user reaction (if applicable and not already included) + if (shouldAddUserReaction) { + outList += ReactionRecord( + messageId = messageId, + author = context.userPublicKey!!, + emoji = emoji, + serverId = openGroupMessageServerID.toString(), + count = 1, + sortId = reaction.index, + ) + } + } +} + +//endregion + +// region Closed Groups + + + +// endregion diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/Attachment.java b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/Attachment.java new file mode 100644 index 0000000000..e0cf2c28d6 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/Attachment.java @@ -0,0 +1,151 @@ +package org.session.libsession.messaging.sending_receiving.attachments; + +import android.net.Uri; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public abstract class Attachment { + + @NonNull + private final String contentType; + private final int transferState; + private final long size; + private final String filename; + + @Nullable + private final String location; + @Nullable + private final String key; + @Nullable + private final String relay; + @Nullable + private final byte[] digest; + @Nullable + private final String fastPreflightId; + private final boolean voiceNote; + private final int width; + private final int height; + private final boolean quote; + @Nullable + private final String caption; + private final String url; + + private final long audioDurationMs; + + public Attachment(@NonNull String contentType, int transferState, long size, String filename, + @Nullable String location, @Nullable String key, @Nullable String relay, + @Nullable byte[] digest, @Nullable String fastPreflightId, boolean voiceNote, + int width, int height, boolean quote, @Nullable String caption, String url, + long audioDurationMs) { + this.contentType = contentType; + this.transferState = transferState; + this.size = size; + this.filename = filename; + this.location = location; + this.key = key; + this.relay = relay; + this.digest = digest; + this.fastPreflightId = fastPreflightId; + this.voiceNote = voiceNote; + this.width = width; + this.height = height; + this.quote = quote; + this.caption = caption; + this.url = url; + this.audioDurationMs = audioDurationMs; + } + + @Nullable + public abstract Uri getDataUri(); + + @Nullable + public abstract Uri getThumbnailUri(); + + public int getTransferState() { + return transferState; + } + + public boolean isInProgress() { + return transferState == AttachmentState.DOWNLOADING.getValue(); + } + + public boolean isDone() { + return transferState == AttachmentState.DONE.getValue(); + } + + public boolean isFailed() { + return transferState == AttachmentState.FAILED.getValue(); + } + + public long getSize() { + return size; + } + + public String getFilename() { + return filename; + } + + @NonNull + public String getContentType() { + return contentType; + } + + @Nullable + public String getLocation() { + return location; + } + + @Nullable + public String getKey() { + return key; + } + + @Nullable + public String getRelay() { + return relay; + } + + @Nullable + public byte[] getDigest() { + return digest; + } + + @Nullable + public String getFastPreflightId() { + return fastPreflightId; + } + + public boolean isVoiceNote() { + return voiceNote; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public boolean isQuote() { + return quote; + } + + public @Nullable String getCaption() { + return caption; + } + + public String getUrl() { + return url; + } + + /** + * Returns the duration of the audio in milliseconds. + * This is only relevant for audio attachments. + * + * Returns -1 if the information is not available. + */ + public long getAudioDurationMs() { + return audioDurationMs; + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/AttachmentId.java b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/AttachmentId.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/AttachmentId.java rename to app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/AttachmentId.java diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/DatabaseAttachment.java b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/DatabaseAttachment.java similarity index 88% rename from libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/DatabaseAttachment.java rename to app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/DatabaseAttachment.java index a01e3319b2..de6de160a5 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/DatabaseAttachment.java +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/DatabaseAttachment.java @@ -12,17 +12,17 @@ public class DatabaseAttachment extends Attachment { private final long mmsId; private final boolean hasData; private final boolean hasThumbnail; - private boolean isUploaded = false; + private boolean isUploaded = false; public DatabaseAttachment(AttachmentId attachmentId, long mmsId, boolean hasData, boolean hasThumbnail, String contentType, int transferProgress, long size, - String fileName, String location, String key, String relay, + String filename, String location, String key, String relay, byte[] digest, String fastPreflightId, boolean voiceNote, int width, int height, boolean quote, @Nullable String caption, - String url) - { - super(contentType, transferProgress, size, fileName, location, key, relay, digest, fastPreflightId, voiceNote, width, height, quote, caption, url); + String url, long audioDurationMs + ) { + super(contentType, transferProgress, size, filename, location, key, relay, digest, fastPreflightId, voiceNote, width, height, quote, caption, url, audioDurationMs); this.attachmentId = attachmentId; this.hasData = hasData; this.hasThumbnail = hasThumbnail; diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/DatabaseAttachmentAudioExtras.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/DatabaseAttachmentAudioExtras.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/DatabaseAttachmentAudioExtras.kt rename to app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/DatabaseAttachmentAudioExtras.kt diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/PointerAttachment.java b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/PointerAttachment.java new file mode 100644 index 0000000000..8d0d5b50e0 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/PointerAttachment.java @@ -0,0 +1,159 @@ +package org.session.libsession.messaging.sending_receiving.attachments; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.session.libsignal.utilities.guava.Optional; +import org.session.libsignal.messages.SignalServiceAttachment; +import org.session.libsignal.utilities.Base64; +import org.session.libsignal.protos.SignalServiceProtos; + +import java.util.LinkedList; +import java.util.List; + +public class PointerAttachment extends Attachment { + + private PointerAttachment(@NonNull String contentType, int transferState, long size, + @Nullable String fileName, @NonNull String location, + @Nullable String key, @Nullable String relay, + @Nullable byte[] digest, @Nullable String fastPreflightId, boolean voiceNote, + int width, int height, @Nullable String caption, String url) + { + super(contentType, transferState, size, fileName, location, key, relay, digest, fastPreflightId, voiceNote, width, height, false, caption, url, -1L); + } + + @Nullable + @Override + public Uri getDataUri() { + return null; + } + + @Nullable + @Override + public Uri getThumbnailUri() { + return null; + } + + + public static List forPointers(Optional> pointers) { + List results = new LinkedList<>(); + + if (pointers.isPresent()) { + for (SignalServiceAttachment pointer : pointers.get()) { + Optional result = forPointer(Optional.of(pointer)); + + if (result.isPresent()) { + results.add(result.get()); + } + } + } + + return results; + } + + public static List forPointers(List pointers) { + List results = new LinkedList<>(); + + if (pointers != null) { + for (SignalServiceProtos.DataMessage.Quote.QuotedAttachment pointer : pointers) { + Optional result = forPointer(pointer); + + if (result.isPresent()) { + results.add(result.get()); + } + } + } + + return results; + } + + public static Optional forPointer(Optional pointer) { + return forPointer(pointer, null); + } + + public static Optional forPointer(Optional pointer, @Nullable String fastPreflightId) { + if (!pointer.isPresent() || !pointer.get().isPointer()) return Optional.absent(); + + String encodedKey = null; + + if (pointer.get().asPointer().getKey() != null) { + encodedKey = Base64.encodeBytes(pointer.get().asPointer().getKey()); + } + + return Optional.of(new PointerAttachment(pointer.get().getContentType(), + AttachmentState.PENDING.getValue(), + pointer.get().asPointer().getSize().or(0), + pointer.get().asPointer().getFilename(), + String.valueOf(pointer.get().asPointer().getId()), + encodedKey, null, + pointer.get().asPointer().getDigest().orNull(), + fastPreflightId, + pointer.get().asPointer().getVoiceNote(), + pointer.get().asPointer().getWidth(), + pointer.get().asPointer().getHeight(), + pointer.get().asPointer().getCaption().orNull(), + pointer.get().asPointer().getUrl())); + + } + + public static Optional forPointer(SignalServiceProtos.AttachmentPointer pointer) { + return Optional.of(new PointerAttachment(pointer.getContentType(), + AttachmentState.PENDING.getValue(), + (long)pointer.getSize(), + pointer.getFileName(), + String.valueOf(pointer != null ? pointer.getId() : 0), + pointer.getKey() != null ? Base64.encodeBytes(pointer.getKey().toByteArray()) : null, + null, + pointer.getDigest().toByteArray(), + null, + false, + pointer.getWidth(), + pointer.getHeight(), + pointer.getCaption(), + pointer.getUrl())); + } + + public static Optional forPointer(SignalServiceProtos.DataMessage.Quote.QuotedAttachment pointer) { + SignalServiceProtos.AttachmentPointer thumbnail = pointer.getThumbnail(); + + return Optional.of(new PointerAttachment(pointer.getContentType(), + AttachmentState.PENDING.getValue(), + thumbnail != null ? (long)thumbnail.getSize() : 0, + thumbnail.getFileName(), + String.valueOf(thumbnail != null ? thumbnail.getId() : 0), + thumbnail != null && thumbnail.getKey() != null ? Base64.encodeBytes(thumbnail.getKey().toByteArray()) : null, + null, + thumbnail != null ? thumbnail.getDigest().toByteArray() : null, + null, + false, + thumbnail != null ? thumbnail.getWidth() : 0, + thumbnail != null ? thumbnail.getHeight() : 0, + thumbnail != null ? thumbnail.getCaption() : null, + thumbnail != null ? thumbnail.getUrl() : "")); + } + + /** + * Converts a Session Attachment to a Signal Attachment + * @param attachment Session Attachment + * @return Signal Attachment + */ + public static Attachment forAttachment(org.session.libsession.messaging.messages.visible.Attachment attachment) { + return new PointerAttachment( + attachment.getContentType(), + AttachmentState.PENDING.getValue(), + attachment.getSizeInBytes(), + attachment.getFilename(), + null, Base64.encodeBytes(attachment.getKey()), + null, + attachment.getDigest(), + null, + attachment.getKind() == org.session.libsession.messaging.messages.visible.Attachment.Kind.VOICE_MESSAGE, + attachment.getSize() != null ? attachment.getSize().getWidth() : 0, + attachment.getSize() != null ? attachment.getSize().getHeight() : 0, + attachment.getCaption(), + attachment.getUrl() + ); + } +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachment.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachment.kt similarity index 82% rename from libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachment.kt rename to app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachment.kt index 5da7630ba8..698bf2dc8c 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachment.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachment.kt @@ -32,9 +32,8 @@ abstract class SessionServiceAttachment protected constructor(val contentType: S class Builder internal constructor() { private var inputStream: InputStream? = null private var contentType: String? = null - private var fileName: String? = null + private var filename: String = "PlaceholderFilename" private var length: Long = 0 - private var listener: SignalServiceAttachment.ProgressListener? = null private var voiceNote = false private var width = 0 private var height = 0 @@ -54,13 +53,8 @@ abstract class SessionServiceAttachment protected constructor(val contentType: S return this } - fun withFileName(fileName: String?): Builder { - this.fileName = fileName - return this - } - - fun withListener(listener: SignalServiceAttachment.ProgressListener?): Builder { - this.listener = listener + fun withFilename(filename: String): Builder { + this.filename = filename return this } @@ -88,7 +82,7 @@ abstract class SessionServiceAttachment protected constructor(val contentType: S requireNotNull(inputStream) { "Must specify stream!" } requireNotNull(contentType) { "No content type specified!" } require(length != 0L) { "No length specified!" } - return SessionServiceAttachmentStream(inputStream, contentType, length, Optional.fromNullable(fileName), voiceNote, Optional.absent(), width, height, Optional.fromNullable(caption), listener) + return SessionServiceAttachmentStream(inputStream, contentType, length, filename, voiceNote, Optional.absent(), width, height, Optional.fromNullable(caption)) } } @@ -100,10 +94,10 @@ abstract class SessionServiceAttachment protected constructor(val contentType: S } } -// matches values in AttachmentDatabase.java enum class AttachmentState(val value: Int) { DONE(0), - STARTED(1), + DOWNLOADING(1), PENDING(2), - FAILED(3) + FAILED(3), + EXPIRED(4) } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachmentPointer.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachmentPointer.kt new file mode 100644 index 0000000000..3e297358cb --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachmentPointer.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2014-2017 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ +package org.session.libsession.messaging.sending_receiving.attachments + +import org.session.libsignal.utilities.guava.Optional + +/** + * Represents a received SignalServiceAttachment "handle." This + * is a pointer to the actual attachment content, which needs to be + * retrieved using SignalServiceMessageReceiver.retrieveAttachment + * + * @author Moxie Marlinspike + */ +class SessionServiceAttachmentPointer(val id: Long, contentType: String?, + key: ByteArray?, + val size: Optional, + val preview: Optional, + val width: Int, + val height: Int, + val digest: Optional, + val filename: String, + val voiceNote: Boolean, + val caption: Optional, + url: String +) : SessionServiceAttachment(contentType) { + + override fun isStream(): Boolean { return false } + override fun isPointer(): Boolean { return true } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachmentStream.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachmentStream.kt similarity index 78% rename from libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachmentStream.kt rename to app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachmentStream.kt index 3da1161cb0..4af1d16acc 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachmentStream.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/SessionServiceAttachmentStream.kt @@ -16,9 +16,9 @@ import kotlin.math.round /** * Represents a local SignalServiceAttachment to be sent. */ -class SessionServiceAttachmentStream(val inputStream: InputStream?, contentType: String?, val length: Long, val fileName: Optional?, val voiceNote: Boolean, val preview: Optional, val width: Int, val height: Int, val caption: Optional, val listener: SAttachment.ProgressListener?) : SessionServiceAttachment(contentType) { +class SessionServiceAttachmentStream(val inputStream: InputStream?, contentType: String?, val length: Long, val filename: String, val voiceNote: Boolean, val preview: Optional, val width: Int, val height: Int, val caption: Optional) : SessionServiceAttachment(contentType) { - constructor(inputStream: InputStream?, contentType: String?, length: Long, fileName: Optional?, voiceNote: Boolean, listener: SAttachment.ProgressListener?) : this(inputStream, contentType, length, fileName, voiceNote, Optional.absent(), 0, 0, Optional.absent(), listener) {} + constructor(inputStream: InputStream?, contentType: String?, length: Long, filename: String, voiceNote: Boolean) : this(inputStream, contentType, length, filename, voiceNote, Optional.absent(), 0, 0, Optional.absent()) {} // Though now required, `digest` may be null for pre-existing records or from // messages received from other clients @@ -38,10 +38,8 @@ class SessionServiceAttachmentStream(val inputStream: InputStream?, contentType: fun toProto(): SignalServiceProtos.AttachmentPointer? { val builder = SignalServiceProtos.AttachmentPointer.newBuilder() builder.contentType = this.contentType + builder.fileName = this.filename - if (!this.fileName?.get().isNullOrEmpty()) { - builder.fileName = this.fileName?.get() - } if (!this.caption.get().isNullOrEmpty()) { builder.caption = this.caption.get() } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/UriAttachment.java b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/UriAttachment.java new file mode 100644 index 0000000000..1aeb3f86a1 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/attachments/UriAttachment.java @@ -0,0 +1,58 @@ +package org.session.libsession.messaging.sending_receiving.attachments; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class UriAttachment extends Attachment { + + private final @NonNull Uri dataUri; + private final @Nullable Uri thumbnailUri; + + public UriAttachment(@NonNull Uri uri, @NonNull String contentType, int transferState, long size, + @Nullable String fileName, boolean voiceNote, boolean quote, @Nullable String caption) + { + this(uri, uri, contentType, transferState, size, 0, 0, fileName, null, voiceNote, quote, caption, -1); + } + + public UriAttachment(@NonNull Uri dataUri, @Nullable Uri thumbnailUri, + @NonNull String contentType, int transferState, long size, int width, int height, + @Nullable String fileName, @Nullable String fastPreflightId, + boolean voiceNote, boolean quote, @Nullable String caption) { + this(dataUri, thumbnailUri, contentType, transferState, size, width, height, fileName, fastPreflightId, + voiceNote, quote, caption, -1); + } + + public UriAttachment(@NonNull Uri dataUri, @Nullable Uri thumbnailUri, + @NonNull String contentType, int transferState, long size, int width, int height, + @Nullable String fileName, @Nullable String fastPreflightId, + boolean voiceNote, boolean quote, @Nullable String caption, long audioDurationMs) + { + super(contentType, transferState, size, fileName, null, null, null, null, fastPreflightId, voiceNote, width, height, quote, caption, "", audioDurationMs); + this.dataUri = dataUri; + this.thumbnailUri = thumbnailUri; + } + + @Override + @NonNull + public Uri getDataUri() { + return dataUri; + } + + @Override + @Nullable + public Uri getThumbnailUri() { + return thumbnailUri; + } + + @Override + public boolean equals(Object other) { + return other != null && other instanceof UriAttachment && ((UriAttachment) other).dataUri.equals(this.dataUri); + } + + @Override + public int hashCode() { + return dataUri.hashCode(); + } +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/data_extraction/DataExtractionNotificationInfoMessage.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/data_extraction/DataExtractionNotificationInfoMessage.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/sending_receiving/data_extraction/DataExtractionNotificationInfoMessage.kt rename to app/src/main/java/org/session/libsession/messaging/sending_receiving/data_extraction/DataExtractionNotificationInfoMessage.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/link_preview/LinkPreview.java b/app/src/main/java/org/session/libsession/messaging/sending_receiving/link_preview/LinkPreview.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/sending_receiving/link_preview/LinkPreview.java rename to app/src/main/java/org/session/libsession/messaging/sending_receiving/link_preview/LinkPreview.java diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/MessageNotifier.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/MessageNotifier.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/MessageNotifier.kt rename to app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/MessageNotifier.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Models.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Models.kt similarity index 95% rename from libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Models.kt rename to app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Models.kt index 67ad8d0553..0f7134877f 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Models.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Models.kt @@ -1,6 +1,5 @@ package org.session.libsession.messaging.sending_receiving.notifications -import com.goterl.lazysodium.utils.Key import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -111,13 +110,3 @@ data class PushNotificationMetadata( 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/PushRegistryV1.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt rename to app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Server.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Server.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Server.kt rename to app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Server.kt diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt new file mode 100644 index 0000000000..49e513b1e1 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt @@ -0,0 +1,405 @@ +package org.session.libsession.messaging.sending_receiving.pollers + +import com.google.protobuf.ByteString +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import nl.komponents.kovenant.functional.map +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.BlindedIdMapping +import org.session.libsession.messaging.jobs.BatchMessageReceiveJob +import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob +import org.session.libsession.messaging.jobs.JobQueue +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 +import org.session.libsession.messaging.open_groups.GroupMember +import org.session.libsession.messaging.open_groups.GroupMemberRole +import org.session.libsession.messaging.open_groups.OpenGroup +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.open_groups.OpenGroupMessage +import org.session.libsession.messaging.sending_receiving.MessageReceiver +import org.session.libsession.messaging.sending_receiving.ReceivedMessageHandler +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.utilities.await +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.GroupUtil +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.GroupMemberDatabase +import org.thoughtcrime.securesms.util.AppVisibilityManager +import java.util.concurrent.TimeUnit + +private typealias ManualPollRequestToken = Channel> + +/** + * A [OpenGroupPoller] is responsible for polling all communities on a particular server. + * + * Once this class is created, it will start polling when the app becomes visible (and stop whe + * the app becomes invisible), it will also respond to manual poll requests regardless of the app visibility. + * + * To stop polling, you can cancel the [CoroutineScope] that was passed to the constructor. + */ +class OpenGroupPoller @AssistedInject constructor( + private val storage: StorageProtocol, + private val appVisibilityManager: AppVisibilityManager, + private val receivedMessageHandler: ReceivedMessageHandler, + private val batchMessageJobFactory: BatchMessageReceiveJob.Factory, + private val groupMemberDatabase: GroupMemberDatabase, + @Assisted private val server: String, + @Assisted private val scope: CoroutineScope, +) { + private val mutableIsCaughtUp = MutableStateFlow(false) + val isCaughtUp: StateFlow get() = mutableIsCaughtUp + + private val manualPollRequest = Channel() + + companion object { + private const val POLL_INTERVAL_MILLS: Long = 4000L + const val MAX_INACTIVITIY_PERIOD_MILLS = 14 * 24 * 60 * 60 * 1000L // 14 days + + private const val TAG = "OpenGroupPoller" + + fun handleRoomPollInfo( + storage: StorageProtocol, + memberDb: GroupMemberDatabase, + server: String, + roomToken: String, + pollInfo: OpenGroupApi.RoomPollInfo, + createGroupIfMissingWithPublicKey: String? = null + ) { + 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 ?: "", + description = (pollInfo.details?.description ?: existingOpenGroup?.description), + 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 -> + memberDb.updateGroupMembers( + groupId, GroupMemberRole.MODERATOR, moderatorList + ) + } + pollInfo.details?.hiddenModerators?.let { moderatorList -> + memberDb.updateGroupMembers( + groupId, GroupMemberRole.HIDDEN_MODERATOR, moderatorList + ) + } + // - Admins + pollInfo.details?.admins?.let { moderatorList -> + memberDb.updateGroupMembers( + groupId, GroupMemberRole.ADMIN, moderatorList + ) + } + pollInfo.details?.hiddenAdmins?.let { moderatorList -> + memberDb.updateGroupMembers( + groupId, GroupMemberRole.HIDDEN_ADMIN, moderatorList + ) + } + + // 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) + } + } + } + + init { + scope.launch { + while (true) { + // Wait until the app is visible before starting the polling, + // or when we receive a manual poll request + val token = merge( + appVisibilityManager.isAppVisible.filter { it }.map { null }, + manualPollRequest.receiveAsFlow() + ).first() + + // We might have more than one manual poll request, collect them all now so + // they don't trigger unnecessary pollings + val extraTokens = buildList { + while (true) { + val nexToken = manualPollRequest.tryReceive().getOrNull() ?: break + add(nexToken) + } + } + + mutableIsCaughtUp.value = false + var delayDuration = POLL_INTERVAL_MILLS + try { + Log.d(TAG, "Polling open group messages for server: $server") + pollOnce() + mutableIsCaughtUp.value = true + token?.trySend(Result.success(Unit)) + extraTokens.forEach { it.trySend(Result.success(Unit)) } + } catch (e: Exception) { + Log.e(TAG, "Error while polling open group messages", e) + delayDuration = 2000L + token?.trySend(Result.failure(e)) + } + + delay(delayDuration) + } + } + } + + private suspend fun pollOnce(isPostCapabilitiesRetry: Boolean = false) { + val rooms = storage.getAllOpenGroups() + .values + .asSequence() + .filter { it.server == server } + .map { it.room } + .toList() + + try { + OpenGroupApi + .poll(rooms, server) + .await() + .asSequence() + .filterNot { it.body == null } + .forEach { response -> + when (response.endpoint) { + is Endpoint.Capabilities -> { + handleCapabilities(server, response.body as OpenGroupApi.Capabilities) + } + is Endpoint.RoomPollInfo -> { + handleRoomPollInfo(storage, groupMemberDatabase, server, response.endpoint.roomToken, response.body as OpenGroupApi.RoomPollInfo) + } + is Endpoint.RoomMessagesRecent -> { + handleMessages(server, response.endpoint.roomToken, response.body as List) + } + is Endpoint.RoomMessagesSince -> { + handleMessages(server, response.endpoint.roomToken, response.body as List) + } + is Endpoint.Inbox, is Endpoint.InboxSince -> { + handleDirectMessages(server, false, response.body as List) + } + is Endpoint.Outbox, is Endpoint.OutboxSince -> { + handleDirectMessages(server, true, response.body as List) + } + else -> { /* We don't care about the result of any other calls (won't be polled for) */} + } + } + } catch (e: Exception) { + if (e !is CancellationException) { + Log.e(TAG, "Error while polling open group messages", e) + updateCapabilitiesIfNeeded(isPostCapabilitiesRetry, e) + } + + throw e + } + } + + suspend fun requestPollOnceAndWait() { + val token = Channel>() + manualPollRequest.send(token) + token.receive().getOrThrow() + } + + fun requestPollOnce() { + scope.launch { + manualPollRequest.send(Channel()) + } + } + + private fun updateCapabilitiesIfNeeded(isPostCapabilitiesRetry: Boolean, exception: Exception) { + if (exception is OnionRequestAPI.HTTPRequestFailedBlindingRequiredException) { + if (!isPostCapabilitiesRetry) { + OpenGroupApi.getCapabilities(server).map { + handleCapabilities(server, it) + } + } + } + } + + private fun handleCapabilities(server: String, capabilities: OpenGroupApi.Capabilities) { + storage.setServerCapabilities(server, capabilities.capabilities) + } + + private fun handleMessages( + server: String, + roomToken: String, + messages: List + ) { + val sortedMessages = messages.sortedBy { it.seqno } + sortedMessages.maxOfOrNull { it.seqno }?.let { seqNo -> + storage.setLastMessageServerID(roomToken, server, seqNo) + OpenGroupApi.pendingReactions.removeAll { !(it.seqNo == null || it.seqNo!! > seqNo) } + } + val (deletions, additions) = sortedMessages.partition { it.deleted } + handleNewMessages(server, roomToken, additions.map { + OpenGroupMessage( + serverID = it.id, + sender = it.sessionId, + sentTimestamp = (it.posted * 1000).toLong(), + base64EncodedData = it.data, + base64EncodedSignature = it.signature, + reactions = it.reactions + ) + }) + handleDeletedMessages(server, roomToken, deletions.map { it.id }) + } + + private fun handleDirectMessages( + server: String, + fromOutbox: Boolean, + messages: List + ) { + if (messages.isEmpty()) return + val serverPublicKey = storage.getOpenGroupPublicKey(server)!! + val sortedMessages = messages.sortedBy { it.id } + val lastMessageId = sortedMessages.last().id + val mappingCache = mutableMapOf() + if (fromOutbox) { + storage.setLastOutboxMessageId(server, lastMessageId) + } else { + storage.setLastInboxMessageId(server, lastMessageId) + } + sortedMessages.forEach { + val encodedMessage = Base64.decode(it.message) + val envelope = SignalServiceProtos.Envelope.newBuilder() + .setTimestamp(TimeUnit.SECONDS.toMillis(it.postedAt)) + .setType(SignalServiceProtos.Envelope.Type.SESSION_MESSAGE) + .setContent(ByteString.copyFrom(encodedMessage)) + .setSource(it.sender) + .build() + try { + val (message, proto) = MessageReceiver.parse( + envelope.toByteArray(), + null, + fromOutbox, + if (fromOutbox) it.recipient else it.sender, + serverPublicKey, + emptySet() // this shouldn't be necessary as we are polling open groups here + ) + if (fromOutbox) { + val mapping = mappingCache[it.recipient] ?: storage.getOrCreateBlindedIdMapping( + it.recipient, + server, + serverPublicKey, + true + ) + val syncTarget = mapping.accountId ?: it.recipient + if (message is VisibleMessage) { + message.syncTarget = syncTarget + } else if (message is ExpirationTimerUpdate) { + message.syncTarget = syncTarget + } + mappingCache[it.recipient] = mapping + } + val threadId = Message.getThreadId(message, null, storage, false) + receivedMessageHandler.handle(message, proto, threadId ?: -1, null, null) + } catch (e: Exception) { + Log.e(TAG, "Couldn't handle direct message", e) + } + } + } + + private fun handleNewMessages(server: String, roomToken: String, messages: List) { + val openGroupID = "$server.$roomToken" + val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) + // check thread still exists + val threadId = storage.getThreadId(Address.fromSerialized(groupID)) ?: -1 + val threadExists = threadId >= 0 + if (!threadExists) { return } + val envelopes = mutableListOf?>>() + messages.sortedBy { it.serverID!! }.forEach { message -> + if (!message.base64EncodedData.isNullOrEmpty()) { + val envelope = SignalServiceProtos.Envelope.newBuilder() + .setType(SignalServiceProtos.Envelope.Type.SESSION_MESSAGE) + .setSource(message.sender!!) + .setSourceDevice(1) + .setContent(message.toProto().toByteString()) + .setTimestamp(message.sentTimestamp) + .build() + envelopes.add(Triple( message.serverID, envelope, message.reactions)) + } + } + + envelopes.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { list -> + val parameters = list.map { (serverId, message, reactions) -> + MessageReceiveParameters(message.toByteArray(), openGroupMessageServerID = serverId, reactions = reactions) + } + JobQueue.shared.add(batchMessageJobFactory.create(parameters, openGroupID)) + } + + if (envelopes.isNotEmpty()) { + JobQueue.shared.add(TrimThreadJob(threadId, openGroupID)) + } + } + + private fun handleDeletedMessages(server: String, roomToken: String, serverIds: List) { + val openGroupId = "$server.$roomToken" + val groupID = GroupUtil.getEncodedOpenGroupID(openGroupId.toByteArray()) + val threadID = storage.getThreadId(Address.fromSerialized(groupID)) ?: return + + if (serverIds.isNotEmpty()) { + val deleteJob = OpenGroupDeleteJob(serverIds.toLongArray(), threadID, openGroupId) + JobQueue.shared.add(deleteJob) + } + } + + @AssistedFactory + interface Factory { + fun create(server: String, scope: CoroutineScope): OpenGroupPoller + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt new file mode 100644 index 0000000000..bafd2ff23e --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPollerManager.kt @@ -0,0 +1,116 @@ +package org.session.libsession.messaging.sending_receiving.pollers + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.ConfigUpdateNotification +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "OpenGroupPollerManager" + +/** + * [OpenGroupPollerManager] manages the lifecycle of [OpenGroupPoller] instances for all + * subscribed open groups. It creates a poller for a server (a server can host + * multiple open groups), and it stops the poller when the server is no longer subscribed by + * any open groups. + * + * This process is fully responsive to changes in the user's config and as long as the config + * is up to date, the pollers will be created and stopped correctly. + */ +@Singleton +class OpenGroupPollerManager @Inject constructor( + pollerFactory: OpenGroupPoller.Factory, + configFactory: ConfigFactoryProtocol, + preferences: TextSecurePreferences, + @ManagerScope scope: CoroutineScope +) : OnAppStartupComponent { + val pollers: StateFlow> = + preferences.watchLocalNumber() + .map { it != null } + .distinctUntilChanged() + .flatMapLatest { loggedIn -> + if (loggedIn) { + (configFactory + .configUpdateNotifications + .filter { it is ConfigUpdateNotification.UserConfigsMerged || it == ConfigUpdateNotification.UserConfigsModified } as Flow<*>) + .onStart { emit(Unit) } + .map { + configFactory.withUserConfigs { configs -> + configs.userGroups.allCommunityInfo() + }.mapTo(hashSetOf()) { it.community.baseUrl } + } + } else { + flowOf(emptySet()) + } + } + .scan(emptyMap()) { acc, value -> + if (acc.keys == value) { + acc // No change, return the same map + } else { + val newPollerStates = value.associateWith { baseUrl -> + acc[baseUrl] ?: run { + val scope = CoroutineScope(Dispatchers.Default) + Log.d(TAG, "Creating new poller for $baseUrl") + PollerHandle( + poller = pollerFactory.create(baseUrl, scope), + pollerScope = scope + ) + } + } + + for ((baseUrl, handle) in acc) { + if (baseUrl !in value) { + Log.d(TAG, "Stopping poller for $baseUrl") + handle.pollerScope.cancel() + } + } + + newPollerStates + } + } + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + val isAllCaughtUp: Boolean + get() = pollers.value.values.all { it.poller.isCaughtUp.value } + + + suspend fun pollAllOpenGroupsOnce() { + Log.d(TAG, "Polling all open groups once") + supervisorScope { + pollers.value.map { (server, handle) -> + handle.pollerScope.launch { + runCatching { + handle.poller.requestPollOnceAndWait() + }.onFailure { + Log.e(TAG, "Error polling open group ${server}", it) + } + } + }.joinAll() + } + } + + data class PollerHandle( + val poller: OpenGroupPoller, + val pollerScope: CoroutineScope + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt new file mode 100644 index 0000000000..2acf9b34f1 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -0,0 +1,371 @@ +package org.session.libsession.messaging.sending_receiving.pollers + +import android.os.SystemClock +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import network.loki.messenger.libsession_util.Namespace +import org.session.libsession.database.StorageProtocol +import org.session.libsession.database.userAuth +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.snode.RawResponse +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.utilities.await +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.ConfigMessage +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.UserConfigType +import org.session.libsignal.database.LokiAPIDatabaseProtocol +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.util.AppVisibilityManager +import org.thoughtcrime.securesms.util.NetworkConnectivity +import kotlin.time.Duration.Companion.days + +private const val TAG = "Poller" + +typealias PollerRequestToken = Channel> + +class Poller @AssistedInject constructor( + private val configFactory: ConfigFactoryProtocol, + private val storage: StorageProtocol, + private val lokiApiDatabase: LokiAPIDatabaseProtocol, + private val preferences: TextSecurePreferences, + private val appVisibilityManager: AppVisibilityManager, + private val networkConnectivity: NetworkConnectivity, + private val batchMessageReceiveJobFactory: BatchMessageReceiveJob.Factory, + @Assisted scope: CoroutineScope +) { + private val userPublicKey: String + get() = storage.getUserPublicKey().orEmpty() + + private val manualRequestTokens: SendChannel + val pollState: StateFlow + + init { + val tokenChannel = Channel() + + manualRequestTokens = tokenChannel + pollState = flow { setUpPolling(this, tokenChannel) } + .stateIn(scope, SharingStarted.Eagerly, PollState.Idle) + } + + @AssistedFactory + interface Factory { + fun create(scope: CoroutineScope): Poller + } + + enum class PollState { + Idle, + Polling, + } + + // region Settings + companion object { + private const val RETRY_INTERVAL_MS: Long = 2 * 1000 + private const val MAX_RETRY_INTERVAL_MS: Long = 15 * 1000 + private const val NEXT_RETRY_MULTIPLIER: Float = 1.2f // If we fail to poll we multiply our current retry interval by this (up to the above max) then try again + } + // endregion + + /** + * Request to do a poll from the poller. If it happens to have other requests pending, they + * will be batched together and processed at once. + * + * Note that if there's any error during the poll, this method will throw the same error. + */ + suspend fun requestPollOnce() { + val token = Channel>() + manualRequestTokens.send(token) + token.receive().getOrThrow() + } + + // region Private API + private suspend fun setUpPolling(collector: FlowCollector, tokenReceiver: ReceiveChannel) { + // Migrate to multipart config when needed + if (!preferences.migratedToMultiPartConfig) { + val allConfigNamespaces = intArrayOf(Namespace.USER_PROFILE(), + Namespace.USER_GROUPS(), + Namespace.CONTACTS(), + Namespace.CONVO_INFO_VOLATILE(), + Namespace.GROUP_KEYS(), + Namespace.GROUP_INFO(), + Namespace.GROUP_MEMBERS() + ) + // To migrate to multi part config, we'll need to fetch all the config messages so we + // get the chance to process those multipart messages again... + lokiApiDatabase.clearLastMessageHashesByNamespaces(*allConfigNamespaces) + lokiApiDatabase.clearReceivedMessageHashValuesByNamespaces(*allConfigNamespaces) + + preferences.migratedToMultiPartConfig = true + } + + val pollPool = hashSetOf() // pollPool is the list of snodes we can use while rotating snodes from our swarm + var retryScalingFactor = 1.0f // We increment the retry interval by NEXT_RETRY_MULTIPLIER times this value, which we bump on each failure + + var scheduledNextPoll = 0L + var hasPolledUserProfileOnce = false + + while (true) { + val requestTokens = merge( + combine( + appVisibilityManager.isAppVisible.filter { it }, + networkConnectivity.networkAvailable.filter { it }, + ) { _, _ -> + // If the app is visible and we have network, we can poll but need to stick to + // the scheduled next poll time + val delayMills = scheduledNextPoll - SystemClock.elapsedRealtime() + if (delayMills > 0) { + Log.d(TAG, "Delaying next poll by $delayMills ms") + delay(delayMills) + } + + mutableListOf() + }, + + tokenReceiver.receiveAsFlow().map { mutableListOf(it) } + ).first() + + // Drain the request tokens channel so we can process all pending requests at once + generateSequence { tokenReceiver.tryReceive().getOrNull() } + .mapTo(requestTokens) { it } + + // When we are only just starting to set up the account, we want to poll only the user + // profile config so the user can see their name/avatar ASAP. Once this is done, we + // will do a full poll immediately. + val pollOnlyUserProfileConfig = !hasPolledUserProfileOnce && + configFactory.withUserConfigs { it.userProfile.activeHashes().isEmpty() } + + Log.d(TAG, "Polling...manualTokenSize=${requestTokens.size}, " + + "pollOnlyUserProfileConfig=$pollOnlyUserProfileConfig") + + var pollDelay = RETRY_INTERVAL_MS + collector.emit(PollState.Polling) + try { + // check if the polling pool is empty + if (pollPool.isEmpty()) { + // if it is empty, fill it with the snodes from our swarm + pollPool.addAll(SnodeAPI.getSwarm(userPublicKey).await()) + } + + // randomly get a snode from the pool + val currentNode = pollPool.random() + + // remove that snode from the pool + pollPool.remove(currentNode) + + poll(currentNode, pollOnlyUserProfileConfig) + retryScalingFactor = 1f + + requestTokens.forEach { it.trySend(Result.success(Unit)) } + + if (pollOnlyUserProfileConfig) { + pollDelay = 0L // If we only polled the user profile config, we need to poll again immediately + } + + hasPolledUserProfileOnce = true + } catch (e: CancellationException) { + Log.w(TAG, "Polling cancelled", e) + requestTokens.forEach { it.trySend(Result.failure(e)) } + throw e + } catch (e: Exception) { + Log.e(TAG, "Error while polling:", e) + pollDelay = minOf( + MAX_RETRY_INTERVAL_MS, + (RETRY_INTERVAL_MS * (NEXT_RETRY_MULTIPLIER * retryScalingFactor)).toLong() + ) + retryScalingFactor++ + requestTokens.forEach { it.trySend(Result.failure(e)) } + } finally { + collector.emit(PollState.Idle) + } + + scheduledNextPoll = SystemClock.elapsedRealtime() + pollDelay + } + } + + 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 -> + JobQueue.shared.add(batchMessageReceiveJobFactory.create(chunk)) + } + } + + private fun processConfig(snode: Snode, rawMessages: RawResponse, forConfig: UserConfigType) { + Log.d(TAG, "Received ${rawMessages.size} messages for $forConfig") + val messages = rawMessages["messages"] as? List<*> + val namespace = forConfig.namespace + val processed = if (!messages.isNullOrEmpty()) { + SnodeAPI.updateLastMessageHashValueIfPossible(snode, userPublicKey, messages, namespace) + SnodeAPI.removeDuplicates( + publicKey = userPublicKey, + messages = messages, + messageHashGetter = { (it as? Map<*, *>)?.get("hash") as? String }, + namespace = namespace, + updateStoredHashes = true + ).mapNotNull { rawMessageAsJSON -> + rawMessageAsJSON as Map<*, *> // removeDuplicates should have ensured this is always a map + val hashValue = rawMessageAsJSON["hash"] as? String ?: return@mapNotNull null + val b64EncodedBody = rawMessageAsJSON["data"] as? String ?: return@mapNotNull null + val timestamp = rawMessageAsJSON["t"] as? Long ?: SnodeAPI.nowWithOffset + val body = Base64.decode(b64EncodedBody) + ConfigMessage(data = body, hash = hashValue, timestamp = timestamp) + } + } else emptyList() + + Log.d(TAG, "About to process ${processed.size} messages for $forConfig") + + if (processed.isEmpty()) return + + try { + configFactory.mergeUserConfigs( + userConfigType = forConfig, + messages = processed, + ) + } catch (e: Exception) { + Log.e(TAG, "Error while merging user configs", e) + } + + Log.d(TAG, "Completed processing messages for $forConfig") + } + + + private suspend fun poll(snode: Snode, pollOnlyUserProfileConfig: Boolean) = supervisorScope { + val userAuth = requireNotNull(MessagingModuleConfiguration.shared.storage.userAuth) + + // Get messages call wrapped in an async + val fetchMessageTask = if (!pollOnlyUserProfileConfig) { + val request = SnodeAPI.buildAuthenticatedRetrieveBatchRequest( + lastHash = lokiApiDatabase.getLastMessageHashValue( + snode = snode, + publicKey = userAuth.accountId.hexString, + namespace = Namespace.DEFAULT() + ), + auth = userAuth, + maxSize = -2) + + this.async { + runCatching { + SnodeAPI.sendBatchRequest( + snode = snode, + publicKey = userPublicKey, + request = request, + responseType = Map::class.java + ) + } + } + } else { + null + } + + // Determine which configs to fetch + val configTypesToFetch = if (pollOnlyUserProfileConfig) listOf(UserConfigType.USER_PROFILE) + else UserConfigType.entries.sortedBy { it.processingOrder } + + // Prepare a set to keep track of hashes of config messages we need to extend + val hashesToExtend = mutableSetOf() + + // Fetch the config messages in parallel, record the type and the result + val configFetchTasks = configFactory.withUserConfigs { configs -> + configTypesToFetch + .map { type -> + val config = configs.getConfig(type) + hashesToExtend += config.activeHashes() + val request = SnodeAPI.buildAuthenticatedRetrieveBatchRequest( + lastHash = lokiApiDatabase.getLastMessageHashValue( + snode = snode, + publicKey = userAuth.accountId.hexString, + namespace = type.namespace + ), + auth = userAuth, + namespace = type.namespace, + maxSize = -8 + ) + + this.async { + type to runCatching { + SnodeAPI.sendBatchRequest(snode, userPublicKey, request, Map::class.java) + } + } + } + } + + if (hashesToExtend.isNotEmpty()) { + launch { + try { + SnodeAPI.sendBatchRequest( + snode, + userPublicKey, + SnodeAPI.buildAuthenticatedAlterTtlBatchRequest( + messageHashes = hashesToExtend.toList(), + auth = userAuth, + newExpiry = SnodeAPI.nowWithOffset + 14.days.inWholeMilliseconds, + extend = true + ) + ) + } catch (e: Exception) { + Log.e(TAG, "Error while extending TTL for hashes", e) + } + } + } + + // From here, we will await on the results of pending tasks + + // Always process the configs before the messages + for (task in configFetchTasks) { + val (configType, result) = task.await() + + if (result.isFailure) { + Log.e(TAG, "Error while fetching config for $configType", result.exceptionOrNull()) + continue + } + + processConfig(snode, result.getOrThrow(), configType) + } + + // Process the messages if we requested them + if (fetchMessageTask != null) { + val result = fetchMessageTask.await() + if (result.isFailure) { + Log.e(TAG, "Error while fetching messages", result.exceptionOrNull()) + } else { + processPersonalMessages(snode, result.getOrThrow()) + } + } + } + + private val UserConfigType.processingOrder: Int + get() = when (this) { + UserConfigType.USER_PROFILE -> 0 + UserConfigType.CONTACTS -> 1 + UserConfigType.CONVO_INFO_VOLATILE -> 2 + UserConfigType.USER_GROUPS -> 3 + } +} diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/PollerManager.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/PollerManager.kt new file mode 100644 index 0000000000..aebfe25349 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/PollerManager.kt @@ -0,0 +1,61 @@ +package org.session.libsession.messaging.sending_receiving.pollers + +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manages the lifecycle of the [Poller] instance and the interaction with the poller. + * + * This is done by controlling the coroutineScope that runs the poller, listening to the changes in + * the logged in state. + */ +@Singleton +class PollerManager @Inject constructor( + prefers: TextSecurePreferences, + provider: Poller.Factory, +) : OnAppStartupComponent { + @OptIn(DelicateCoroutinesApi::class) + private val currentPoller: StateFlow = channelFlow { + prefers.watchLocalNumber() + .map { it != null } + .distinctUntilChanged() + .collectLatest { loggedIn -> + if (loggedIn) { + coroutineScope { + val poller = provider.create(this) + send(poller) + awaitCancellation() + } + } else { + send(null) + } + } + }.stateIn(GlobalScope, SharingStarted.Eagerly, null) + + val isPolling: Boolean + get() = currentPoller.value?.pollState?.value == Poller.PollState.Polling + + /** + * Requests a poll from the current poller. + * + * If there's none, it will suspend until one is created. + */ + suspend fun pollOnce() { + currentPoller.filterNotNull().first().requestPollOnce() + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/quotes/QuoteModel.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/quotes/QuoteModel.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/sending_receiving/quotes/QuoteModel.kt rename to app/src/main/java/org/session/libsession/messaging/sending_receiving/quotes/QuoteModel.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/Data.java b/app/src/main/java/org/session/libsession/messaging/utilities/Data.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/utilities/Data.java rename to app/src/main/java/org/session/libsession/messaging/utilities/Data.java diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/MessageAuthentication.kt b/app/src/main/java/org/session/libsession/messaging/utilities/MessageAuthentication.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/utilities/MessageAuthentication.kt rename to app/src/main/java/org/session/libsession/messaging/utilities/MessageAuthentication.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt b/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt similarity index 97% rename from libsession/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt rename to app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt index ee5b5f87f3..cee136afa4 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt +++ b/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt @@ -53,7 +53,7 @@ object MessageWrapper { request = WebSocketRequestMessage.newBuilder().apply { verb = "PUT" path = "/api/v1/message" - id = SecureRandom.getInstance("SHA1PRNG").nextLong() + id = SecureRandom().nextLong() body = envelope.toByteString() }.build() type = WebSocketMessage.Type.REQUEST diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt b/app/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt similarity index 93% rename from libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt rename to app/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt index 0045c31c52..0fbd0f3151 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt +++ b/app/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt @@ -2,23 +2,19 @@ package org.session.libsession.messaging.utilities import android.content.Context import com.squareup.phrase.Phrase -import network.loki.messenger.libsession_util.getOrNull -import org.session.libsession.R +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.ExpiryMode 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.ConfigFactoryProtocol import org.session.libsession.utilities.ExpirationUtil -import org.session.libsession.utilities.getExpirationTypeDisplayValue -import org.session.libsession.utilities.truncateIdForDisplay -import org.session.libsignal.utilities.Log import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DISAPPEARING_MESSAGES_TYPE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY @@ -27,15 +23,17 @@ import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_K import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY import org.session.libsession.utilities.getGroup import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Log object UpdateMessageBuilder { const val TAG = "UpdateMessageBuilder" val storage = MessagingModuleConfiguration.shared.storage + val usernameUtils = MessagingModuleConfiguration.shared.usernameUtils private fun getGroupMemberName(memberId: String, groupId: AccountId? = null) = - storage.getContactNameWithAccountID(memberId, groupId) + usernameUtils.getContactNameWithAccountID(memberId, groupId) @JvmStatic fun buildGroupUpdateMessage( @@ -315,15 +313,12 @@ object UpdateMessageBuilder { } } - fun buildExpirationTimerMessage( context: Context, - duration: Long, - isGroup: Boolean, // Note: isGroup should cover both closed groups AND communities - senderId: String? = null, - isOutgoing: Boolean = false, - timestamp: Long, - expireStarted: Long + mode: ExpiryMode, + isGroup: Boolean, // Note: isGroup should cover both closed groups AND communities + senderId: String?, + isOutgoing: Boolean, ): CharSequence { if (!isOutgoing && senderId == null) { Log.w(TAG, "buildExpirationTimerMessage: Cannot build for outgoing message when senderId is null.") @@ -333,7 +328,7 @@ object UpdateMessageBuilder { val senderName = if (isOutgoing) context.getString(R.string.you) else getGroupMemberName(senderId!!) // Case 1.) Disappearing messages have been turned off.. - if (duration <= 0) { + if (mode == ExpiryMode.NONE) { // ..by you.. return if (isOutgoing) { // in a group @@ -355,8 +350,12 @@ object UpdateMessageBuilder { } // Case 2.) Disappearing message settings have been changed but not turned off. - val time = ExpirationUtil.getExpirationDisplayValue(context, duration.toInt()) - val action = context.getExpirationTypeDisplayValue(timestamp >= expireStarted) + val time = ExpirationUtil.getExpirationDisplayValue(context, mode.duration) + val action = if (mode is ExpiryMode.AfterSend) { + context.getString(R.string.disappearingMessagesTypeSent) + } else { + context.getString(R.string.disappearingMessagesTypeRead) + } //..by you.. if (isOutgoing) { @@ -383,6 +382,30 @@ object UpdateMessageBuilder { } } + + @Deprecated("Use the version with ExpiryMode instead. This will be removed in a future release.") + fun buildExpirationTimerMessage( + context: Context, + duration: Long, + isGroup: Boolean, // Note: isGroup should cover both closed groups AND communities + senderId: String? = null, + isOutgoing: Boolean = false, + timestamp: Long, + expireStarted: Long + ): CharSequence { + return buildExpirationTimerMessage( + context, + mode = when { + duration == 0L -> ExpiryMode.NONE + timestamp >= expireStarted -> ExpiryMode.AfterSend(duration) // Not the greatest logic here but keeping it for backwards compatibility, can be removed once migrated over + else -> ExpiryMode.AfterRead(duration) + }, + isGroup = isGroup, + senderId = senderId, + isOutgoing = isOutgoing, + ) + } + fun buildDataExtractionMessage(context: Context, kind: DataExtractionNotificationInfoMessage.Kind, senderId: String? = null): CharSequence { @@ -401,7 +424,7 @@ object UpdateMessageBuilder { } fun buildCallMessage(context: Context, type: CallMessageType, senderId: String): String { - val senderName = storage.getContactNameWithAccountID(senderId) + val senderName = usernameUtils.getContactNameWithAccountID(senderId) return when (type) { CALL_INCOMING -> Phrase.from(context, R.string.callsCalledYou).put(NAME_KEY, senderName) diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageData.kt b/app/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageData.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageData.kt rename to app/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageData.kt diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/WebRtcUtils.kt b/app/src/main/java/org/session/libsession/messaging/utilities/WebRtcUtils.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/messaging/utilities/WebRtcUtils.kt rename to app/src/main/java/org/session/libsession/messaging/utilities/WebRtcUtils.kt diff --git a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt b/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt similarity index 96% rename from libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt rename to app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt index 7357c1f1aa..9315ed424d 100644 --- a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt +++ b/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt @@ -1,6 +1,5 @@ package org.session.libsession.snode -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -27,7 +26,8 @@ import org.session.libsignal.crypto.secureRandom import org.session.libsignal.crypto.secureRandomOrNull import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.utilities.Base64 -import org.session.libsignal.utilities.Broadcaster +import org.session.libsignal.utilities.ByteArraySlice +import org.session.libsignal.utilities.ByteArraySlice.Companion.view import org.session.libsignal.utilities.ForkInfo import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.JsonUtil @@ -35,14 +35,14 @@ import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.recover import org.session.libsignal.utilities.toHexString -import java.util.concurrent.atomic.AtomicReference -import kotlin.collections.set private typealias Path = List /** * See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information. */ + + object OnionRequestAPI { private var buildPathsPromise: Promise, Exception>? = null private val database: LokiAPIDatabaseProtocol @@ -340,7 +340,7 @@ object OnionRequestAPI { val url = "${nonNullGuardSnode.address}:${nonNullGuardSnode.port}/onion_req/v2" val finalEncryptionResult = result.finalEncryptionResult val onion = finalEncryptionResult.ciphertext - if (destination is Destination.Server && onion.count().toDouble() > 0.75 * FileServerApi.maxFileSize.toDouble()) { + if (destination is Destination.Server && onion.count().toDouble() > 0.75 * FileServerApi.MAX_FILE_SIZE.toDouble()) { Log.d("Loki", "Approaching request size limit: ~${onion.count()} bytes.") } @Suppress("NAME_SHADOWING") val parameters = mapOf( @@ -366,7 +366,7 @@ object OnionRequestAPI { } val promise = deferred.promise promise.fail { exception -> - if (exception is HTTP.HTTPRequestFailedException && SnodeModule.isInitialized) { + if (exception is HTTP.HTTPRequestFailedException) { val checkedGuardSnode = guardSnode val path = if (checkedGuardSnode == null) null @@ -411,7 +411,7 @@ object OnionRequestAPI { handleUnspecificError() } } else if (destination is Destination.Server && exception.statusCode == 400) { - Log.d("Loki","Destination server returned ${exception.statusCode}") + Log.d("Loki","Destination server returned code ${exception.statusCode} with message: $message") } else if (message == "Loki Server error") { Log.d("Loki", "message was $message") } else if (exception.statusCode == 404) { @@ -524,7 +524,7 @@ object OnionRequestAPI { if (response.size <= AESGCM.ivSize) return deferred.reject(Exception("Invalid response")) // The data will be in the form of `l123:jsone` or `l123:json456:bodye` so we need to break the data into // parts to properly process it - val plaintext = AESGCM.decrypt(response, destinationSymmetricKey) + val plaintext = AESGCM.decrypt(response, symmetricKey = destinationSymmetricKey) if (!byteArrayOf(plaintext.first()).contentEquals("l".toByteArray())) return deferred.reject(Exception("Invalid response")) val infoSepIdx = plaintext.indexOfFirst { byteArrayOf(it).contentEquals(":".toByteArray()) } val infoLenSlice = plaintext.slice(1 until infoSepIdx) @@ -556,6 +556,7 @@ object OnionRequestAPI { responseInfo, destination.description ) + return deferred.reject(exception) } } @@ -579,7 +580,10 @@ object OnionRequestAPI { val base64EncodedIVAndCiphertext = json["result"] as? String ?: return deferred.reject(Exception("Invalid JSON")) val ivAndCiphertext = Base64.decode(base64EncodedIVAndCiphertext) try { - val plaintext = AESGCM.decrypt(ivAndCiphertext, destinationSymmetricKey) + val plaintext = AESGCM.decrypt( + ivAndCiphertext, + symmetricKey = destinationSymmetricKey + ) try { @Suppress("NAME_SHADOWING") val json = JsonUtil.fromJson(plaintext.toString(Charsets.UTF_8), Map::class.java) @@ -629,7 +633,7 @@ object OnionRequestAPI { ) return deferred.reject(exception) } - deferred.resolve(OnionResponse(body, JsonUtil.toJson(body).toByteArray())) + deferred.resolve(OnionResponse(body, JsonUtil.toJson(body).toByteArray().view())) } else -> { if (statusCode != 200) { @@ -640,7 +644,7 @@ object OnionRequestAPI { ) return deferred.reject(exception) } - deferred.resolve(OnionResponse(json, JsonUtil.toJson(json).toByteArray())) + deferred.resolve(OnionResponse(json, JsonUtil.toJson(json).toByteArray().view())) } } } catch (exception: Exception) { @@ -652,17 +656,16 @@ object OnionRequestAPI { } } - private fun ByteArray.getBody(infoLength: Int, infoEndIndex: Int): ByteArray { + private fun ByteArray.getBody(infoLength: Int, infoEndIndex: Int): ByteArraySlice { // If there is no data in the response, i.e. only `l123:jsone`, then just return the ResponseInfo val infoLengthStringLength = infoLength.toString().length if (size <= infoLength + infoLengthStringLength + 2/*l and e bytes*/) { - return byteArrayOf() + return ByteArraySlice.EMPTY } // Extract the response data as well - val dataSlice = slice(infoEndIndex + 1 until size - 1) - val dataSepIdx = dataSlice.indexOfFirst { byteArrayOf(it).contentEquals(":".toByteArray()) } - val responseBody = dataSlice.slice(dataSepIdx + 1 until dataSlice.size) - return responseBody.toByteArray() + val dataSlice = view(infoEndIndex + 1 until size - 1) + val dataSepIdx = dataSlice.asList().indexOfFirst { it.toInt() == ':'.code } + return dataSlice.view(dataSepIdx + 1 until dataSlice.len) } // endregion @@ -676,7 +679,7 @@ enum class Version(val value: String) { data class OnionResponse( val info: Map<*, *>, - val body: ByteArray? = null + val body: ByteArraySlice? = 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/OnionRequestEncryption.kt b/app/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt similarity index 93% rename from libsession/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt rename to app/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt index e762fefcfe..dc3435b65f 100644 --- a/libsession/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt +++ b/app/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt @@ -1,15 +1,10 @@ package org.session.libsession.snode -import kotlinx.coroutines.GlobalScope -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.deferred import org.session.libsession.snode.OnionRequestAPI.Destination -import org.session.libsession.snode.utilities.asyncPromise import org.session.libsession.utilities.AESGCM import org.session.libsession.utilities.AESGCM.EncryptionResult -import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.JsonUtil -import org.session.libsignal.utilities.ThreadUtils +import org.session.libsignal.utilities.toHexString import java.nio.Buffer import java.nio.ByteBuffer import java.nio.ByteOrder diff --git a/app/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt b/app/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt new file mode 100644 index 0000000000..6188ea17b6 --- /dev/null +++ b/app/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt @@ -0,0 +1,38 @@ +package org.session.libsession.snode + +import network.loki.messenger.libsession_util.ED25519 +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Base64 + +/** + * A [SwarmAuth] that signs message using a single ED25519 private key. + * + * This should be used for the owner of an account, like a user or a group admin. + */ +class OwnedSwarmAuth( + override val accountId: AccountId, + override val ed25519PublicKeyHex: String?, + val ed25519PrivateKey: ByteArray, +) : SwarmAuth { + override fun sign(data: ByteArray): Map { + val signature = Base64.encodeBytes(ED25519.sign(ed25519PrivateKey = ed25519PrivateKey, message = data)) + + return buildMap { + put("signature", signature) + } + } + + override fun signForPushRegistry(data: ByteArray): Map { + return sign(data) + } + + companion object { + fun ofClosedGroup(groupAccountId: AccountId, adminKey: ByteArray): OwnedSwarmAuth { + return OwnedSwarmAuth( + accountId = groupAccountId, + ed25519PublicKeyHex = null, + ed25519PrivateKey = adminKey + ) + } + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt similarity index 91% rename from libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt rename to app/src/main/java/org/session/libsession/snode/SnodeAPI.kt index 1484235293..3dae9da7a0 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -4,21 +4,22 @@ package org.session.libsession.snode import android.os.SystemClock import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.node.NullNode -import com.fasterxml.jackson.databind.node.TextNode -import com.goterl.lazysodium.exceptions.SodiumException -import com.goterl.lazysodium.interfaces.GenericHash -import com.goterl.lazysodium.interfaces.PwHash -import com.goterl.lazysodium.interfaces.SecretBox -import com.goterl.lazysodium.utils.Key import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select +import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.Hash +import network.loki.messenger.libsession_util.SessionEncrypt +import kotlinx.coroutines.selects.onTimeout import nl.komponents.kovenant.Promise import nl.komponents.kovenant.all import nl.komponents.kovenant.functional.bind @@ -26,12 +27,12 @@ import nl.komponents.kovenant.functional.map import nl.komponents.kovenant.unwrap import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.utilities.MessageWrapper -import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsession.snode.model.BatchResponse import org.session.libsession.snode.model.StoreMessageResponse import org.session.libsession.snode.utilities.asyncPromise import org.session.libsession.snode.utilities.await import org.session.libsession.snode.utilities.retrySuspendAsPromise +import org.session.libsession.utilities.Environment import org.session.libsession.utilities.mapValuesNotNull import org.session.libsession.utilities.toByteArray import org.session.libsignal.crypto.secureRandom @@ -62,6 +63,8 @@ object SnodeAPI { get() = SnodeModule.shared.broadcaster private var snodeFailureCount: MutableMap = mutableMapOf() + + // the list of "generic" nodes we use to make non swarm specific api calls internal var snodePool: Set get() = database.getSnodePool() set(newValue) { database.setSnodePool(newValue) } @@ -83,15 +86,17 @@ object SnodeAPI { private const val minimumSnodePoolCount = 12 private const val minimumSwarmSnodeCount = 3 // Use port 4433 to enforce pinned certificates - private val seedNodePort = 4443 - - private val seedNodePool = if (SnodeModule.shared.useTestNet) setOf( - "http://public.loki.foundation:38157" - ) else setOf( - "https://seed1.getsession.org:$seedNodePort", - "https://seed2.getsession.org:$seedNodePort", - "https://seed3.getsession.org:$seedNodePort", - ) + private val seedNodePort = 443 + + private val seedNodePool = when (SnodeModule.shared.environment) { + Environment.DEV_NET -> setOf("http://192.168.1.223:1280") + Environment.TEST_NET -> setOf("http://public.loki.foundation:38157") + Environment.MAIN_NET -> setOf( + "https://seed1.getsession.org:$seedNodePort", + "https://seed2.getsession.org:$seedNodePort", + "https://seed3.getsession.org:$seedNodePort", + ) + } private const val snodeFailureThreshold = 3 private const val useOnionRequests = true @@ -242,70 +247,44 @@ object SnodeAPI { } // Public API - fun getAccountID(onsName: String): Promise = scope.asyncPromise { + suspend fun getAccountID(onsName: String): String { val validationCount = 3 val accountIDByteCount = 33 // Hash the ONS name using BLAKE2b - val onsName = onsName.toLowerCase(Locale.US) - val nameAsData = onsName.toByteArray() - val nameHash = ByteArray(GenericHash.BYTES) - if (!sodium.cryptoGenericHash(nameHash, nameHash.size, nameAsData, nameAsData.size.toLong())) { - throw Error.HashingFailed - } - val base64EncodedNameHash = Base64.encodeBytes(nameHash) + val onsName = onsName.lowercase(Locale.US) // Ask 3 different snodes for the Account ID associated with the given name hash val parameters = buildMap { this["endpoint"] = "ons_resolve" this["params"] = buildMap { this["type"] = 0 - this["name_hash"] = base64EncodedNameHash + this["name_hash"] = Base64.encodeBytes(Hash.hash32(onsName.toByteArray())) } } - val promises = List(validationCount) { - getRandomSnode().bind { snode -> - retryIfNeeded(maxRetryCount) { - invoke(Snode.Method.OxenDaemonRPCCall, snode, parameters) + + return List(validationCount) { + scope.async { + retryWithUniformInterval( + maxRetryCount = maxRetryCount, + ) { + val snode = getRandomSnode().await() + invoke(Snode.Method.OxenDaemonRPCCall, snode, parameters).await() } } - } - all(promises).map { results -> - results.map { json -> + }.awaitAll().map { json -> val intermediate = json["result"] as? Map<*, *> ?: throw Error.Generic val hexEncodedCiphertext = intermediate["encrypted_value"] as? String ?: throw Error.Generic val ciphertext = Hex.fromStringCondensed(hexEncodedCiphertext) - val isArgon2Based = (intermediate["nonce"] == null) - if (isArgon2Based) { - // Handle old Argon2-based encryption used before HF16 - val salt = ByteArray(PwHash.SALTBYTES) - val nonce = ByteArray(SecretBox.NONCEBYTES) - val accountIDAsData = ByteArray(accountIDByteCount) - val key = try { - Key.fromHexString(sodium.cryptoPwHash(onsName, SecretBox.KEYBYTES, salt, PwHash.OPSLIMIT_MODERATE, PwHash.MEMLIMIT_MODERATE, PwHash.Alg.PWHASH_ALG_ARGON2ID13)).asBytes - } catch (e: SodiumException) { - throw Error.HashingFailed - } - if (!sodium.cryptoSecretBoxOpenEasy(accountIDAsData, ciphertext, ciphertext.size.toLong(), nonce, key)) { - throw Error.DecryptionFailed - } - Hex.toStringCondensed(accountIDAsData) - } else { - val hexEncodedNonce = intermediate["nonce"] as? String ?: throw Error.Generic - val nonce = Hex.fromStringCondensed(hexEncodedNonce) - val key = ByteArray(GenericHash.BYTES) - if (!sodium.cryptoGenericHash(key, key.size, nameAsData, nameAsData.size.toLong(), nameHash, nameHash.size)) { - throw Error.HashingFailed - } - val accountIDAsData = ByteArray(accountIDByteCount) - if (!sodium.cryptoAeadXChaCha20Poly1305IetfDecrypt(accountIDAsData, null, null, ciphertext, ciphertext.size.toLong(), null, 0, nonce, key)) { - throw Error.DecryptionFailed - } - Hex.toStringCondensed(accountIDAsData) - } + val nonce = (intermediate["nonce"] as? String)?.let(Hex::fromStringCondensed) + SessionEncrypt.decryptOnsResponse( + lowercaseName = onsName, + ciphertext = ciphertext, + nonce = nonce + ) }.takeIf { it.size == validationCount && it.toSet().size == 1 }?.first() ?: throw Error.ValidationFailed - } - }.unwrap() + } + // the list of snodes that represent the swarm for that pubkey fun getSwarm(publicKey: String): Promise, Exception> = database.getSwarm(publicKey)?.takeIf { it.size >= minimumSwarmSnodeCount }?.let(Promise.Companion::of) ?: getRandomSnode().bind { @@ -333,7 +312,6 @@ object SnodeAPI { return parseSnodes(response) } - /** * Build parameters required to call authenticated storage API. * @@ -917,17 +895,16 @@ object SnodeAPI { // Hashes of deleted messages val hashes = json["deleted"] as List 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 = sequenceOf(swarmAuth.accountId.hexString) .plus(serverHashes) .plus(hashes) .toByteArray() - sodium.cryptoSignVerifyDetached( - Base64.decode(signature), - message, - message.size, - snodePublicKey.asBytes + + ED25519.verify( + ed25519PublicKey = Hex.fromStringCondensed(hexSnodePublicKey), + signature = Base64.decode(signature), + message = message, ) } } @@ -1081,10 +1058,13 @@ object SnodeAPI { } else { val hashes = (json["deleted"] as Map>).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 = sequenceOf(userPublicKey, "$timestamp").plus(hashes).toByteArray() - sodium.cryptoSignVerifyDetached(Base64.decode(signature), message, message.size, snodePublicKey.asBytes) + ED25519.verify( + ed25519PublicKey = Hex.fromStringCondensed(hexSnodePublicKey), + signature = Base64.decode(signature), + message = message, + ) } } ?: mapOf() diff --git a/libsession/src/main/java/org/session/libsession/snode/SnodeClock.kt b/app/src/main/java/org/session/libsession/snode/SnodeClock.kt similarity index 85% rename from libsession/src/main/java/org/session/libsession/snode/SnodeClock.kt rename to app/src/main/java/org/session/libsession/snode/SnodeClock.kt index be6f2fd8d6..71382140cf 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeClock.kt +++ b/app/src/main/java/org/session/libsession/snode/SnodeClock.kt @@ -1,7 +1,7 @@ package org.session.libsession.snode import android.os.SystemClock -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -10,7 +10,11 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.session.libsession.snode.utilities.await import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton /** * A class that manages the network time by querying the network time from a random snode. The @@ -20,14 +24,17 @@ import java.util.Date * Before the first network query is successfully, calling [currentTimeMills] will return the current * system time. */ -class SnodeClock() { +@Singleton +class SnodeClock @Inject constructor( + @param:ManagerScope private val scope: CoroutineScope +) : OnAppStartupComponent { private val instantState = MutableStateFlow(null) private var job: Job? = null - fun start() { + override fun onPostAppStarted() { require(job == null) { "Already started" } - job = GlobalScope.launch { + job = scope.launch { while (true) { try { val node = SnodeAPI.getRandomSnode().await() @@ -76,6 +83,10 @@ class SnodeClock() { return instantState.value?.now() ?: System.currentTimeMillis() } + fun currentTimeSeconds(): Long { + return currentTimeMills() / 1000 + } + private class Instant( val systemUptime: Long, val networkTime: Long, diff --git a/libsession/src/main/java/org/session/libsession/snode/SnodeMessage.kt b/app/src/main/java/org/session/libsession/snode/SnodeMessage.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/snode/SnodeMessage.kt rename to app/src/main/java/org/session/libsession/snode/SnodeMessage.kt diff --git a/app/src/main/java/org/session/libsession/snode/SnodeModule.kt b/app/src/main/java/org/session/libsession/snode/SnodeModule.kt new file mode 100644 index 0000000000..fc0549cd14 --- /dev/null +++ b/app/src/main/java/org/session/libsession/snode/SnodeModule.kt @@ -0,0 +1,28 @@ +package org.session.libsession.snode + +import android.app.Application +import dagger.Lazy +import org.session.libsession.utilities.Environment +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.database.LokiAPIDatabaseProtocol +import org.session.libsignal.utilities.Broadcaster +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SnodeModule @Inject constructor( + application: Application, + val storage: LokiAPIDatabaseProtocol, + prefs: TextSecurePreferences, +) { + + val broadcaster: Broadcaster = org.thoughtcrime.securesms.util.Broadcaster(application) + val environment: Environment = prefs.getEnvironment() + + companion object { + lateinit var sharedLazy: Lazy + + @Deprecated("Use properly DI components instead") + val shared: SnodeModule get() = sharedLazy.get() + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/snode/StorageProtocol.kt b/app/src/main/java/org/session/libsession/snode/StorageProtocol.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/snode/StorageProtocol.kt rename to app/src/main/java/org/session/libsession/snode/StorageProtocol.kt diff --git a/libsession/src/main/java/org/session/libsession/snode/SwarmAuth.kt b/app/src/main/java/org/session/libsession/snode/SwarmAuth.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/snode/SwarmAuth.kt rename to app/src/main/java/org/session/libsession/snode/SwarmAuth.kt diff --git a/libsession/src/main/java/org/session/libsession/snode/model/BatchResponse.kt b/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/snode/model/BatchResponse.kt rename to app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt diff --git a/libsession/src/main/java/org/session/libsession/snode/model/MessageResponses.kt b/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/snode/model/MessageResponses.kt rename to app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt diff --git a/libsession/src/main/java/org/session/libsession/snode/utilities/OKHTTPUtilities.kt b/app/src/main/java/org/session/libsession/snode/utilities/OKHTTPUtilities.kt similarity index 94% rename from libsession/src/main/java/org/session/libsession/snode/utilities/OKHTTPUtilities.kt rename to app/src/main/java/org/session/libsession/snode/utilities/OKHTTPUtilities.kt index 981664f2d3..f6b44398d2 100644 --- a/libsession/src/main/java/org/session/libsession/snode/utilities/OKHTTPUtilities.kt +++ b/app/src/main/java/org/session/libsession/snode/utilities/OKHTTPUtilities.kt @@ -17,7 +17,7 @@ internal fun Request.getHeadersForOnionRequest(): Map { for (name in headers.names()) { val value = headers[name] if (value != null) { - if (value.toLowerCase(Locale.US) == "true" || value.toLowerCase(Locale.US) == "false") { + if (value.lowercase(Locale.US) == "true" || value.lowercase(Locale.US) == "false") { result[name] = value.toBoolean() } else if (value.toIntOrNull() != null) { result[name] = value.toInt() diff --git a/libsession/src/main/java/org/session/libsession/snode/utilities/PromiseUtil.kt b/app/src/main/java/org/session/libsession/snode/utilities/PromiseUtil.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/snode/utilities/PromiseUtil.kt rename to app/src/main/java/org/session/libsession/snode/utilities/PromiseUtil.kt diff --git a/app/src/main/java/org/session/libsession/utilities/AESGCM.kt b/app/src/main/java/org/session/libsession/utilities/AESGCM.kt new file mode 100644 index 0000000000..4685b5767c --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/AESGCM.kt @@ -0,0 +1,76 @@ +package org.session.libsession.utilities + +import androidx.annotation.WorkerThread +import network.loki.messenger.libsession_util.Curve25519 +import network.loki.messenger.libsession_util.SessionEncrypt +import org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK +import org.session.libsignal.utilities.ByteUtil +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.Util +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +@WorkerThread +internal object AESGCM { + internal val gcmTagSize = 128 + internal val ivSize = 12 + + internal data class EncryptionResult( + internal val ciphertext: ByteArray, + internal val symmetricKey: ByteArray, + internal val ephemeralPublicKey: ByteArray + ) + + /** + * Sync. Don't call from the main thread. + */ + internal fun decrypt( + ivAndCiphertext: ByteArray, + offset: Int = 0, + len: Int = ivAndCiphertext.size, + symmetricKey: ByteArray + ): ByteArray { + val iv = ivAndCiphertext.sliceArray(offset until (offset + ivSize)) + synchronized(CIPHER_LOCK) { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(symmetricKey, "AES"), GCMParameterSpec(gcmTagSize, iv)) + return cipher.doFinal(ivAndCiphertext, offset + ivSize, len - ivSize) + } + } + + /** + * Sync. Don't call from the main thread. + */ + private fun generateSymmetricKey(x25519PublicKey: ByteArray, x25519PrivateKey: ByteArray): ByteArray { + val ephemeralSharedSecret = SessionEncrypt.calculateECHDAgreement(x25519PubKey = x25519PublicKey, x25519PrivKey = x25519PrivateKey) + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec("LOKI".toByteArray(), "HmacSHA256")) + return mac.doFinal(ephemeralSharedSecret) + } + + /** + * Sync. Don't call from the main thread. + */ + internal fun encrypt(plaintext: ByteArray, symmetricKey: ByteArray): ByteArray { + val iv = Util.getSecretBytes(ivSize) + 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)) + } + } + + /** + * Sync. Don't call from the main thread. + */ + internal fun encrypt(plaintext: ByteArray, hexEncodedX25519PublicKey: String): EncryptionResult { + val x25519PublicKey = Hex.fromStringCondensed(hexEncodedX25519PublicKey) + val ephemeralKeyPair = Curve25519.generateKeyPair() + val symmetricKey = generateSymmetricKey(x25519PublicKey, ephemeralKeyPair.secretKey.data) + val ciphertext = encrypt(plaintext, symmetricKey) + return EncryptionResult(ciphertext, symmetricKey, ephemeralKeyPair.pubKey.data) + } + +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/Address.kt b/app/src/main/java/org/session/libsession/utilities/Address.kt similarity index 86% rename from libsession/src/main/java/org/session/libsession/utilities/Address.kt rename to app/src/main/java/org/session/libsession/utilities/Address.kt index 6b1a47f7b9..6fc8c545d8 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/Address.kt +++ b/app/src/main/java/org/session/libsession/utilities/Address.kt @@ -14,7 +14,7 @@ import java.util.regex.Matcher import java.util.regex.Pattern class Address private constructor(address: String) : Parcelable, Comparable { - private val address: String = address.toLowerCase() + private val address: String = address.lowercase() constructor(`in`: Parcel) : this(`in`.readString()!!) {} @@ -48,34 +48,17 @@ class Address private constructor(address: String) : Parcelable, Comparable = object : Parcelable.Creator { - override fun createFromParcel(`in`: Parcel): Address { - return Address(`in`) - } - - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } + override fun createFromParcel(`in`: Parcel): Address = Address(`in`) + override fun newArray(size: Int): Array = arrayOfNulls(size) } val UNKNOWN = Address("Unknown") private val TAG = Address::class.java.simpleName private val cachedFormatter = AtomicReference>() @JvmStatic - fun fromSerialized(serialized: String): Address { - return Address(serialized) - } + fun fromSerialized(serialized: String): Address = Address(serialized) @JvmStatic - fun fromExternal(context: Context, external: String?): Address { - return fromSerialized(external!!) - } + fun fromExternal(context: Context, external: String?): Address = fromSerialized(external!!) @JvmStatic fun fromSerializedList(serialized: String, delimiter: Char): List
{ @@ -184,7 +158,7 @@ class Address private constructor(address: String) : Parcelable, Comparable = LinkedList() for (address in set) { - escapedAddresses.add(DelimiterUtil.escape(address.serialize(), delimiter)) + escapedAddresses.add(DelimiterUtil.escape(address.toString(), delimiter)) } return Util.join(escapedAddresses, delimiter.toString() + "") } diff --git a/libsession/src/main/java/org/session/libsession/utilities/CenterAlignedRelativeSizeSpan.java b/app/src/main/java/org/session/libsession/utilities/CenterAlignedRelativeSizeSpan.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/CenterAlignedRelativeSizeSpan.java rename to app/src/main/java/org/session/libsession/utilities/CenterAlignedRelativeSizeSpan.java diff --git a/libsession/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt b/app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt similarity index 89% rename from libsession/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt rename to app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt index f14e1695f9..66734f463a 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt +++ b/app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt @@ -13,6 +13,7 @@ import network.loki.messenger.libsession_util.MutableGroupKeysConfig import network.loki.messenger.libsession_util.MutableGroupMembersConfig import network.loki.messenger.libsession_util.MutableUserGroupsConfig import network.loki.messenger.libsession_util.MutableUserProfile +import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.ReadableConfig import network.loki.messenger.libsession_util.ReadableContacts import network.loki.messenger.libsession_util.ReadableConversationVolatileConfig @@ -26,7 +27,6 @@ import network.loki.messenger.libsession_util.util.GroupInfo import org.session.libsession.snode.SwarmAuth import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.Namespace interface ConfigFactoryProtocol { val configUpdateNotifications: Flow @@ -63,6 +63,7 @@ interface ConfigFactoryProtocol { fun getGroupAuth(groupId: AccountId): SwarmAuth? fun removeGroup(groupId: AccountId) + fun removeContact(accountId: String) fun decryptForUser(encoded: ByteArray, domain: String, @@ -100,7 +101,7 @@ class ConfigMessage( ) data class ConfigPushResult( - val hash: String, + val hashes: List, val timestamp: Long ) @@ -122,7 +123,13 @@ fun ConfigFactoryProtocol.getGroup(groupId: AccountId): GroupInfo.ClosedGroupInf * Shortcut to check if the current user was kicked from a given group V2 (as a Recipient) */ fun ConfigFactoryProtocol.wasKickedFromGroupV2(group: Recipient) = - group.isGroupV2Recipient && getGroup(AccountId(group.address.serialize()))?.kicked == true + group.isGroupV2Recipient && getGroup(AccountId(group.address.toString()))?.kicked == true + +/** + * Shortcut to check if the a given group is destroyed + */ +fun ConfigFactoryProtocol.isGroupDestroyed(group: Recipient) = + group.isGroupV2Recipient && getGroup(AccountId(group.address.toString()))?.destroyed == true /** * Wait until all user configs are pushed to the server. @@ -155,6 +162,7 @@ suspend fun ConfigFactoryProtocol.waitUntilUserConfigsPushed(timeoutMills: Long * * This function will check the group configs immediately, if nothing needs to be pushed, it will return immediately. * + * @param timeoutMills The maximum time to wait for the group configs to be pushed, in milliseconds. 0 means no timeout. * @return True if all group configs are pushed, false if the timeout is reached. */ suspend fun ConfigFactoryProtocol.waitUntilGroupConfigsPushed(groupId: AccountId, timeoutMills: Long = 10_000L): Boolean { @@ -162,12 +170,16 @@ suspend fun ConfigFactoryProtocol.waitUntilGroupConfigsPushed(groupId: AccountId configs.groupInfo.needsPush() || configs.groupMembers.needsPush() } - return withTimeoutOrNull(timeoutMills) { - configUpdateNotifications - .onStart { emit(ConfigUpdateNotification.GroupConfigsUpdated(groupId)) } // Trigger the filtering immediately - .filter { it == ConfigUpdateNotification.GroupConfigsUpdated(groupId) && !needsPush() } - .first() - } != null + val pushed = configUpdateNotifications + .onStart { emit(ConfigUpdateNotification.GroupConfigsUpdated(groupId, fromMerge = false)) } // Trigger the filtering immediately + .filter { it is ConfigUpdateNotification.GroupConfigsUpdated && it.groupId == groupId && !needsPush() } + + if (timeoutMills > 0) { + return withTimeoutOrNull(timeoutMills) { pushed.first() } != null + } else { + pushed.first() + return true + } } interface UserConfigs { @@ -228,5 +240,5 @@ sealed interface ConfigUpdateNotification { */ data class UserConfigsMerged(val configType: UserConfigType) : ConfigUpdateNotification - data class GroupConfigsUpdated(val groupId: AccountId) : ConfigUpdateNotification + data class GroupConfigsUpdated(val groupId: AccountId, val fromMerge: Boolean) : ConfigUpdateNotification } diff --git a/app/src/main/java/org/session/libsession/utilities/ConfigUtils.kt b/app/src/main/java/org/session/libsession/utilities/ConfigUtils.kt new file mode 100644 index 0000000000..fe99202e2b --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/ConfigUtils.kt @@ -0,0 +1,24 @@ +package org.session.libsession.utilities + +import network.loki.messenger.libsession_util.MutableContacts +import network.loki.messenger.libsession_util.util.Contact +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log + +/** + * This function will create the underlying contact if it doesn't exist before passing to [updateFunction] + */ +fun MutableContacts.upsertContact(accountId: String, updateFunction: Contact.() -> Unit = {}) { + when { + accountId.startsWith(IdPrefix.STANDARD.value) -> { + getOrConstruct(accountId).let { + updateFunction(it) + set(it) + } + } + accountId.startsWith(IdPrefix.BLINDED.value) -> Log.w("Loki", "Trying to create a contact with a blinded ID prefix") + accountId.startsWith(IdPrefix.UN_BLINDED.value) -> Log.w("Loki", "Trying to create a contact with an un-blinded ID prefix") + accountId.startsWith(IdPrefix.BLINDEDV2.value) -> Log.w("Loki", "Trying to create a contact with a blindedv2 ID prefix") + else -> Log.w("Loki", "Trying to create a contact with an unknown ID prefix") + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/Contact.java b/app/src/main/java/org/session/libsession/utilities/Contact.java similarity index 99% rename from libsession/src/main/java/org/session/libsession/utilities/Contact.java rename to app/src/main/java/org/session/libsession/utilities/Contact.java index a0d181ad62..4bd0c1e01a 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/Contact.java +++ b/app/src/main/java/org/session/libsession/utilities/Contact.java @@ -13,7 +13,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; 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.AttachmentState; import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId; import org.session.libsession.messaging.sending_receiving.attachments.UriAttachment; import org.session.libsignal.utilities.JsonUtil; @@ -642,7 +642,7 @@ public int describeContents() { private static Attachment attachmentFromUri(@Nullable Uri uri) { if (uri == null) return null; - return new UriAttachment(uri, MediaTypes.IMAGE_JPEG, AttachmentTransferProgress.TRANSFER_PROGRESS_DONE, 0, null, false, false, null); + return new UriAttachment(uri, MediaTypes.IMAGE_JPEG, AttachmentState.DONE.getValue(), 0, null, false, false, null); } @Override diff --git a/libsession/src/main/java/org/session/libsession/utilities/Conversions.java b/app/src/main/java/org/session/libsession/utilities/Conversions.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/Conversions.java rename to app/src/main/java/org/session/libsession/utilities/Conversions.java diff --git a/libsession/src/main/java/org/session/libsession/utilities/Debouncer.java b/app/src/main/java/org/session/libsession/utilities/Debouncer.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/Debouncer.java rename to app/src/main/java/org/session/libsession/utilities/Debouncer.java diff --git a/libsession/src/main/java/org/session/libsession/utilities/DecodedAudio.kt b/app/src/main/java/org/session/libsession/utilities/DecodedAudio.kt similarity index 97% rename from libsession/src/main/java/org/session/libsession/utilities/DecodedAudio.kt rename to app/src/main/java/org/session/libsession/utilities/DecodedAudio.kt index a342ee1ae5..6c293fb11b 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/DecodedAudio.kt +++ b/app/src/main/java/org/session/libsession/utilities/DecodedAudio.kt @@ -53,8 +53,8 @@ class DecodedAudio { val sampleRate: Int - /** In microseconds. */ - val totalDuration: Long + /** In microseconds. There are 1 million microseconds in a second. */ + val totalDurationMicroseconds: Long val channels: Int @@ -96,15 +96,15 @@ class DecodedAudio { channels = mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT) sampleRate = mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) // On some old APIs (23) this field might be missing. - totalDuration = if (mediaFormat.containsKey(MediaFormat.KEY_DURATION)) { + totalDurationMicroseconds = if (mediaFormat.containsKey(MediaFormat.KEY_DURATION)) { mediaFormat.getLong(MediaFormat.KEY_DURATION) } else { -1L } // Expected total number of samples per channel. - val expectedNumSamples = if (totalDuration >= 0) { - ((totalDuration / 1000000f) * sampleRate + 0.5f).toInt() + val expectedNumSamples = if (totalDurationMicroseconds >= 0) { + ((totalDurationMicroseconds / 1000000f) * sampleRate + 0.5f).toInt() } else { Int.MAX_VALUE } diff --git a/libsession/src/main/java/org/session/libsession/utilities/DelimiterUtil.kt b/app/src/main/java/org/session/libsession/utilities/DelimiterUtil.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/DelimiterUtil.kt rename to app/src/main/java/org/session/libsession/utilities/DelimiterUtil.kt diff --git a/libsession/src/main/java/org/session/libsession/utilities/Device.kt b/app/src/main/java/org/session/libsession/utilities/Device.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/Device.kt rename to app/src/main/java/org/session/libsession/utilities/Device.kt diff --git a/libsession/src/main/java/org/session/libsession/utilities/DistributionTypes.kt b/app/src/main/java/org/session/libsession/utilities/DistributionTypes.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/DistributionTypes.kt rename to app/src/main/java/org/session/libsession/utilities/DistributionTypes.kt diff --git a/libsession/src/main/java/org/session/libsession/utilities/Document.java b/app/src/main/java/org/session/libsession/utilities/Document.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/Document.java rename to app/src/main/java/org/session/libsession/utilities/Document.java diff --git a/app/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt b/app/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt new file mode 100644 index 0000000000..f47bd67baa --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt @@ -0,0 +1,36 @@ +package org.session.libsession.utilities + +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.session.libsession.messaging.file_server.FileServerApi +import org.session.libsession.snode.utilities.await +import org.session.libsignal.utilities.ByteArraySlice +import org.session.libsignal.utilities.HTTP +import org.session.libsignal.utilities.Log + +object DownloadUtilities { + /** + * Downloads a file from the file server using the provided URL. + * + * This will assume the URL is a valid file server URL, and if not, + */ + suspend fun downloadFromFileServer(urlAsString: String): FileServerApi.SendResponse { + try { + val url = urlAsString.toHttpUrl() + require(url.host == FileServerApi.fileServerUrl.host) { + "Invalid file server URL: $url" + } + val fileID = checkNotNull(url.pathSegments.lastOrNull()) { + "No file ID found in URL: $url" + } + + return FileServerApi.download(fileID).await() + } catch (e: Exception) { + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/utilities/Environment.kt b/app/src/main/java/org/session/libsession/utilities/Environment.kt new file mode 100644 index 0000000000..acd98d87e6 --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/Environment.kt @@ -0,0 +1,7 @@ +package org.session.libsession.utilities + +enum class Environment(val label: String) { + MAIN_NET("Mainnet"), + TEST_NET("Testnet"), + DEV_NET("Devnet"), +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/utilities/ExpirationUtil.kt b/app/src/main/java/org/session/libsession/utilities/ExpirationUtil.kt new file mode 100644 index 0000000000..d1d90560b9 --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/ExpirationUtil.kt @@ -0,0 +1,32 @@ +package org.session.libsession.utilities + +import android.content.Context +import org.session.libsession.LocalisedTimeUtil +import java.util.concurrent.TimeUnit +import kotlin.time.Duration + + +object ExpirationUtil { + + @JvmStatic + fun getExpirationDisplayValue(context: Context, duration: Duration): String = + LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit(context, duration) + + fun getExpirationAbbreviatedDisplayValue(expirationTimeSecs: Long): String { + return if (expirationTimeSecs < TimeUnit.MINUTES.toSeconds(1)) { + expirationTimeSecs.toString() + "s" + } else if (expirationTimeSecs < TimeUnit.HOURS.toSeconds(1)) { + val minutes = expirationTimeSecs / TimeUnit.MINUTES.toSeconds(1) + minutes.toString() + "m" + } else if (expirationTimeSecs < TimeUnit.DAYS.toSeconds(1)) { + val hours = expirationTimeSecs / TimeUnit.HOURS.toSeconds(1) + hours.toString() + "h" + } else if (expirationTimeSecs < TimeUnit.DAYS.toSeconds(7)) { + val days = expirationTimeSecs / TimeUnit.DAYS.toSeconds(1) + days.toString() + "d" + } else { + val weeks = expirationTimeSecs / TimeUnit.DAYS.toSeconds(7) + weeks.toString() + "w" + } + } +} diff --git a/app/src/main/java/org/session/libsession/utilities/FileUtils.kt b/app/src/main/java/org/session/libsession/utilities/FileUtils.kt new file mode 100644 index 0000000000..b830403cd6 --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/FileUtils.kt @@ -0,0 +1,53 @@ +package org.session.libsession.utilities + +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.lang.AssertionError +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException + +object FileUtils { + + @JvmStatic + @Throws(IOException::class) + fun getFileDigest(fin: FileInputStream): ByteArray? { + try { + val digest = MessageDigest.getInstance("SHA256") + + val buffer = ByteArray(4096) + var read = 0 + + while ((fin.read(buffer, 0, buffer.size).also { read = it }) != -1) { + digest.update(buffer, 0, read) + } + + return digest.digest() + } catch (e: NoSuchAlgorithmException) { + throw AssertionError(e) + } + } + + @Throws(IOException::class) + fun deleteDirectoryContents(directory: File?) { + if (directory == null || !directory.exists() || !directory.isDirectory()) return + + val files = directory.listFiles() + + if (files != null) { + for (file in files) { + if (file.isDirectory()) deleteDirectory(file) + else file.delete() + } + } + } + + @Throws(IOException::class) + fun deleteDirectory(directory: File?) { + if (directory == null || !directory.exists() || !directory.isDirectory()) { + return + } + deleteDirectoryContents(directory) + directory.delete() + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/FutureTaskListener.java b/app/src/main/java/org/session/libsession/utilities/FutureTaskListener.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/FutureTaskListener.java rename to app/src/main/java/org/session/libsession/utilities/FutureTaskListener.java diff --git a/app/src/main/java/org/session/libsession/utilities/GroupDisplayInfo.kt b/app/src/main/java/org/session/libsession/utilities/GroupDisplayInfo.kt new file mode 100644 index 0000000000..285c04e8c9 --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/GroupDisplayInfo.kt @@ -0,0 +1,15 @@ +package org.session.libsession.utilities + +import network.loki.messenger.libsession_util.util.UserPic +import org.session.libsignal.utilities.AccountId + +data class GroupDisplayInfo( + val id: AccountId, + val created: Long?, + val expiryTimer: Long?, + val name: String?, + val description: String?, + val destroyed: Boolean, + val profilePic: UserPic, + val isUserAdmin: Boolean +) \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/GroupRecord.kt b/app/src/main/java/org/session/libsession/utilities/GroupRecord.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/GroupRecord.kt rename to app/src/main/java/org/session/libsession/utilities/GroupRecord.kt diff --git a/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt b/app/src/main/java/org/session/libsession/utilities/GroupUtil.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt rename to app/src/main/java/org/session/libsession/utilities/GroupUtil.kt diff --git a/libsession/src/main/java/org/session/libsession/utilities/IdUtil.kt b/app/src/main/java/org/session/libsession/utilities/IdUtil.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/IdUtil.kt rename to app/src/main/java/org/session/libsession/utilities/IdUtil.kt diff --git a/libsession/src/main/java/org/session/libsession/utilities/IdentityKeyMismatch.java b/app/src/main/java/org/session/libsession/utilities/IdentityKeyMismatch.java similarity index 98% rename from libsession/src/main/java/org/session/libsession/utilities/IdentityKeyMismatch.java rename to app/src/main/java/org/session/libsession/utilities/IdentityKeyMismatch.java index f0308daa7d..e317224e59 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/IdentityKeyMismatch.java +++ b/app/src/main/java/org/session/libsession/utilities/IdentityKeyMismatch.java @@ -35,7 +35,7 @@ public class IdentityKeyMismatch { public IdentityKeyMismatch() {} public IdentityKeyMismatch(Address address, IdentityKey identityKey) { - this.address = address.serialize(); + this.address = address.toString(); this.identityKey = identityKey; } diff --git a/libsession/src/main/java/org/session/libsession/utilities/IdentityKeyMismatchList.java b/app/src/main/java/org/session/libsession/utilities/IdentityKeyMismatchList.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/IdentityKeyMismatchList.java rename to app/src/main/java/org/session/libsession/utilities/IdentityKeyMismatchList.java diff --git a/libsession/src/main/java/org/session/libsession/utilities/LinkedBlockingLifoQueue.java b/app/src/main/java/org/session/libsession/utilities/LinkedBlockingLifoQueue.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/LinkedBlockingLifoQueue.java rename to app/src/main/java/org/session/libsession/utilities/LinkedBlockingLifoQueue.java diff --git a/libsession/src/main/java/org/session/libsession/utilities/ListenableFutureTask.java b/app/src/main/java/org/session/libsession/utilities/ListenableFutureTask.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/ListenableFutureTask.java rename to app/src/main/java/org/session/libsession/utilities/ListenableFutureTask.java diff --git a/libsession/src/main/java/org/session/libsession/utilities/LocalisedTimeUtil.kt b/app/src/main/java/org/session/libsession/utilities/LocalisedTimeUtil.kt similarity index 90% rename from libsession/src/main/java/org/session/libsession/utilities/LocalisedTimeUtil.kt rename to app/src/main/java/org/session/libsession/utilities/LocalisedTimeUtil.kt index adb082f8ee..433b8baff6 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/LocalisedTimeUtil.kt +++ b/app/src/main/java/org/session/libsession/utilities/LocalisedTimeUtil.kt @@ -52,6 +52,20 @@ object LocalisedTimeUtil { "0m ${this.inWholeSeconds}s" } + // Method to return a non-localised single-part shorted string of a duration. + // For example "6s" / "6m" / "6h" / "6d" / "6w" for 6 seconds/minutes/hours/days/weeks. + fun Duration.toShortSinglePartString(): String = + if (this.inWholeWeeks > 0) { + "${this.inWholeWeeks}w" + } else if (this.inWholeDays > 0) { + "${this.inWholeDays}d" + } else if (this.inWholeHours > 0) { + "${this.inWholeHours}h" + } else { + // We treat anything less than one hour as minutes, so we'll get things like "33m" and sub-one-minute durations will be "0m" + "${this.inWholeMinutes}m" + } + // Method to get a locale-aware duration string using the largest time unit in a given duration. // For example a duration of 3 hours and 7 minutes will return "3 hours" in English, or // "3 horas" in Spanish. diff --git a/libsession/src/main/java/org/session/libsession/utilities/MaterialColor.java b/app/src/main/java/org/session/libsession/utilities/MaterialColor.java similarity index 88% rename from libsession/src/main/java/org/session/libsession/utilities/MaterialColor.java rename to app/src/main/java/org/session/libsession/utilities/MaterialColor.java index 03b54745ad..52556077d6 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/MaterialColor.java +++ b/app/src/main/java/org/session/libsession/utilities/MaterialColor.java @@ -7,7 +7,7 @@ import androidx.annotation.ColorRes; import androidx.annotation.NonNull; -import org.session.libsession.R; +import network.loki.messenger.R; import java.util.HashMap; import java.util.Map; @@ -26,7 +26,7 @@ public enum MaterialColor { VIOLET (R.color.conversation_violet, R.color.conversation_violet_tint, R.color.conversation_violet_shade, "purple"), PLUM (R.color.conversation_plumb, R.color.conversation_plumb_tint, R.color.conversation_plumb_shade, "pink"), TAUPE (R.color.conversation_taupe, R.color.conversation_taupe_tint, R.color.conversation_taupe_shade, "blue_grey"), - STEEL (R.color.conversation_steel, R.color.conversation_steel_tint, R.color.conversation_steel_shade, "grey"), + STEEL (R.color.classic_dark_3, R.color.classic_dark_3, R.color.classic_dark_3, "grey"), GROUP (R.color.conversation_group, R.color.conversation_group_tint, R.color.conversation_group_shade, "blue"); private static final Map COLOR_MATCHES = new HashMap() {{ @@ -98,16 +98,6 @@ public enum MaterialColor { : R.color.transparent_white_aa); } - public @ColorInt int toQuoteFooterColor(@NonNull Context context, boolean outgoing) { - if (outgoing) { - int color = toConversationColor(context); - int alpha = isDarkTheme(context) ? (int) (0.4 * 255) : (int) (0.6 * 255); - return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)); - } - return context.getResources().getColor(isDarkTheme(context) ? R.color.transparent_black_90 - : R.color.transparent_white_bb); - } - public boolean represents(Context context, int colorValue) { return context.getResources().getColor(mainColor) == colorValue || context.getResources().getColor(tintColor) == colorValue diff --git a/libsession/src/main/java/org/session/libsession/utilities/MediaTypes.kt b/app/src/main/java/org/session/libsession/utilities/MediaTypes.kt similarity index 90% rename from libsession/src/main/java/org/session/libsession/utilities/MediaTypes.kt rename to app/src/main/java/org/session/libsession/utilities/MediaTypes.kt index 4ef7c10c11..decd1d5a10 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/MediaTypes.kt +++ b/app/src/main/java/org/session/libsession/utilities/MediaTypes.kt @@ -5,7 +5,7 @@ object MediaTypes { const val IMAGE_JPEG = "image/jpeg" const val IMAGE_WEBP = "image/webp" const val IMAGE_GIF = "image/gif" - const val AUDIO_AAC = "audio/aac" + const val AUDIO_MP4 = "audio/mp4" const val AUDIO_UNSPECIFIED = "audio/*" const val VIDEO_UNSPECIFIED = "video/*" const val VCARD = "text/x-vcard" diff --git a/libsession/src/main/java/org/session/libsession/utilities/NetworkFailure.java b/app/src/main/java/org/session/libsession/utilities/NetworkFailure.java similarity index 95% rename from libsession/src/main/java/org/session/libsession/utilities/NetworkFailure.java rename to app/src/main/java/org/session/libsession/utilities/NetworkFailure.java index 5d930ef1a9..f999e92d36 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/NetworkFailure.java +++ b/app/src/main/java/org/session/libsession/utilities/NetworkFailure.java @@ -11,7 +11,7 @@ public class NetworkFailure { private String address; public NetworkFailure(Address address) { - this.address = address.serialize(); + this.address = address.toString(); } public NetworkFailure() {} diff --git a/libsession/src/main/java/org/session/libsession/utilities/NetworkFailureList.java b/app/src/main/java/org/session/libsession/utilities/NetworkFailureList.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/NetworkFailureList.java rename to app/src/main/java/org/session/libsession/utilities/NetworkFailureList.java diff --git a/app/src/main/java/org/session/libsession/utilities/NonTranslatableStringConstants.kt b/app/src/main/java/org/session/libsession/utilities/NonTranslatableStringConstants.kt new file mode 100644 index 0000000000..2266df0467 --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/NonTranslatableStringConstants.kt @@ -0,0 +1,19 @@ +package org.session.libsession.utilities + +// Non-translatable strings for use with the UI +object NonTranslatableStringConstants { + const val APP_NAME = "Session" + const val SESSION_DOWNLOAD_URL = "https://getsession.org/download" + const val GIF = "GIF" + const val OXEN_FOUNDATION = "Oxen Foundation" + const val NETWORK_NAME = "Session Network" + const val TOKEN_NAME_LONG = "Session Token" + const val STAKING_REWARD_POOL = "Staking Reward Pool" + const val TOKEN_NAME_SHORT = "SESH" + const val USD_NAME_SHORT = "USD" + const val SESSION_NETWORK_DATA_PRICE = "Price data powered by CoinGecko\nAccurate at {date_time}" + const val APP_PRO = "Session Pro" + const val SESSION_FOUNDATION = "Session Foundation" + const val PRO = "Pro" +} + diff --git a/libsession/src/main/java/org/session/libsession/utilities/NotificationPrivacyPreference.java b/app/src/main/java/org/session/libsession/utilities/NotificationPrivacyPreference.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/NotificationPrivacyPreference.java rename to app/src/main/java/org/session/libsession/utilities/NotificationPrivacyPreference.java diff --git a/libsession/src/main/java/org/session/libsession/utilities/OpenGroupUrlParser.kt b/app/src/main/java/org/session/libsession/utilities/OpenGroupUrlParser.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/OpenGroupUrlParser.kt rename to app/src/main/java/org/session/libsession/utilities/OpenGroupUrlParser.kt diff --git a/libsession/src/main/java/org/session/libsession/utilities/ParcelableUtil.kt b/app/src/main/java/org/session/libsession/utilities/ParcelableUtil.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/ParcelableUtil.kt rename to app/src/main/java/org/session/libsession/utilities/ParcelableUtil.kt diff --git a/libsession/src/main/java/org/session/libsession/utilities/ProfileKeyUtil.java b/app/src/main/java/org/session/libsession/utilities/ProfileKeyUtil.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/ProfileKeyUtil.java rename to app/src/main/java/org/session/libsession/utilities/ProfileKeyUtil.java diff --git a/libsession/src/main/java/org/session/libsession/utilities/ProfilePictureModifiedEvent.kt b/app/src/main/java/org/session/libsession/utilities/ProfilePictureModifiedEvent.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/ProfilePictureModifiedEvent.kt rename to app/src/main/java/org/session/libsession/utilities/ProfilePictureModifiedEvent.kt diff --git a/app/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt b/app/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt new file mode 100644 index 0000000000..958f20b3e3 --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt @@ -0,0 +1,147 @@ +package org.session.libsession.utilities + +import android.content.Context +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import network.loki.messenger.libsession_util.util.Bytes +import network.loki.messenger.libsession_util.util.UserPic +import okio.Buffer +import org.session.libsession.avatars.AvatarHelper +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.file_server.FileServerApi +import org.session.libsession.snode.utilities.await +import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber +import org.session.libsession.utilities.TextSecurePreferences.Companion.getProfileKey +import org.session.libsession.utilities.TextSecurePreferences.Companion.setLastProfilePictureUpload +import org.session.libsignal.streams.DigestingRequestBody +import org.session.libsignal.streams.ProfileCipherOutputStream +import org.session.libsignal.streams.ProfileCipherOutputStreamFactory +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.ProfileAvatarData +import org.session.libsignal.utilities.retryIfNeeded +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.Date + +object ProfilePictureUtilities { + + const val DEFAULT_AVATAR_TTL: Long = 14 * 24 * 60 * 60 * 1000 // 14 days + const val DEBUG_AVATAR_TTL: Long = 30 // 30 seconds + + @OptIn(DelicateCoroutinesApi::class) + fun resubmitProfilePictureIfNeeded(context: Context) { + GlobalScope.launch(Dispatchers.IO) { + // Files expire on the file server after a while, so we simply re-upload the user's profile picture + // at a certain interval to ensure it's always available. + val userPublicKey = getLocalNumber(context) ?: return@launch + // no need to go any further if we do not have an avatar + if(TextSecurePreferences.getProfileAvatarId(context) == 0) return@launch + + val now = Date().time + + val avatarTtl = TextSecurePreferences.getProfileExpiry(context) + // we can stop here if we have no info on expiry yet + // We will get that info on upload or download, and the check can happen when we next reopen the app + if(avatarTtl == 0L) return@launch + + Log.d("Loki-Avatar", "Should reupload avatar? ${now < avatarTtl} (TTL of $avatarTtl)") + if (now < avatarTtl) return@launch + + // 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") + val encodedProfileKey = getProfileKey(context) + try { + // Read the file into a byte array + val inputStream = AvatarHelper.getInputStreamFor( + context, + fromSerialized(userPublicKey) + ) + val baos = ByteArrayOutputStream() + var count: Int + val buffer = ByteArray(1024) + while ((inputStream.read(buffer, 0, buffer.size) + .also { count = it }) != -1 + ) { + baos.write(buffer, 0, count) + } + baos.flush() + val profilePicture = baos.toByteArray() + // Re-upload it + val url = upload( + profilePicture, + encodedProfileKey!!, + context + ) + + // Update the last profile picture upload date + setLastProfilePictureUpload( + context, + Date().time + ) + + // update config with new URL for reuploaded file + val profileKey = ProfileKeyUtil.getProfileKey(context) + MessagingModuleConfiguration.shared.configFactory.withMutableUserConfigs { + it.userProfile.setPic(UserPic(url, Bytes(profileKey))) + } + + Log.d("Loki-Avatar", "Uploading Avatar Finished") + } catch (e: Exception) { + Log.e("Loki-Avatar", "Uploading avatar failed.") + } + } + } + + suspend fun upload(profilePicture: ByteArray, encodedProfileKey: String, context: Context): String { + val inputStream = ByteArrayInputStream(profilePicture) + val outputStream = + ProfileCipherOutputStream.getCiphertextLength(profilePicture.size.toLong()) + val profileKey = ProfileKeyUtil.getProfileKeyFromEncodedString(encodedProfileKey) + val pad = ProfileAvatarData( + inputStream, + outputStream, + "image/jpeg", + ProfileCipherOutputStreamFactory(profileKey) + ) + val drb = DigestingRequestBody( + pad.data, + pad.outputStreamFactory, + pad.contentType, + pad.dataLength, + ) + val b = Buffer() + drb.writeTo(b) + val data = b.readByteArray() + + // add a custom TTL header if we have enabled it i the debug menu + val customHeaders = if(TextSecurePreferences.forcedShortTTL(context)){ + mapOf("X-FS-TTL" to DEBUG_AVATAR_TTL.toString()) // force the TTL to 30 seconds + } else mapOf() + + // this can throw an error + val result = retryIfNeeded(4) { + FileServerApi.upload(file = data, customHeaders = customHeaders) + }.await() + + TextSecurePreferences.setLastProfilePictureUpload(context, Date().time) + + // save the expiry for this profile picture, so that whe we periodically check if we should + // reupload, we can check against this timestamp + updateAvatarExpiryTimestamp(context, result.ttlTimestamp) + + val url = "${FileServerApi.FILE_SERVER_URL}/file/${result.id}" + TextSecurePreferences.setProfilePictureURL(context, url) + + return url + } + + fun updateAvatarExpiryTimestamp(context: Context, expiry: Long?){ + TextSecurePreferences.setProfileExpiry( + context, + expiry ?: DEFAULT_AVATAR_TTL + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt b/app/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt new file mode 100644 index 0000000000..7407de3446 --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt @@ -0,0 +1,62 @@ +package org.session.libsession.utilities + +import android.content.Context +import dagger.Lazy +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.utilities.recipients.Recipient +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SSKEnvironment @Inject constructor( + val typingIndicators: TypingIndicatorsProtocol, + val profileManager: ProfileManagerProtocol, + val messageExpirationManager: MessageExpirationManagerProtocol +) { + + interface TypingIndicatorsProtocol { + fun didReceiveTypingStartedMessage(threadId: Long, author: Address, device: Int) + fun didReceiveTypingStoppedMessage( + threadId: Long, + author: Address, + device: Int, + isReplacedByIncomingMessage: Boolean + ) + fun didReceiveIncomingMessage(threadId: Long, author: Address, device: Int) + } + + interface ReadReceiptManagerProtocol { + fun processReadReceipts( + fromRecipientId: String, + sentTimestamps: List, + readTimestamp: Long + ) + } + + interface ProfileManagerProtocol { + companion object { + const val NAME_PADDED_LENGTH = 100 + } + + fun setNickname(context: Context, recipient: Recipient, nickname: String?) + fun setName(context: Context, recipient: Recipient, name: String?) + fun setProfilePicture(context: Context, recipient: Recipient, profilePictureURL: String?, profileKey: ByteArray?) + fun contactUpdatedInternal(contact: Contact): String? + } + + interface MessageExpirationManagerProtocol { + fun insertExpirationTimerMessage(message: ExpirationTimerUpdate) + + fun onMessageSent(message: Message) + fun onMessageReceived(message: Message) + } + + companion object { + lateinit var sharedLazy: Lazy + + @Deprecated("Use Hilt to inject your dependencies instead") + val shared: SSKEnvironment get() = sharedLazy.get() + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/Selectable.java b/app/src/main/java/org/session/libsession/utilities/Selectable.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/Selectable.java rename to app/src/main/java/org/session/libsession/utilities/Selectable.java diff --git a/libsession/src/main/java/org/session/libsession/utilities/ServiceUtil.java b/app/src/main/java/org/session/libsession/utilities/ServiceUtil.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/ServiceUtil.java rename to app/src/main/java/org/session/libsession/utilities/ServiceUtil.java diff --git a/libsession/src/main/java/org/session/libsession/utilities/SoftHashMap.java b/app/src/main/java/org/session/libsession/utilities/SoftHashMap.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/SoftHashMap.java rename to app/src/main/java/org/session/libsession/utilities/SoftHashMap.java diff --git a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt new file mode 100644 index 0000000000..60bfe8cd30 --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt @@ -0,0 +1,51 @@ +package org.session.libsession.utilities + + +typealias StringSubKey = String + +// String substitution keys for use with the Phrase library. +// Note: The substitution will be to {app_name} etc. in the strings - but do NOT include the curly braces in these keys! +object StringSubstitutionConstants { + const val ACCOUNT_ID_KEY: StringSubKey = "account_id" + const val APP_NAME_KEY: StringSubKey = "app_name" + const val AUTHOR_KEY: StringSubKey = "author" + const val COMMUNITY_NAME_KEY: StringSubKey = "community_name" + const val CONVERSATION_COUNT_KEY: StringSubKey = "conversation_count" + const val CONVERSATION_NAME_KEY: StringSubKey = "conversation_name" + const val COUNT_KEY: StringSubKey = "count" + const val DATE_KEY: StringSubKey = "date" + const val DATE_TIME_KEY: StringSubKey = "date_time" + const val DISAPPEARING_MESSAGES_TYPE_KEY: StringSubKey = "disappearing_messages_type" + const val DOWNLOAD_URL_KEY: StringSubKey = "session_download_url" // Used to invite people to download Session + const val EMOJI_KEY: StringSubKey = "emoji" + const val ETHEREUM_KEY: StringSubKey = "ethereum" + const val FILE_TYPE_KEY: StringSubKey = "file_type" + const val GROUP_NAME_KEY: StringSubKey = "group_name" + const val ICON_KEY: StringSubKey = "icon" + const val MEMBERS_KEY: StringSubKey = "members" + const val MESSAGE_COUNT_KEY: StringSubKey = "message_count" + const val MESSAGE_SNIPPET_KEY: StringSubKey = "message_snippet" + const val NAME_KEY: StringSubKey = "name" + const val NETWORK_NAME_KEY: StringSubKey = "network_name" + const val OTHER_NAME_KEY: StringSubKey = "other_name" + const val PRICE_DATA_POWERED_BY_KEY: StringSubKey = "price_data_powered_by" + const val QUERY_KEY: StringSubKey = "query" + const val RELATIVE_TIME_KEY: StringSubKey = "relative_time" + const val SECONDS_KEY: StringSubKey = "seconds" + const val SESSION_DOWNLOAD_URL_KEY: StringSubKey = "session_download_url" + const val STAKING_REWARD_POOL_KEY: StringSubKey = "staking_reward_pool" + const val TIME_KEY: StringSubKey = "time" + const val TIME_LARGE_KEY: StringSubKey = "time_large" + const val TIME_SMALL_KEY: StringSubKey = "time_small" + const val TOKEN_BONUS_TITLE_KEY: StringSubKey = "token_bonus_title" + const val TOKEN_NAME_LONG_KEY: StringSubKey = "token_name_long" + const val TOKEN_NAME_LONG_PLURAL_KEY: StringSubKey = "token_name_long_plural" + const val TOKEN_NAME_SHORT_KEY: StringSubKey = "token_name_short" + const val TOTAL_COUNT_KEY: StringSubKey = "total_count" + const val URL_KEY: StringSubKey = "url" + const val VALUE_KEY: StringSubKey = "value" + const val VERSION_KEY: StringSubKey = "version" + const val LIMIT_KEY: StringSubKey = "limit" + const val STORE_VARIANT_KEY: StringSubKey = "storevariant" + const val APP_PRO_KEY: StringSubKey = "app_pro" +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/Stub.java b/app/src/main/java/org/session/libsession/utilities/Stub.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/Stub.java rename to app/src/main/java/org/session/libsession/utilities/Stub.java diff --git a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt similarity index 86% rename from libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt rename to app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index 2b992f84c0..d6deb4323a 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -6,23 +6,35 @@ import android.net.Uri import android.provider.Settings import androidx.annotation.ArrayRes import androidx.annotation.StyleRes +import androidx.camera.core.CameraSelector import androidx.core.app.NotificationCompat +import androidx.core.content.edit import androidx.preference.PreferenceManager.getDefaultSharedPreferences import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow -import org.session.libsession.R +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.update +import network.loki.messenger.BuildConfig +import network.loki.messenger.R +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.utilities.TextSecurePreferences.Companion.AUTOPLAY_AUDIO_MESSAGES import org.session.libsession.utilities.TextSecurePreferences.Companion.CALL_NOTIFICATIONS_ENABLED import org.session.libsession.utilities.TextSecurePreferences.Companion.CLASSIC_DARK import org.session.libsession.utilities.TextSecurePreferences.Companion.CLASSIC_LIGHT import org.session.libsession.utilities.TextSecurePreferences.Companion.ENVIRONMENT import org.session.libsession.utilities.TextSecurePreferences.Companion.FOLLOW_SYSTEM_SETTINGS +import org.session.libsession.utilities.TextSecurePreferences.Companion.FORCED_SHORT_TTL import org.session.libsession.utilities.TextSecurePreferences.Companion.HAS_HIDDEN_MESSAGE_REQUESTS import org.session.libsession.utilities.TextSecurePreferences.Companion.HAS_HIDDEN_NOTE_TO_SELF +import org.session.libsession.utilities.TextSecurePreferences.Companion.HAVE_SHOWN_A_NOTIFICATION_ABOUT_TOKEN_PAGE import org.session.libsession.utilities.TextSecurePreferences.Companion.HIDE_PASSWORD import org.session.libsession.utilities.TextSecurePreferences.Companion.LAST_VACUUM_TIME import org.session.libsession.utilities.TextSecurePreferences.Companion.LAST_VERSION_CHECK @@ -31,10 +43,15 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.OCEAN_DA import org.session.libsession.utilities.TextSecurePreferences.Companion.OCEAN_LIGHT import org.session.libsession.utilities.TextSecurePreferences.Companion.SELECTED_ACCENT_COLOR import org.session.libsession.utilities.TextSecurePreferences.Companion.SELECTED_STYLE +import org.session.libsession.utilities.TextSecurePreferences.Companion.SET_FORCE_CURRENT_USER_PRO +import org.session.libsession.utilities.TextSecurePreferences.Companion.SET_FORCE_INCOMING_MESSAGE_PRO +import org.session.libsession.utilities.TextSecurePreferences.Companion.SET_FORCE_OTHER_USERS_PRO +import org.session.libsession.utilities.TextSecurePreferences.Companion.SET_FORCE_POST_PRO import org.session.libsession.utilities.TextSecurePreferences.Companion.SHOWN_CALL_NOTIFICATION import org.session.libsession.utilities.TextSecurePreferences.Companion.SHOWN_CALL_WARNING import org.session.libsession.utilities.TextSecurePreferences.Companion._events import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.pro.ProStatusManager import java.io.IOException import java.time.ZonedDateTime import java.util.Arrays @@ -94,8 +111,8 @@ interface TextSecurePreferences { fun getProfilePictureURL(): String? fun getNotificationPriority(): Int fun getMessageBodyTextSize(): Int - fun setDirectCaptureCameraId(value: Int) - fun getDirectCaptureCameraId(): Int + fun setPreferredCameraDirection(value: CameraSelector) + fun getPreferredCameraDirection(): CameraSelector fun getNotificationPrivacy(): NotificationPrivacyPreference fun getRepeatAlertsCount(): Int fun getLocalRegistrationId(): Int @@ -130,11 +147,6 @@ interface TextSecurePreferences { fun isNotificationVibrateEnabled(): Boolean fun getNotificationLedColor(): Int fun isThreadLengthTrimmingEnabled(): Boolean - fun isSystemEmojiPreferred(): Boolean - fun getMobileMediaDownloadAllowed(): Set? - fun getWifiMediaDownloadAllowed(): Set? - fun getRoamingMediaDownloadAllowed(): Set? - fun getMediaDownloadAllowed(key: String, @ArrayRes defaultValuesRes: Int): Set? fun getLogEncryptedSecret(): String? fun setLogEncryptedSecret(base64Secret: String?) fun getLogUnencryptedSecret(): String? @@ -154,6 +166,7 @@ interface TextSecurePreferences { fun setLongPreference(key: String, value: Long) fun removePreference(key: String) fun getStringSetPreference(key: String, defaultValues: Set): Set? + fun setStringSetPreference(key: String, value: Set) fun getHasViewedSeed(): Boolean fun setHasViewedSeed(hasViewedSeed: Boolean) fun setRestorationTime(time: Long) @@ -170,6 +183,16 @@ interface TextSecurePreferences { fun setHasSeenLinkPreviewSuggestionDialog() fun hasHiddenMessageRequests(): Boolean fun setHasHiddenMessageRequests(hidden: Boolean) + fun forceCurrentUserAsPro(): Boolean + fun watchProStatus(): StateFlow + fun setForceCurrentUserAsPro(isPro: Boolean) + fun forceOtherUsersAsPro(): Boolean + fun setForceOtherUsersAsPro(isPro: Boolean) + fun forceIncomingMessagesAsPro(): Boolean + fun setForceIncomingMessagesAsPro(isPro: Boolean) + fun forcePostPro(): Boolean + fun setForcePostPro(postPro: Boolean) + fun watchPostProStatus(): StateFlow fun hasHiddenNoteToSelf(): Boolean fun setHasHiddenNoteToSelf(hidden: Boolean) fun setShownCallWarning(): Boolean @@ -192,16 +215,31 @@ interface TextSecurePreferences { fun clearAll() fun getHidePassword(): Boolean fun setHidePassword(value: Boolean) + fun watchHidePassword(): StateFlow fun getLastVersionCheck(): Long fun setLastVersionCheck() fun getEnvironment(): Environment fun setEnvironment(value: Environment) + fun hasSeenTokenPageNotification(): Boolean + fun setHasSeenTokenPageNotification(value: Boolean) + fun forcedShortTTL(): Boolean + fun setForcedShortTTL(value: Boolean) + + fun getDebugMessageFeatures(): Set + fun setDebugMessageFeatures(features: Set) var deprecationStateOverride: String? var deprecatedTimeOverride: ZonedDateTime? var deprecatingStartTimeOverride: ZonedDateTime? - var migratedToGroupV2Config: Boolean + var migratedToDisablingKDF: Boolean + var migratedToMultiPartConfig: Boolean + + var migratedDisappearingMessagesToMessageContent: Boolean + + var selectedActivityAliasName: String? + + var inAppReviewState: String? companion object { val TAG = TextSecurePreferences::class.simpleName @@ -214,7 +252,8 @@ interface TextSecurePreferences { // This is a stop-gap solution for static access to shared preference. - internal lateinit var preferenceInstance: TextSecurePreferences + val preferenceInstance: TextSecurePreferences + get() = MessagingModuleConfiguration.shared.preferences const val DISABLE_PASSPHRASE_PREF = "pref_disable_passphrase" const val LANGUAGE_PREF = "pref_language" @@ -246,7 +285,6 @@ interface TextSecurePreferences { const val MEDIA_DOWNLOAD_MOBILE_PREF = "pref_media_download_mobile" const val MEDIA_DOWNLOAD_WIFI_PREF = "pref_media_download_wifi" const val MEDIA_DOWNLOAD_ROAMING_PREF = "pref_media_download_roaming" - const val SYSTEM_EMOJI_PREF = "pref_system_emoji" const val DIRECT_CAPTURE_CAMERA_ID = "pref_direct_capture_camera_id" const val PROFILE_KEY_PREF = "pref_profile_key" const val PROFILE_NAME_PREF = "pref_profile_name" @@ -279,9 +317,14 @@ interface TextSecurePreferences { val IS_PUSH_ENABLED get() = "pref_is_using_fcm$pushSuffix" const val CONFIGURATION_SYNCED = "pref_configuration_synced" const val LAST_PROFILE_UPDATE_TIME = "pref_last_profile_update_time" + const val PROFILE_PIC_EXPIRY = "profile_pic_expiry" const val LAST_OPEN_DATE = "pref_last_open_date" const val HAS_HIDDEN_MESSAGE_REQUESTS = "pref_message_requests_hidden" const val HAS_HIDDEN_NOTE_TO_SELF = "pref_note_to_self_hidden" + const val SET_FORCE_CURRENT_USER_PRO = "pref_force_current_user_pro" + const val SET_FORCE_OTHER_USERS_PRO = "pref_force_other_users_pro" + const val SET_FORCE_INCOMING_MESSAGE_PRO = "pref_force_incoming_message_pro" + const val SET_FORCE_POST_PRO = "pref_force_post_pro" const val CALL_NOTIFICATIONS_ENABLED = "pref_call_notifications_enabled" const val SHOWN_CALL_WARNING = "pref_shown_call_warning" // call warning is user-facing warning of enabling calls const val SHOWN_CALL_NOTIFICATION = "pref_shown_call_notification" // call notification is a prompt to check privacy settings @@ -292,6 +335,9 @@ interface TextSecurePreferences { const val LAST_VERSION_CHECK = "pref_last_version_check" const val ENVIRONMENT = "debug_environment" const val MIGRATED_TO_GROUP_V2_CONFIG = "migrated_to_group_v2_config" + const val MIGRATED_TO_DISABLING_KDF = "migrated_to_disabling_kdf" + const val MIGRATED_TO_MULTIPART_CONFIG = "migrated_to_multi_part_config" + const val FORCED_COMMUNITY_DESCRIPTION_POLL = "forced_community_description_poll" const val HAS_RECEIVED_LEGACY_CONFIG = "has_received_legacy_config" const val HAS_FORCED_NEW_CONFIG = "has_forced_new_config" @@ -325,6 +371,23 @@ interface TextSecurePreferences { // for the lifetime of the Session installation. const val HAVE_WARNED_USER_ABOUT_SAVING_ATTACHMENTS = "libsession.HAVE_WARNED_USER_ABOUT_SAVING_ATTACHMENTS" + // As we will have an incoming push notification to inform the user about the new token page, but we + // will also schedule instigating a local notification, we need to keep track of whether ANY notification + // has been shown to the user, and if so we don't show another. + const val HAVE_SHOWN_A_NOTIFICATION_ABOUT_TOKEN_PAGE = "pref_shown_a_notification_about_token_page" + + // Key name for the user's preferred date format string + const val DATE_FORMAT_PREF = "libsession.DATE_FORMAT_PREF" + + // Key name for the user's preferred time format string + const val TIME_FORMAT_PREF = "libsession.TIME_FORMAT_PREF" + + const val FORCED_SHORT_TTL = "forced_short_ttl" + + const val IN_APP_REVIEW_STATE = "in_app_review_state" + + const val DEBUG_MESSAGE_FEATURES = "debug_message_features" + @JvmStatic fun getConfigurationMessageSynced(context: Context): Boolean { return getBooleanPreference(context, CONFIGURATION_SYNCED, false) @@ -553,26 +616,6 @@ interface TextSecurePreferences { return getStringPreference(context, PROFILE_AVATAR_URL_PREF, null) } - @JvmStatic - fun getNotificationPriority(context: Context): Int { - return getStringPreference(context, NOTIFICATION_PRIORITY_PREF, NotificationCompat.PRIORITY_HIGH.toString())!!.toInt() - } - - @JvmStatic - fun getMessageBodyTextSize(context: Context): Int { - return getStringPreference(context, MESSAGE_BODY_TEXT_SIZE_PREF, "16")!!.toInt() - } - - @JvmStatic - fun setDirectCaptureCameraId(context: Context, value: Int) { - setIntegerPreference(context, DIRECT_CAPTURE_CAMERA_ID, value) - } - - @JvmStatic - fun getDirectCaptureCameraId(context: Context): Int { - return getIntegerPreference(context, DIRECT_CAPTURE_CAMERA_ID, Camera.CameraInfo.CAMERA_FACING_BACK) - } - @JvmStatic fun getNotificationPrivacy(context: Context): NotificationPrivacyPreference { return NotificationPrivacyPreference(getStringPreference(context, NOTIFICATION_PRIVACY_PREF, "all")) @@ -741,26 +784,6 @@ interface TextSecurePreferences { return getBooleanPreference(context, THREAD_TRIM_ENABLED, true) } - @JvmStatic - fun isSystemEmojiPreferred(context: Context): Boolean { - return getBooleanPreference(context, SYSTEM_EMOJI_PREF, false) - } - - @JvmStatic - fun getMobileMediaDownloadAllowed(context: Context): Set? { - return getMediaDownloadAllowed(context, MEDIA_DOWNLOAD_MOBILE_PREF, R.array.pref_media_download_mobile_data_default) - } - - @JvmStatic - fun getWifiMediaDownloadAllowed(context: Context): Set? { - return getMediaDownloadAllowed(context, MEDIA_DOWNLOAD_WIFI_PREF, R.array.pref_media_download_wifi_default) - } - - @JvmStatic - fun getRoamingMediaDownloadAllowed(context: Context): Set? { - return getMediaDownloadAllowed(context, MEDIA_DOWNLOAD_ROAMING_PREF, R.array.pref_media_download_roaming_default) - } - private fun getMediaDownloadAllowed(context: Context, key: String, @ArrayRes defaultValuesRes: Int): Set? { return getStringSetPreference(context, key, HashSet(Arrays.asList(*context.resources.getStringArray(defaultValuesRes)))) } @@ -809,6 +832,14 @@ interface TextSecurePreferences { fun hasForcedNewConfig(context: Context): Boolean { return getBooleanPreference(context, HAS_FORCED_NEW_CONFIG, false) } + + fun forcedCommunityDescriptionPoll(context: Context, room: String): Boolean { + return getBooleanPreference(context, FORCED_COMMUNITY_DESCRIPTION_POLL+room, false) + } + + fun setForcedCommunityDescriptionPoll(context: Context, room: String, forced: Boolean) { + setBooleanPreference(context, FORCED_COMMUNITY_DESCRIPTION_POLL+room, forced) + } @JvmStatic fun getBooleanPreference(context: Context, key: String?, defaultValue: Boolean): Boolean { @@ -889,6 +920,16 @@ interface TextSecurePreferences { setLongPreference(context, "last_profile_picture_upload", newValue) } + @JvmStatic + fun getProfileExpiry(context: Context): Long{ + return getLongPreference(context, PROFILE_PIC_EXPIRY, 0) + } + + @JvmStatic + fun setProfileExpiry(context: Context, newValue: Long){ + setLongPreference(context, PROFILE_PIC_EXPIRY, newValue) + } + fun getLastSnodePoolRefreshDate(context: Context?): Long { return getLongPreference(context!!, "last_snode_pool_refresh_date", 0) } @@ -970,8 +1011,14 @@ interface TextSecurePreferences { setBooleanPreference(context, FINGERPRINT_KEY_GENERATED, true) } + @JvmStatic + fun clearAll(context: Context) { + getDefaultSharedPreferences(context).edit().clear().commit() + } + // ----- Get / set methods for if we have already warned the user that saving attachments will allow other apps to access them ----- + // Note: We only ever show the warning dialog about this ONCE - when the user accepts this fact we write true to the flag & never show again. @JvmStatic fun getHaveWarnedUserAboutSavingAttachments(context: Context): Boolean { return getBooleanPreference(context, HAVE_WARNED_USER_ABOUT_SAVING_ATTACHMENTS, false) @@ -981,11 +1028,20 @@ interface TextSecurePreferences { fun setHaveWarnedUserAboutSavingAttachments(context: Context) { setBooleanPreference(context, HAVE_WARNED_USER_ABOUT_SAVING_ATTACHMENTS, true) } - // --------------------------------------------------------------------------------------------------------------------------------- + // ----- Get / set methods for the user's date format preference ----- @JvmStatic - fun clearAll(context: Context) { - getDefaultSharedPreferences(context).edit().clear().commit() + fun getDateFormatPref(context: Context): Int { + // Note: 0 means "follow system setting" (default) - go to the declaration of DATE_FORMAT_PREF for further details. + return getIntegerPreference(context, DATE_FORMAT_PREF, -1) + } + + @JvmStatic + fun setDateFormatPref(context: Context, value: Int) { setIntegerPreference(context, DATE_FORMAT_PREF, value) } + + @JvmStatic + fun forcedShortTTL(context: Context): Boolean { + return getBooleanPreference(context, FORCED_SHORT_TTL, false) } } } @@ -994,21 +1050,39 @@ interface TextSecurePreferences { class AppTextSecurePreferences @Inject constructor( @ApplicationContext private val context: Context ): TextSecurePreferences { - init { - // Should remove once all static access to the companion objects is removed - TextSecurePreferences.preferenceInstance = this - } - private val localNumberState = MutableStateFlow(getStringPreference(TextSecurePreferences.LOCAL_NUMBER_PREF, null)) + private val proState = MutableStateFlow(getBooleanPreference(SET_FORCE_CURRENT_USER_PRO, false)) + private val postProLaunchState = MutableStateFlow(getBooleanPreference(SET_FORCE_POST_PRO, false)) + private val hiddenPasswordState = MutableStateFlow(getBooleanPreference(HIDE_PASSWORD, false)) override var migratedToGroupV2Config: Boolean get() = getBooleanPreference(TextSecurePreferences.MIGRATED_TO_GROUP_V2_CONFIG, false) set(value) = setBooleanPreference(TextSecurePreferences.MIGRATED_TO_GROUP_V2_CONFIG, value) + override var migratedToDisablingKDF: Boolean + get() = getBooleanPreference(TextSecurePreferences.MIGRATED_TO_DISABLING_KDF, false) + set(value) = getDefaultSharedPreferences(context).edit(commit = true) { + putBoolean(TextSecurePreferences.MIGRATED_TO_DISABLING_KDF, value) + } + + override var migratedToMultiPartConfig: Boolean + get() = getBooleanPreference(TextSecurePreferences.MIGRATED_TO_MULTIPART_CONFIG, false) + set(value) = setBooleanPreference(TextSecurePreferences.MIGRATED_TO_MULTIPART_CONFIG, value) + + override var migratedDisappearingMessagesToMessageContent: Boolean + get() = getBooleanPreference("migrated_disappearing_messages_to_message_content", false) + set(value) = setBooleanPreference("migrated_disappearing_messages_to_message_content", value) + override fun getConfigurationMessageSynced(): Boolean { return getBooleanPreference(TextSecurePreferences.CONFIGURATION_SYNCED, false) } + override var selectedActivityAliasName: String? + get() = getStringPreference("selected_activity_alias_name", null) + set(value) { + setStringPreference("selected_activity_alias_name", value) + } + override fun setConfigurationMessageSynced(value: Boolean) { setBooleanPreference(TextSecurePreferences.CONFIGURATION_SYNCED, value) _events.tryEmit(TextSecurePreferences.CONFIGURATION_SYNCED) @@ -1201,12 +1275,19 @@ class AppTextSecurePreferences @Inject constructor( return getStringPreference(TextSecurePreferences.MESSAGE_BODY_TEXT_SIZE_PREF, "16")!!.toInt() } - override fun setDirectCaptureCameraId(value: Int) { - setIntegerPreference(TextSecurePreferences.DIRECT_CAPTURE_CAMERA_ID, value) + override fun setPreferredCameraDirection(value: CameraSelector) { + setIntegerPreference(TextSecurePreferences.DIRECT_CAPTURE_CAMERA_ID, + when(value){ + CameraSelector.DEFAULT_FRONT_CAMERA -> Camera.CameraInfo.CAMERA_FACING_FRONT + else -> Camera.CameraInfo.CAMERA_FACING_BACK + }) } - override fun getDirectCaptureCameraId(): Int { - return getIntegerPreference(TextSecurePreferences.DIRECT_CAPTURE_CAMERA_ID, Camera.CameraInfo.CAMERA_FACING_BACK) + override fun getPreferredCameraDirection(): CameraSelector { + return when(getIntegerPreference(TextSecurePreferences.DIRECT_CAPTURE_CAMERA_ID, Camera.CameraInfo.CAMERA_FACING_BACK)){ + Camera.CameraInfo.CAMERA_FACING_FRONT -> CameraSelector.DEFAULT_FRONT_CAMERA + else -> CameraSelector.DEFAULT_BACK_CAMERA + } } override fun getNotificationPrivacy(): NotificationPrivacyPreference { @@ -1277,7 +1358,7 @@ class AppTextSecurePreferences @Inject constructor( override fun setHasLegacyConfig(newValue: Boolean) { setBooleanPreference(TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG, newValue) - TextSecurePreferences._events.tryEmit(TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG) + _events.tryEmit(TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG) } override fun setLocalNumber(localNumber: String) { @@ -1362,26 +1443,6 @@ class AppTextSecurePreferences @Inject constructor( return getBooleanPreference(TextSecurePreferences.THREAD_TRIM_ENABLED, true) } - override fun isSystemEmojiPreferred(): Boolean { - return getBooleanPreference(TextSecurePreferences.SYSTEM_EMOJI_PREF, false) - } - - override fun getMobileMediaDownloadAllowed(): Set? { - return getMediaDownloadAllowed(TextSecurePreferences.MEDIA_DOWNLOAD_MOBILE_PREF, R.array.pref_media_download_mobile_data_default) - } - - override fun getWifiMediaDownloadAllowed(): Set? { - return getMediaDownloadAllowed(TextSecurePreferences.MEDIA_DOWNLOAD_WIFI_PREF, R.array.pref_media_download_wifi_default) - } - - override fun getRoamingMediaDownloadAllowed(): Set? { - return getMediaDownloadAllowed(TextSecurePreferences.MEDIA_DOWNLOAD_ROAMING_PREF, R.array.pref_media_download_roaming_default) - } - - override fun getMediaDownloadAllowed(key: String, @ArrayRes defaultValuesRes: Int): Set? { - return getStringSetPreference(key, HashSet(listOf(*context.resources.getStringArray(defaultValuesRes)))) - } - override fun getLogEncryptedSecret(): String? { return getStringPreference(TextSecurePreferences.LOG_ENCRYPTED_SECRET, null) } @@ -1470,6 +1531,10 @@ class AppTextSecurePreferences @Inject constructor( } } + override fun setStringSetPreference(key: String, value: Set) { + getDefaultSharedPreferences(context).edit { putStringSet(key, value) } + } + override fun getHasViewedSeed(): Boolean { return getBooleanPreference("has_viewed_seed", false) } @@ -1550,7 +1615,7 @@ class AppTextSecurePreferences @Inject constructor( val environment = getStringPreference(ENVIRONMENT, null) return if (environment != null) { Environment.valueOf(environment) - } else Environment.MAIN_NET + } else BuildConfig.DEFAULT_ENVIRONMENT } override fun setEnvironment(value: Environment) { @@ -1598,6 +1663,48 @@ class AppTextSecurePreferences @Inject constructor( _events.tryEmit(HAS_HIDDEN_NOTE_TO_SELF) } + override fun forceCurrentUserAsPro(): Boolean { + return getBooleanPreference(SET_FORCE_CURRENT_USER_PRO, false) + } + + override fun setForceCurrentUserAsPro(isPro: Boolean) { + setBooleanPreference(SET_FORCE_CURRENT_USER_PRO, isPro) + proState.update { isPro } + } + + override fun watchProStatus(): StateFlow { + return proState + } + + override fun forceOtherUsersAsPro(): Boolean { + return getBooleanPreference(SET_FORCE_OTHER_USERS_PRO, false) + } + + override fun setForceOtherUsersAsPro(isPro: Boolean) { + setBooleanPreference(SET_FORCE_OTHER_USERS_PRO, isPro) + } + + override fun forceIncomingMessagesAsPro(): Boolean { + return getBooleanPreference(SET_FORCE_INCOMING_MESSAGE_PRO, false) + } + + override fun setForceIncomingMessagesAsPro(isPro: Boolean) { + setBooleanPreference(SET_FORCE_INCOMING_MESSAGE_PRO, isPro) + } + + override fun forcePostPro(): Boolean { + return getBooleanPreference(SET_FORCE_POST_PRO, false) + } + + override fun setForcePostPro(postPro: Boolean) { + setBooleanPreference(SET_FORCE_POST_PRO, postPro) + postProLaunchState.update { postPro } + } + + override fun watchPostProStatus(): StateFlow { + return postProLaunchState + } + override fun getFingerprintKeyGenerated(): Boolean { return getBooleanPreference(TextSecurePreferences.FINGERPRINT_KEY_GENERATED, false) } @@ -1625,7 +1732,7 @@ class AppTextSecurePreferences @Inject constructor( override fun setAccentColorStyle(@StyleRes newColorStyle: Int?) { setStringPreference( - TextSecurePreferences.SELECTED_ACCENT_COLOR, when (newColorStyle) { + SELECTED_ACCENT_COLOR, when (newColorStyle) { R.style.PrimaryGreen -> TextSecurePreferences.GREEN_ACCENT R.style.PrimaryBlue -> TextSecurePreferences.BLUE_ACCENT R.style.PrimaryPurple -> TextSecurePreferences.PURPLE_ACCENT @@ -1700,8 +1807,33 @@ class AppTextSecurePreferences @Inject constructor( override fun setHidePassword(value: Boolean) { setBooleanPreference(HIDE_PASSWORD, value) + hiddenPasswordState.update { value } + } + + override fun watchHidePassword(): StateFlow { + return hiddenPasswordState + } + + override fun hasSeenTokenPageNotification(): Boolean { + return getBooleanPreference(HAVE_SHOWN_A_NOTIFICATION_ABOUT_TOKEN_PAGE, false) + } + + override fun setHasSeenTokenPageNotification(value: Boolean) { + setBooleanPreference(HAVE_SHOWN_A_NOTIFICATION_ABOUT_TOKEN_PAGE, value) + } + + override fun forcedShortTTL(): Boolean { + return getBooleanPreference(FORCED_SHORT_TTL, false) } + override fun setForcedShortTTL(value: Boolean) { + setBooleanPreference(FORCED_SHORT_TTL, value) + } + + override var inAppReviewState: String? + get() = getStringPreference(TextSecurePreferences.IN_APP_REVIEW_STATE, null) + set(value) = setStringPreference(TextSecurePreferences.IN_APP_REVIEW_STATE, value) + override var deprecationStateOverride: String? get() = getStringPreference(TextSecurePreferences.DEPRECATED_STATE_OVERRIDE, null) set(value) { @@ -1731,4 +1863,13 @@ class AppTextSecurePreferences @Inject constructor( setStringPreference(TextSecurePreferences.DEPRECATING_START_TIME_OVERRIDE, value.toString()) } } + + override fun getDebugMessageFeatures(): Set { + return getStringSetPreference( TextSecurePreferences.DEBUG_MESSAGE_FEATURES, emptySet()) + ?.map { ProStatusManager.MessageProFeature.valueOf(it) }?.toSet() ?: emptySet() + } + + override fun setDebugMessageFeatures(features: Set) { + setStringSetPreference(TextSecurePreferences.DEBUG_MESSAGE_FEATURES, features.map { it.name }.toSet()) + } } diff --git a/libsession/src/main/java/org/session/libsession/utilities/ThemeUtil.java b/app/src/main/java/org/session/libsession/utilities/ThemeUtil.java similarity index 98% rename from libsession/src/main/java/org/session/libsession/utilities/ThemeUtil.java rename to app/src/main/java/org/session/libsession/utilities/ThemeUtil.java index 22375b1da7..beaaa8b9b1 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/ThemeUtil.java +++ b/app/src/main/java/org/session/libsession/utilities/ThemeUtil.java @@ -18,7 +18,7 @@ import org.session.libsignal.utilities.Log; -import org.session.libsession.R; +import network.loki.messenger.R; public class ThemeUtil { private static final String TAG = ThemeUtil.class.getSimpleName(); diff --git a/libsession/src/main/java/org/session/libsession/utilities/Throttler.java b/app/src/main/java/org/session/libsession/utilities/Throttler.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/Throttler.java rename to app/src/main/java/org/session/libsession/utilities/Throttler.java diff --git a/libsession/src/main/java/org/session/libsession/utilities/UploadResult.kt b/app/src/main/java/org/session/libsession/utilities/UploadResult.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/UploadResult.kt rename to app/src/main/java/org/session/libsession/utilities/UploadResult.kt diff --git a/app/src/main/java/org/session/libsession/utilities/UsernameUtils.kt b/app/src/main/java/org/session/libsession/utilities/UsernameUtils.kt new file mode 100644 index 0000000000..beb61769f5 --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/UsernameUtils.kt @@ -0,0 +1,25 @@ +package org.session.libsession.utilities + +import org.session.libsession.messaging.contacts.Contact +import org.session.libsignal.utilities.AccountId + +interface UsernameUtils { + fun getCurrentUsernameWithAccountIdFallback(): String + + fun getCurrentUsername(): String? + + fun saveCurrentUserName(name: String) + + fun getContactNameWithAccountID( + accountID: String, + groupId: AccountId? = null, + contactContext: Contact.ContactContext = Contact.ContactContext.REGULAR + ): String + + fun getContactNameWithAccountID( + contact: Contact?, + accountID: String, + groupId: AccountId? = null, + contactContext: Contact.ContactContext = Contact.ContactContext.REGULAR + ): String +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/Util.kt b/app/src/main/java/org/session/libsession/utilities/Util.kt similarity index 91% rename from libsession/src/main/java/org/session/libsession/utilities/Util.kt rename to app/src/main/java/org/session/libsession/utilities/Util.kt index e0c47c34c7..5afa6c09bd 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/Util.kt +++ b/app/src/main/java/org/session/libsession/utilities/Util.kt @@ -9,22 +9,18 @@ import android.os.Looper import android.provider.Telephony import android.text.Spannable import android.text.SpannableString -import android.text.Spanned import android.text.TextUtils import android.text.style.StyleSpan -import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Util.SECURE_RANDOM import java.io.* import java.nio.charset.StandardCharsets -import java.security.SecureRandom -import java.text.DecimalFormat import java.util.* import java.util.concurrent.CountDownLatch import java.util.concurrent.ExecutorService import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit -import kotlin.collections.LinkedHashMap import kotlin.math.min object Util { @@ -44,17 +40,12 @@ object Util { @JvmStatic @Throws(IOException::class) - fun copy(`in`: InputStream, out: OutputStream?): Long { - val buffer = ByteArray(8192) - var read: Int - var total: Long = 0 - while (`in`.read(buffer).also { read = it } != -1) { - out?.write(buffer, 0, read) - total += read.toLong() + fun copy(src: InputStream, dst: OutputStream): Long { + return src.use { + dst.use { + src.copyTo(dst) + } } - `in`.close() - out?.close() - return total } @JvmStatic @@ -242,12 +233,6 @@ object Util { return utf8String.toByteArray(StandardCharsets.UTF_8) } - @JvmStatic - @SuppressLint("NewApi") - fun isDefaultSmsProvider(context: Context): Boolean { - return context.packageName == Telephony.Sms.getDefaultSmsPackage(context) - } - @JvmStatic @Throws(IOException::class) fun readFully(`in`: InputStream?, buffer: ByteArray) { @@ -351,14 +336,6 @@ object Util { System.arraycopy(input, firstLength, parts[1], 0, secondLength) return parts } - - @JvmStatic - fun getPrettyFileSize(sizeBytes: Long): String { - if (sizeBytes <= 0) return "0" - val units = arrayOf("B", "kB", "MB", "GB", "TB") - val digitGroups = (Math.log10(sizeBytes.toDouble()) / Math.log10(1024.0)).toInt() - return DecimalFormat("#,##0.#").format(sizeBytes / Math.pow(1024.0, digitGroups.toDouble())) + " " + units[digitGroups] - } } fun T.runIf(condition: Boolean, block: T.() -> R): R where T: R = if (condition) block() else this diff --git a/libsession/src/main/java/org/session/libsession/utilities/ViewUtil.java b/app/src/main/java/org/session/libsession/utilities/ViewUtil.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/ViewUtil.java rename to app/src/main/java/org/session/libsession/utilities/ViewUtil.java diff --git a/app/src/main/java/org/session/libsession/utilities/ViewUtils.kt b/app/src/main/java/org/session/libsession/utilities/ViewUtils.kt new file mode 100644 index 0000000000..196e9fa181 --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/ViewUtils.kt @@ -0,0 +1,77 @@ +package org.session.libsession.utilities + +import android.content.Context +import android.os.Build +import android.text.Layout +import android.text.StaticLayout +import android.text.TextDirectionHeuristics +import android.util.TypedValue +import android.view.View +import android.view.View.TEXT_ALIGNMENT_CENTER +import android.view.View.TEXT_ALIGNMENT_TEXT_END +import android.view.View.TEXT_ALIGNMENT_VIEW_END +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.AttrRes +import androidx.annotation.ColorInt +import androidx.core.text.TextDirectionHeuristicsCompat +import androidx.core.widget.TextViewCompat + +@ColorInt +fun Context.getColorFromAttr( + @AttrRes attrColor: Int, + typedValue: TypedValue = TypedValue(), + resolveRefs: Boolean = true +): Int { + theme.resolveAttribute(attrColor, typedValue, resolveRefs) + return typedValue.data +} + +inline fun View.modifyLayoutParams(function: LP.() -> Unit) { + layoutParams = (layoutParams as LP).apply { function() } +} + +fun TextView.needsCollapsing( + availableWidthPx: Int, + maxLines: Int +): Boolean { + if (availableWidthPx <= 0 || text.isNullOrEmpty()) return false + + // The exact text that will be drawn (all-caps, password dots …) + val textForLayout = transformationMethod?.getTransformation(text, this) ?: text + + // Build a StaticLayout that mirrors this TextView’s wrap rules + val builder = StaticLayout.Builder + .obtain(textForLayout, 0, textForLayout.length, paint, availableWidthPx) + .setIncludePad(includeFontPadding) + .setLineSpacing(lineSpacingExtra, lineSpacingMultiplier) + .setBreakStrategy(breakStrategy) // API 23+ + .setHyphenationFrequency(hyphenationFrequency) + .setMaxLines(Int.MAX_VALUE) + + // Alignment (honours RTL if textAlignment is END/VIEW_END) + builder.setAlignment( + when (textAlignment) { + TEXT_ALIGNMENT_CENTER -> Layout.Alignment.ALIGN_CENTER + TEXT_ALIGNMENT_VIEW_END, + TEXT_ALIGNMENT_TEXT_END -> Layout.Alignment.ALIGN_OPPOSITE + else -> Layout.Alignment.ALIGN_NORMAL + } + ) + + // Direction heuristic + val dir = when (textDirection) { + View.TEXT_DIRECTION_FIRST_STRONG_RTL -> TextDirectionHeuristics.FIRSTSTRONG_RTL + View.TEXT_DIRECTION_RTL -> TextDirectionHeuristics.RTL + View.TEXT_DIRECTION_LTR -> TextDirectionHeuristics.LTR + else -> TextDirectionHeuristics.FIRSTSTRONG_LTR + } + builder.setTextDirection(dir) + + builder.setEllipsize(ellipsize) + + builder.setJustificationMode(justificationMode) + + val layout = builder.build() + return layout.lineCount > maxLines +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/WindowDebouncer.kt b/app/src/main/java/org/session/libsession/utilities/WindowDebouncer.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/WindowDebouncer.kt rename to app/src/main/java/org/session/libsession/utilities/WindowDebouncer.kt diff --git a/libsession/src/main/java/org/session/libsession/utilities/bencode/Bencode.kt b/app/src/main/java/org/session/libsession/utilities/bencode/Bencode.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/bencode/Bencode.kt rename to app/src/main/java/org/session/libsession/utilities/bencode/Bencode.kt diff --git a/libsession/src/main/java/org/session/libsession/utilities/concurrent/AssertedSuccessListener.java b/app/src/main/java/org/session/libsession/utilities/concurrent/AssertedSuccessListener.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/concurrent/AssertedSuccessListener.java rename to app/src/main/java/org/session/libsession/utilities/concurrent/AssertedSuccessListener.java diff --git a/libsession/src/main/java/org/session/libsession/utilities/concurrent/SignalExecutors.java b/app/src/main/java/org/session/libsession/utilities/concurrent/SignalExecutors.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/concurrent/SignalExecutors.java rename to app/src/main/java/org/session/libsession/utilities/concurrent/SignalExecutors.java diff --git a/libsession/src/main/java/org/session/libsession/utilities/concurrent/SimpleTask.java b/app/src/main/java/org/session/libsession/utilities/concurrent/SimpleTask.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/concurrent/SimpleTask.java rename to app/src/main/java/org/session/libsession/utilities/concurrent/SimpleTask.java diff --git a/libsession/src/main/java/org/session/libsession/utilities/recipients/MessageType.kt b/app/src/main/java/org/session/libsession/utilities/recipients/MessageType.kt similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/recipients/MessageType.kt rename to app/src/main/java/org/session/libsession/utilities/recipients/MessageType.kt diff --git a/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java b/app/src/main/java/org/session/libsession/utilities/recipients/Recipient.java similarity index 93% rename from libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java rename to app/src/main/java/org/session/libsession/utilities/recipients/Recipient.java index 8232642f5a..61a6b806d7 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java +++ b/app/src/main/java/org/session/libsession/utilities/recipients/Recipient.java @@ -33,7 +33,6 @@ import org.session.libsession.avatars.ProfileContactPhoto; import org.session.libsession.avatars.SystemContactPhoto; import org.session.libsession.avatars.TransparentContactPhoto; -import org.session.libsession.database.StorageProtocol; import org.session.libsession.messaging.MessagingModuleConfiguration; import org.session.libsession.messaging.contacts.Contact; import org.session.libsession.utilities.Address; @@ -44,6 +43,7 @@ import org.session.libsession.utilities.MaterialColor; import org.session.libsession.utilities.ProfilePictureModifiedEvent; import org.session.libsession.utilities.TextSecurePreferences; +import org.session.libsession.utilities.UsernameUtils; import org.session.libsession.utilities.Util; import org.session.libsession.utilities.recipients.RecipientProvider.RecipientDetails; import org.session.libsignal.utilities.Log; @@ -100,11 +100,8 @@ public class Recipient implements RecipientModifiedListener, Cloneable { private boolean profileSharing; private String notificationChannel; private boolean forceSmsSelection; - private String wrapperHash; private boolean blocksCommunityMessageRequests; - private @NonNull UnidentifiedAccessMode unidentifiedAccessMode = UnidentifiedAccessMode.ENABLED; - @SuppressWarnings("ConstantConditions") public static @NonNull Recipient from(@NonNull Context context, @NonNull Address address, boolean asynchronous) { if (address == null) throw new AssertionError(address); @@ -161,7 +158,6 @@ public static boolean removeCached(@NonNull Address address) { this.profileName = stale.profileName; this.profileAvatar = stale.profileAvatar; this.profileSharing = stale.profileSharing; - this.unidentifiedAccessMode = stale.unidentifiedAccessMode; this.forceSmsSelection = stale.forceSmsSelection; this.notifyType = stale.notifyType; this.disappearingState = stale.disappearingState; @@ -193,7 +189,6 @@ public static boolean removeCached(@NonNull Address address) { this.profileName = details.get().profileName; this.profileAvatar = details.get().profileAvatar; this.profileSharing = details.get().profileSharing; - this.unidentifiedAccessMode = details.get().unidentifiedAccessMode; this.forceSmsSelection = details.get().forceSmsSelection; this.notifyType = details.get().notifyType; this.autoDownloadAttachments = details.get().autoDownloadAttachments; @@ -232,7 +227,6 @@ public void onSuccess(RecipientDetails result) { Recipient.this.profileName = result.profileName; Recipient.this.profileAvatar = result.profileAvatar; Recipient.this.profileSharing = result.profileSharing; - Recipient.this.unidentifiedAccessMode = result.unidentifiedAccessMode; Recipient.this.forceSmsSelection = result.forceSmsSelection; Recipient.this.notifyType = result.notifyType; Recipient.this.disappearingState = result.disappearingState; @@ -290,9 +284,7 @@ public void onFailure(ExecutionException error) { this.profileName = details.profileName; this.profileAvatar = details.profileAvatar; 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); @@ -321,13 +313,13 @@ public void setContactUri(@Nullable Uri contactUri) { } public synchronized @NonNull String getName() { - StorageProtocol storage = MessagingModuleConfiguration.getShared().getStorage(); + UsernameUtils usernameUtils = MessagingModuleConfiguration.getShared().getUsernameUtils(); String accountID = this.address.toString(); if (isGroupOrCommunityRecipient()) { if (this.name == null) { List names = new LinkedList<>(); for (Recipient recipient : participants) { - names.add(recipient.toShortString()); + names.add(recipient.name); } return Util.join(names, ", "); } else { @@ -335,12 +327,15 @@ public void setContactUri(@Nullable Uri contactUri) { } } else if (isCommunityInboxRecipient()){ String inboxID = GroupUtil.getDecodedOpenGroupInboxAccountId(accountID); - return storage.getContactNameWithAccountID(inboxID, null, Contact.ContactContext.OPEN_GROUP); + return usernameUtils.getContactNameWithAccountID(inboxID, null, Contact.ContactContext.OPEN_GROUP); } else { - return storage.getContactNameWithAccountID(accountID, null, Contact.ContactContext.REGULAR); + return usernameUtils.getContactNameWithAccountID(accountID, null, Contact.ContactContext.REGULAR); } } + //todo SESSIONHERO refactor: This can be removed + public synchronized String getRawName() { return name; } + public void setName(@Nullable String name) { boolean notify = false; @@ -518,10 +513,6 @@ public synchronized void removeListener(RecipientModifiedListener listener) { } } - public synchronized String toShortString() { - return getName(); - } - public synchronized @NonNull Drawable getFallbackContactPhotoDrawable(Context context, boolean inverted) { return (new TransparentContactPhoto()).asDrawable(context, getColor().toAvatarColor(context), inverted); } @@ -773,37 +764,13 @@ public void setProfileKey(@Nullable byte[] profileKey) { notifyListeners(); } - public @NonNull synchronized UnidentifiedAccessMode getUnidentifiedAccessMode() { - 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; - } - - notifyListeners(); - } - - public synchronized boolean isSystemContact() { - return contactUri != null; - } - public synchronized Recipient resolve() { while (resolving) Util.wait(this, 0); return this; } public synchronized boolean showCallMenu() { - return !isGroupOrCommunityRecipient() && hasApprovedMe(); + return !isGroupOrCommunityRecipient() && hasApprovedMe() && isApproved(); } @Override @@ -825,7 +792,6 @@ public boolean equals(Object o) { && Arrays.equals(profileKey, recipient.profileKey) && Objects.equals(profileName, recipient.profileName) && Objects.equals(profileAvatar, recipient.profileAvatar) - && Objects.equals(wrapperHash, recipient.wrapperHash) && blocksCommunityMessageRequests == recipient.blocksCommunityMessageRequests; } @@ -845,7 +811,6 @@ public int hashCode() { expireMessages, profileName, profileAvatar, - wrapperHash, blocksCommunityMessageRequests ); result = 31 * result + Arrays.hashCode(profileKey); @@ -926,24 +891,6 @@ public static RegisteredState fromId(int id) { } } - public enum UnidentifiedAccessMode { - UNKNOWN(0), DISABLED(1), ENABLED(2), UNRESTRICTED(3); - - private final int mode; - - UnidentifiedAccessMode(int mode) { - this.mode = mode; - } - - public int getMode() { - return mode; - } - - public static UnidentifiedAccessMode fromMode(int mode) { - return values()[mode]; - } - } - public static class RecipientSettings { private final boolean blocked; private final boolean approved; @@ -969,9 +916,7 @@ public static class RecipientSettings { private final String signalProfileAvatar; private final boolean profileSharing; 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, @@ -995,9 +940,7 @@ public RecipientSettings(boolean blocked, boolean approved, boolean approvedMe, @Nullable String signalProfileAvatar, boolean profileSharing, @Nullable String notificationChannel, - @NonNull UnidentifiedAccessMode unidentifiedAccessMode, boolean forceSmsSelection, - String wrapperHash, boolean blocksCommunityMessageRequests ) { @@ -1025,9 +968,7 @@ public RecipientSettings(boolean blocked, boolean approved, boolean approvedMe, this.signalProfileAvatar = signalProfileAvatar; this.profileSharing = profileSharing; this.notificationChannel = notificationChannel; - this.unidentifiedAccessMode = unidentifiedAccessMode; this.forceSmsSelection = forceSmsSelection; - this.wrapperHash = wrapperHash; this.blocksCommunityMessageRequests = blocksCommunityMessageRequests; } @@ -1127,18 +1068,10 @@ public boolean isProfileSharing() { return notificationChannel; } - public @NonNull UnidentifiedAccessMode getUnidentifiedAccessMode() { - return unidentifiedAccessMode; - } - 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/RecipientExporter.java b/app/src/main/java/org/session/libsession/utilities/recipients/RecipientExporter.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientExporter.java rename to app/src/main/java/org/session/libsession/utilities/recipients/RecipientExporter.java diff --git a/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientFormattingException.java b/app/src/main/java/org/session/libsession/utilities/recipients/RecipientFormattingException.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientFormattingException.java rename to app/src/main/java/org/session/libsession/utilities/recipients/RecipientFormattingException.java diff --git a/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientModifiedListener.java b/app/src/main/java/org/session/libsession/utilities/recipients/RecipientModifiedListener.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientModifiedListener.java rename to app/src/main/java/org/session/libsession/utilities/recipients/RecipientModifiedListener.java diff --git a/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java b/app/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java similarity index 94% rename from libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java rename to app/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java index b22e3450ed..bb65805268 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java +++ b/app/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java @@ -23,7 +23,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import org.session.libsession.R; +import network.loki.messenger.R; import org.session.libsession.messaging.MessagingModuleConfiguration; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.GroupRecord; @@ -34,7 +34,6 @@ 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; import org.session.libsession.utilities.recipients.Recipient.VibrateState; import org.session.libsignal.utilities.guava.Optional; @@ -87,7 +86,7 @@ boolean removeCached(@NonNull Address address) { if (address.isGroupOrCommunity() && settings.isPresent() && groupRecord.isPresent()) { return Optional.of(getGroupRecipientDetails(context, address, groupRecord, settings, true)); } else if (!address.isGroupOrCommunity() && settings.isPresent()) { - boolean isLocalNumber = address.serialize().equals(TextSecurePreferences.getLocalNumber(context)); + boolean isLocalNumber = address.toString().equals(TextSecurePreferences.getLocalNumber(context)); return Optional.of(new RecipientDetails(null, null, !TextUtils.isEmpty(settings.get().getSystemDisplayName()), isLocalNumber, settings.get(), null)); } @@ -114,7 +113,7 @@ boolean removeCached(@NonNull Address address) { } boolean systemContact = settings.isPresent() && !TextUtils.isEmpty(settings.get().getSystemDisplayName()); - boolean isLocalNumber = address.serialize().equals(TextSecurePreferences.getLocalNumber(context)); + boolean isLocalNumber = address.toString().equals(TextSecurePreferences.getLocalNumber(context)); return new RecipientDetails(null, null, systemContact, isLocalNumber, settings.orNull(), null); } @@ -178,9 +177,7 @@ static class RecipientDetails { final boolean systemContact; final boolean isLocalNumber; @Nullable final String notificationChannel; - @NonNull final UnidentifiedAccessMode unidentifiedAccessMode; final boolean forceSmsSelection; - final String wrapperHash; final boolean blocksCommunityMessageRequests; RecipientDetails(@Nullable String name, @Nullable Long groupAvatarId, @@ -214,9 +211,7 @@ static class RecipientDetails { this.systemContact = systemContact; this.isLocalNumber = isLocalNumber; 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(); diff --git a/libsession/src/main/java/org/session/libsession/utilities/task/ProgressDialogAsyncTask.java b/app/src/main/java/org/session/libsession/utilities/task/ProgressDialogAsyncTask.java similarity index 100% rename from libsession/src/main/java/org/session/libsession/utilities/task/ProgressDialogAsyncTask.java rename to app/src/main/java/org/session/libsession/utilities/task/ProgressDialogAsyncTask.java diff --git a/libsignal/src/main/java/org/session/libsignal/crypto/CipherUtil.java b/app/src/main/java/org/session/libsignal/crypto/CipherUtil.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/crypto/CipherUtil.java rename to app/src/main/java/org/session/libsignal/crypto/CipherUtil.java diff --git a/libsignal/src/main/java/org/session/libsignal/crypto/IdentityKey.java b/app/src/main/java/org/session/libsignal/crypto/IdentityKey.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/crypto/IdentityKey.java rename to app/src/main/java/org/session/libsignal/crypto/IdentityKey.java diff --git a/libsignal/src/main/java/org/session/libsignal/crypto/IdentityKeyPair.java b/app/src/main/java/org/session/libsignal/crypto/IdentityKeyPair.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/crypto/IdentityKeyPair.java rename to app/src/main/java/org/session/libsignal/crypto/IdentityKeyPair.java diff --git a/libsignal/src/main/java/org/session/libsignal/crypto/MnemonicCodec.kt b/app/src/main/java/org/session/libsignal/crypto/MnemonicCodec.kt similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/crypto/MnemonicCodec.kt rename to app/src/main/java/org/session/libsignal/crypto/MnemonicCodec.kt diff --git a/libsignal/src/main/java/org/session/libsignal/crypto/PushTransportDetails.java b/app/src/main/java/org/session/libsignal/crypto/PushTransportDetails.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/crypto/PushTransportDetails.java rename to app/src/main/java/org/session/libsignal/crypto/PushTransportDetails.java diff --git a/libsignal/src/main/java/org/session/libsignal/crypto/Random.kt b/app/src/main/java/org/session/libsignal/crypto/Random.kt similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/crypto/Random.kt rename to app/src/main/java/org/session/libsignal/crypto/Random.kt diff --git a/app/src/main/java/org/session/libsignal/crypto/ecc/Curve.java b/app/src/main/java/org/session/libsignal/crypto/ecc/Curve.java new file mode 100644 index 0000000000..e31eeaf0fe --- /dev/null +++ b/app/src/main/java/org/session/libsignal/crypto/ecc/Curve.java @@ -0,0 +1,40 @@ +/** + * Copyright (C) 2013-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ +package org.session.libsignal.crypto.ecc; + +import org.session.libsignal.exceptions.InvalidKeyException; + +public class Curve { + + public static final int DJB_TYPE = 0x05; + + public static ECPublicKey decodePoint(byte[] bytes, int offset) + throws InvalidKeyException + { + if (bytes == null || bytes.length - offset < 1) { + throw new InvalidKeyException("No key type identifier"); + } + + int type = bytes[offset] & 0xFF; + + switch (type) { + case Curve.DJB_TYPE: + if (bytes.length - offset < 33) { + throw new InvalidKeyException("Bad key length: " + bytes.length); + } + + byte[] keyBytes = new byte[32]; + System.arraycopy(bytes, offset+1, keyBytes, 0, keyBytes.length); + return new DjbECPublicKey(keyBytes); + default: + throw new InvalidKeyException("Bad key type: " + type); + } + } + + public static ECPrivateKey decodePrivatePoint(byte[] bytes) { + return new DjbECPrivateKey(bytes); + } +} diff --git a/libsignal/src/main/java/org/session/libsignal/crypto/ecc/DjbECPrivateKey.java b/app/src/main/java/org/session/libsignal/crypto/ecc/DjbECPrivateKey.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/crypto/ecc/DjbECPrivateKey.java rename to app/src/main/java/org/session/libsignal/crypto/ecc/DjbECPrivateKey.java diff --git a/libsignal/src/main/java/org/session/libsignal/crypto/ecc/DjbECPublicKey.java b/app/src/main/java/org/session/libsignal/crypto/ecc/DjbECPublicKey.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/crypto/ecc/DjbECPublicKey.java rename to app/src/main/java/org/session/libsignal/crypto/ecc/DjbECPublicKey.java diff --git a/libsignal/src/main/java/org/session/libsignal/crypto/ecc/ECKeyPair.java b/app/src/main/java/org/session/libsignal/crypto/ecc/ECKeyPair.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/crypto/ecc/ECKeyPair.java rename to app/src/main/java/org/session/libsignal/crypto/ecc/ECKeyPair.java diff --git a/libsignal/src/main/java/org/session/libsignal/crypto/ecc/ECPrivateKey.java b/app/src/main/java/org/session/libsignal/crypto/ecc/ECPrivateKey.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/crypto/ecc/ECPrivateKey.java rename to app/src/main/java/org/session/libsignal/crypto/ecc/ECPrivateKey.java diff --git a/libsignal/src/main/java/org/session/libsignal/crypto/ecc/ECPublicKey.java b/app/src/main/java/org/session/libsignal/crypto/ecc/ECPublicKey.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/crypto/ecc/ECPublicKey.java rename to app/src/main/java/org/session/libsignal/crypto/ecc/ECPublicKey.java diff --git a/libsignal/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt b/app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt similarity index 94% rename from libsignal/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt rename to app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt index 0ceb4753b6..901bef3f6c 100644 --- a/libsignal/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt +++ b/app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt @@ -18,11 +18,13 @@ interface LokiAPIDatabaseProtocol { fun getLastMessageHashValue(snode: Snode, publicKey: String, namespace: Int): String? fun setLastMessageHashValue(snode: Snode, publicKey: String, newValue: String, namespace: Int) fun clearLastMessageHashes(publicKey: String) + fun clearLastMessageHashesByNamespaces(vararg namespaces: Int) fun clearAllLastMessageHashes() fun getReceivedMessageHashValues(publicKey: String, namespace: Int): Set? fun setReceivedMessageHashValues(publicKey: String, newValue: Set, namespace: Int) fun clearReceivedMessageHashValues(publicKey: String) fun clearReceivedMessageHashValues() + fun clearReceivedMessageHashValuesByNamespaces(vararg namespaces: Int) fun getAuthToken(server: String): String? fun setAuthToken(server: String, newValue: String?) fun setUserCount(room: String, server: String, newValue: Int) diff --git a/app/src/main/java/org/session/libsignal/database/LokiMessageDatabaseProtocol.kt b/app/src/main/java/org/session/libsignal/database/LokiMessageDatabaseProtocol.kt new file mode 100644 index 0000000000..542acb3680 --- /dev/null +++ b/app/src/main/java/org/session/libsignal/database/LokiMessageDatabaseProtocol.kt @@ -0,0 +1,8 @@ +package org.session.libsignal.database + +import org.thoughtcrime.securesms.database.model.MessageId + +interface LokiMessageDatabaseProtocol { + + fun setServerID(messageID: MessageId, serverID: Long) +} diff --git a/libsignal/src/main/java/org/session/libsignal/database/LokiOpenGroupDatabaseProtocol.kt b/app/src/main/java/org/session/libsignal/database/LokiOpenGroupDatabaseProtocol.kt similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/database/LokiOpenGroupDatabaseProtocol.kt rename to app/src/main/java/org/session/libsignal/database/LokiOpenGroupDatabaseProtocol.kt diff --git a/libsignal/src/main/java/org/session/libsignal/exceptions/InvalidKeyException.java b/app/src/main/java/org/session/libsignal/exceptions/InvalidKeyException.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/exceptions/InvalidKeyException.java rename to app/src/main/java/org/session/libsignal/exceptions/InvalidKeyException.java diff --git a/libsignal/src/main/java/org/session/libsignal/exceptions/InvalidMacException.java b/app/src/main/java/org/session/libsignal/exceptions/InvalidMacException.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/exceptions/InvalidMacException.java rename to app/src/main/java/org/session/libsignal/exceptions/InvalidMacException.java diff --git a/libsignal/src/main/java/org/session/libsignal/exceptions/InvalidMessageException.java b/app/src/main/java/org/session/libsignal/exceptions/InvalidMessageException.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/exceptions/InvalidMessageException.java rename to app/src/main/java/org/session/libsignal/exceptions/InvalidMessageException.java diff --git a/libsignal/src/main/java/org/session/libsignal/exceptions/NonRetryableException.kt b/app/src/main/java/org/session/libsignal/exceptions/NonRetryableException.kt similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/exceptions/NonRetryableException.kt rename to app/src/main/java/org/session/libsignal/exceptions/NonRetryableException.kt diff --git a/libsignal/src/main/java/org/session/libsignal/messages/SharedContact.java b/app/src/main/java/org/session/libsignal/messages/SharedContact.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/messages/SharedContact.java rename to app/src/main/java/org/session/libsignal/messages/SharedContact.java diff --git a/app/src/main/java/org/session/libsignal/messages/SignalServiceAttachment.java b/app/src/main/java/org/session/libsignal/messages/SignalServiceAttachment.java new file mode 100644 index 0000000000..493135ccf0 --- /dev/null +++ b/app/src/main/java/org/session/libsignal/messages/SignalServiceAttachment.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.session.libsignal.messages; + +import org.session.libsignal.utilities.guava.Optional; + +import java.io.InputStream; + +public abstract class SignalServiceAttachment { + + private final String contentType; + + protected SignalServiceAttachment(String contentType) { + this.contentType = contentType; + } + + public String getContentType() { + return contentType; + } + + public abstract boolean isStream(); + public abstract boolean isPointer(); + + public SignalServiceAttachmentStream asStream() { + return (SignalServiceAttachmentStream)this; + } + + public SignalServiceAttachmentPointer asPointer() { + return (SignalServiceAttachmentPointer)this; + } + + public static Builder newStreamBuilder() { + return new Builder(); + } + + public static class Builder { + + private InputStream inputStream; + private String contentType; + private String filename; + private long length; + private boolean voiceNote; + private int width; + private int height; + private String caption; + + private Builder() {} + + public Builder withStream(InputStream inputStream) { + this.inputStream = inputStream; + return this; + } + + public Builder withContentType(String contentType) { + this.contentType = contentType; + return this; + } + + public Builder withLength(long length) { + this.length = length; + return this; + } + + public Builder withFileName(String filename) { + this.filename = filename; + return this; + } + + public Builder withVoiceNote(boolean voiceNote) { + this.voiceNote = voiceNote; + return this; + } + + public Builder withWidth(int width) { + this.width = width; + return this; + } + + public Builder withHeight(int height) { + this.height = height; + return this; + } + + public Builder withCaption(String caption) { + this.caption = caption; + return this; + } + + public SignalServiceAttachmentStream build() { + if (inputStream == null) throw new IllegalArgumentException("Must specify stream!"); + if (contentType == null) throw new IllegalArgumentException("No content type specified!"); + if (length == 0) throw new IllegalArgumentException("No length specified!"); + + return new SignalServiceAttachmentStream(inputStream, contentType, length, filename, voiceNote, Optional.absent(), width, height, Optional.fromNullable(caption)); + } + } +} diff --git a/app/src/main/java/org/session/libsignal/messages/SignalServiceAttachmentPointer.java b/app/src/main/java/org/session/libsignal/messages/SignalServiceAttachmentPointer.java new file mode 100644 index 0000000000..622654ca0e --- /dev/null +++ b/app/src/main/java/org/session/libsignal/messages/SignalServiceAttachmentPointer.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2014-2017 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.session.libsignal.messages; + +import org.session.libsignal.utilities.guava.Optional; + +/** + * Represents a received SignalServiceAttachment "handle." This + * is a pointer to the actual attachment content, which needs to be + * retrieved using SignalServiceMessageReceiver.retrieveAttachment(SignalServiceAttachmentPointer, java.io.File, int) + * + * @author Moxie Marlinspike + */ +public class SignalServiceAttachmentPointer extends SignalServiceAttachment { + + private final long id; + private final byte[] key; + private final Optional size; + private final Optional preview; + private final Optional digest; + private final String filename; + private final boolean voiceNote; + private final int width; + private final int height; + private final Optional caption; + private final String url; + + public SignalServiceAttachmentPointer(long id, + String contentType, + byte[] key, + Optional size, + Optional preview, + int width, + int height, + Optional digest, + String filename, + boolean voiceNote, + Optional caption, + String url + ) { + super(contentType); + this.id = id; + this.key = key; + this.size = size; + this.preview = preview; + this.width = width; + this.height = height; + this.digest = digest; + this.filename = filename; + this.voiceNote = voiceNote; + this.caption = caption; + this.url = url; + } + + public long getId() { return id; } + public byte[] getKey() { return key; } + @Override public boolean isStream() { return false; } + @Override public boolean isPointer() { return true; } + public Optional getSize() { return size; } + public String getFilename() { return filename; } + public Optional getPreview() { return preview; } + public Optional getDigest() { return digest; } + public boolean getVoiceNote() { return voiceNote; } + public int getWidth() { return width; } + public int getHeight() { return height; } + public Optional getCaption() { return caption; } + public String getUrl() { return url; } +} diff --git a/app/src/main/java/org/session/libsignal/messages/SignalServiceAttachmentStream.java b/app/src/main/java/org/session/libsignal/messages/SignalServiceAttachmentStream.java new file mode 100644 index 0000000000..062df319d3 --- /dev/null +++ b/app/src/main/java/org/session/libsignal/messages/SignalServiceAttachmentStream.java @@ -0,0 +1,49 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.session.libsignal.messages; +import org.session.libsignal.utilities.guava.Optional; +import java.io.InputStream; + +// Represents a local SignalServiceAttachment to be sent +public class SignalServiceAttachmentStream extends SignalServiceAttachment { + + private final InputStream inputStream; + private final long length; + private final String filename; + private final Optional preview; + private final boolean voiceNote; + private final int width; + private final int height; + private final Optional caption; + + public SignalServiceAttachmentStream(InputStream inputStream, String contentType, long length, String filename, boolean voiceNote, Optional preview, int width, int height, Optional caption) { + super(contentType); + this.inputStream = inputStream; + this.length = length; + this.filename = filename; + this.voiceNote = voiceNote; + this.preview = preview; + this.width = width; + this.height = height; + this.caption = caption; + } + + @Override + public boolean isStream() { return true; } + + @Override + public boolean isPointer() { return false; } + + public InputStream getInputStream() { return inputStream; } + public long getLength() { return length; } + public String getFilename() { return filename; } + public Optional getPreview() { return preview; } + public boolean getVoiceNote() { return voiceNote; } + public int getWidth() { return width; } + public int getHeight() { return height; } + public Optional getCaption() { return caption; } +} diff --git a/libsignal/src/main/java/org/session/libsignal/messages/SignalServiceDataMessage.java b/app/src/main/java/org/session/libsignal/messages/SignalServiceDataMessage.java similarity index 94% rename from libsignal/src/main/java/org/session/libsignal/messages/SignalServiceDataMessage.java rename to app/src/main/java/org/session/libsignal/messages/SignalServiceDataMessage.java index 8f908284eb..f9c3f3dc21 100644 --- a/libsignal/src/main/java/org/session/libsignal/messages/SignalServiceDataMessage.java +++ b/app/src/main/java/org/session/libsignal/messages/SignalServiceDataMessage.java @@ -8,7 +8,6 @@ import org.session.libsignal.utilities.guava.Optional; import org.session.libsignal.utilities.SignalServiceAddress; -import org.session.libsignal.protos.SignalServiceProtos.DataMessage.ClosedGroupControlMessage; import java.util.LinkedList; import java.util.List; @@ -27,8 +26,6 @@ public class SignalServiceDataMessage { private final Optional quote; public final Optional> contacts; private final Optional> previews; - // Loki - private final Optional closedGroupControlMessage; private final Optional syncTarget; /** @@ -121,16 +118,16 @@ public SignalServiceDataMessage(long timestamp, SignalServiceGroup group, boolean expirationUpdate, byte[] profileKey, Quote quote, List sharedContacts, List previews) { - this(timestamp, group, attachments, body, expiresInSeconds, expirationUpdate, profileKey, quote, sharedContacts, previews, null, null); + this(timestamp, group, attachments, body, expiresInSeconds, expirationUpdate, profileKey, quote, sharedContacts, previews, null); } /** * Construct a SignalServiceDataMessage. * - * @param timestamp The sent timestamp. - * @param group The group information (or null if none). - * @param attachments The attachments (or null if none). - * @param body The message contents. + * @param timestamp The sent timestamp. + * @param group The group information (or null if none). + * @param attachments The attachments (or null if none). + * @param body The message contents. * @param expiresInSeconds Number of seconds in which the message should disappear after being seen. */ public SignalServiceDataMessage(long timestamp, SignalServiceGroup group, @@ -138,7 +135,6 @@ public SignalServiceDataMessage(long timestamp, SignalServiceGroup group, String body, int expiresInSeconds, boolean expirationUpdate, byte[] profileKey, Quote quote, List sharedContacts, List previews, - ClosedGroupControlMessage closedGroupControlMessage, String syncTarget) { this.timestamp = timestamp; @@ -148,7 +144,6 @@ public SignalServiceDataMessage(long timestamp, SignalServiceGroup group, this.expirationUpdate = expirationUpdate; this.profileKey = Optional.fromNullable(profileKey); this.quote = Optional.fromNullable(quote); - this.closedGroupControlMessage = Optional.fromNullable(closedGroupControlMessage); this.syncTarget = Optional.fromNullable(syncTarget); if (attachments != null && !attachments.isEmpty()) { @@ -236,9 +231,6 @@ public Optional> getPreviews() { return previews; } - // Loki - public Optional getClosedGroupControlMessage() { return closedGroupControlMessage; } - public boolean hasVisibleContent() { return (body.isPresent() && !body.get().isEmpty()) || (attachments.isPresent() && !attachments.get().isEmpty()); @@ -323,7 +315,7 @@ 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); + syncTarget); } } diff --git a/libsignal/src/main/java/org/session/libsignal/messages/SignalServiceEnvelope.java b/app/src/main/java/org/session/libsignal/messages/SignalServiceEnvelope.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/messages/SignalServiceEnvelope.java rename to app/src/main/java/org/session/libsignal/messages/SignalServiceEnvelope.java diff --git a/libsignal/src/main/java/org/session/libsignal/messages/SignalServiceGroup.java b/app/src/main/java/org/session/libsignal/messages/SignalServiceGroup.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/messages/SignalServiceGroup.java rename to app/src/main/java/org/session/libsignal/messages/SignalServiceGroup.java diff --git a/libsignal/src/main/java/org/session/libsignal/streams/AttachmentCipherInputStream.java b/app/src/main/java/org/session/libsignal/streams/AttachmentCipherInputStream.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/streams/AttachmentCipherInputStream.java rename to app/src/main/java/org/session/libsignal/streams/AttachmentCipherInputStream.java diff --git a/libsignal/src/main/java/org/session/libsignal/streams/AttachmentCipherOutputStream.java b/app/src/main/java/org/session/libsignal/streams/AttachmentCipherOutputStream.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/streams/AttachmentCipherOutputStream.java rename to app/src/main/java/org/session/libsignal/streams/AttachmentCipherOutputStream.java diff --git a/libsignal/src/main/java/org/session/libsignal/streams/AttachmentCipherOutputStreamFactory.java b/app/src/main/java/org/session/libsignal/streams/AttachmentCipherOutputStreamFactory.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/streams/AttachmentCipherOutputStreamFactory.java rename to app/src/main/java/org/session/libsignal/streams/AttachmentCipherOutputStreamFactory.java diff --git a/libsignal/src/main/java/org/session/libsignal/streams/ContentLengthInputStream.java b/app/src/main/java/org/session/libsignal/streams/ContentLengthInputStream.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/streams/ContentLengthInputStream.java rename to app/src/main/java/org/session/libsignal/streams/ContentLengthInputStream.java diff --git a/libsignal/src/main/java/org/session/libsignal/streams/DigestingOutputStream.java b/app/src/main/java/org/session/libsignal/streams/DigestingOutputStream.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/streams/DigestingOutputStream.java rename to app/src/main/java/org/session/libsignal/streams/DigestingOutputStream.java diff --git a/libsignal/src/main/java/org/session/libsignal/streams/DigestingRequestBody.java b/app/src/main/java/org/session/libsignal/streams/DigestingRequestBody.java similarity index 81% rename from libsignal/src/main/java/org/session/libsignal/streams/DigestingRequestBody.java rename to app/src/main/java/org/session/libsignal/streams/DigestingRequestBody.java index 4eb739368a..6e7d1f5514 100644 --- a/libsignal/src/main/java/org/session/libsignal/streams/DigestingRequestBody.java +++ b/app/src/main/java/org/session/libsignal/streams/DigestingRequestBody.java @@ -1,7 +1,5 @@ package org.session.libsignal.streams; -import org.session.libsignal.messages.SignalServiceAttachment.ProgressListener; - import java.io.IOException; import java.io.InputStream; @@ -15,20 +13,17 @@ public class DigestingRequestBody extends RequestBody { private final OutputStreamFactory outputStreamFactory; private final String contentType; private final long contentLength; - private final ProgressListener progressListener; private byte[] digest; public DigestingRequestBody(InputStream inputStream, OutputStreamFactory outputStreamFactory, - String contentType, long contentLength, - ProgressListener progressListener) + String contentType, long contentLength) { this.inputStream = inputStream; this.outputStreamFactory = outputStreamFactory; this.contentType = contentType; this.contentLength = contentLength; - this.progressListener = progressListener; } @Override @@ -47,10 +42,6 @@ public void writeTo(BufferedSink sink) throws IOException { while ((read = inputStream.read(buffer, 0, buffer.length)) != -1) { outputStream.write(buffer, 0, read); total += read; - - if (progressListener != null) { - progressListener.onAttachmentProgress(contentLength, total); - } } outputStream.flush(); diff --git a/libsignal/src/main/java/org/session/libsignal/streams/OutputStreamFactory.java b/app/src/main/java/org/session/libsignal/streams/OutputStreamFactory.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/streams/OutputStreamFactory.java rename to app/src/main/java/org/session/libsignal/streams/OutputStreamFactory.java diff --git a/libsignal/src/main/java/org/session/libsignal/streams/PaddingInputStream.java b/app/src/main/java/org/session/libsignal/streams/PaddingInputStream.java similarity index 81% rename from libsignal/src/main/java/org/session/libsignal/streams/PaddingInputStream.java rename to app/src/main/java/org/session/libsignal/streams/PaddingInputStream.java index 8e4c52c3cb..e9bf434054 100644 --- a/libsignal/src/main/java/org/session/libsignal/streams/PaddingInputStream.java +++ b/app/src/main/java/org/session/libsignal/streams/PaddingInputStream.java @@ -53,6 +53,15 @@ public int available() throws IOException { } public static long getPaddedSize(long size) { - return (int) Math.max(541, Math.floor(Math.pow(1.05, Math.ceil(Math.log(size) / Math.log(1.05))))); + return Math.max( + 541L, + Math.min( + 10_000_000L, + (long) Math.floor(Math.pow( + 1.05, + Math.ceil(Math.log(Math.max(1, size)) / Math.log(1.05)) + )) + ) + ); } } diff --git a/libsignal/src/main/java/org/session/libsignal/streams/PlaintextOutputStreamFactory.kt b/app/src/main/java/org/session/libsignal/streams/PlaintextOutputStreamFactory.kt similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/streams/PlaintextOutputStreamFactory.kt rename to app/src/main/java/org/session/libsignal/streams/PlaintextOutputStreamFactory.kt diff --git a/libsignal/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStream.java b/app/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStream.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStream.java rename to app/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStream.java diff --git a/libsignal/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStreamFactory.java b/app/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStreamFactory.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStreamFactory.java rename to app/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStreamFactory.java diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/AccountId.kt b/app/src/main/java/org/session/libsignal/utilities/AccountId.kt similarity index 91% rename from libsignal/src/main/java/org/session/libsignal/utilities/AccountId.kt rename to app/src/main/java/org/session/libsignal/utilities/AccountId.kt index ebed7b4831..f433cb3fa0 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/AccountId.kt +++ b/app/src/main/java/org/session/libsignal/utilities/AccountId.kt @@ -1,6 +1,7 @@ package org.session.libsignal.utilities -import org.session.libsignal.BuildConfig +import network.loki.messenger.BuildConfig + private val VALID_ACCOUNT_ID_PATTERN = Regex("[0-9]{2}[0-9a-fA-F]{64}") @@ -38,7 +39,7 @@ data class AccountId( } companion object { - fun fromString(accountId: String): AccountId? { + fun fromStringOrNull(accountId: String): AccountId? { return if (VALID_ACCOUNT_ID_PATTERN.matches(accountId)) { AccountId(accountId) } else { diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Base64.java b/app/src/main/java/org/session/libsignal/utilities/Base64.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/Base64.java rename to app/src/main/java/org/session/libsignal/utilities/Base64.java diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Broadcaster.kt b/app/src/main/java/org/session/libsignal/utilities/Broadcaster.kt similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/Broadcaster.kt rename to app/src/main/java/org/session/libsignal/utilities/Broadcaster.kt diff --git a/app/src/main/java/org/session/libsignal/utilities/ByteArraySlice.kt b/app/src/main/java/org/session/libsignal/utilities/ByteArraySlice.kt new file mode 100644 index 0000000000..8bb047bbaf --- /dev/null +++ b/app/src/main/java/org/session/libsignal/utilities/ByteArraySlice.kt @@ -0,0 +1,94 @@ +package org.session.libsignal.utilities + +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.io.OutputStream + +/** + * A view of a byte array with a range. This is useful for avoiding copying data when slicing a byte array. + */ +class ByteArraySlice private constructor( + val data: ByteArray, + val offset: Int, + val len: Int, +) { + init { + check(offset in 0..data.size) { "Offset $offset is not within [0..${data.size}]" } + check(len in 0..data.size) { "Length $len is not within [0..${data.size}]" } + } + + fun view(range: IntRange): ByteArraySlice { + val newOffset = offset + range.first + val newLength = range.last + 1 - range.first + return ByteArraySlice( + data = data, + offset = newOffset, + len = newLength + ) + } + + fun copyToBytes(): ByteArray { + return data.copyOfRange(offset, offset + len) + } + + operator fun get(index: Int): Byte { + return data[offset + index] + } + + fun asList(): List { + return object : AbstractList() { + override val size: Int + get() = this@ByteArraySlice.len + + override fun get(index: Int) = this@ByteArraySlice[index] + } + } + + fun decodeToString(): String { + return data.decodeToString(offset, offset + len) + } + + fun inputStream(): InputStream { + return ByteArrayInputStream(data, offset, len) + } + + fun isEmpty(): Boolean = len == 0 + fun isNotEmpty(): Boolean = len != 0 + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ByteArraySlice) return false + + if (offset != other.offset) return false + if (len != other.len) return false + if (!data.contentEquals(other.data)) return false + + return true + } + + override fun hashCode(): Int { + var result = offset + result = 31 * result + len + result = 31 * result + data.contentHashCode() + return result + } + + companion object { + val EMPTY = ByteArraySlice(byteArrayOf(), 0, 0) + + /** + * Create a view of a byte array + */ + fun ByteArray.view(range: IntRange = indices): ByteArraySlice { + return ByteArraySlice( + data = this, + offset = range.first, + len = range.last + 1 - range.first + ) + } + + fun OutputStream.write(view: ByteArraySlice) { + write(view.data, view.offset, view.len) + } + } +} diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/ByteUtil.java b/app/src/main/java/org/session/libsignal/utilities/ByteUtil.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/ByteUtil.java rename to app/src/main/java/org/session/libsignal/utilities/ByteUtil.java diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/ExternalStorageUtil.kt b/app/src/main/java/org/session/libsignal/utilities/ExternalStorageUtil.kt similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/ExternalStorageUtil.kt rename to app/src/main/java/org/session/libsignal/utilities/ExternalStorageUtil.kt diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/ForkInfo.kt b/app/src/main/java/org/session/libsignal/utilities/ForkInfo.kt similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/ForkInfo.kt rename to app/src/main/java/org/session/libsignal/utilities/ForkInfo.kt diff --git a/app/src/main/java/org/session/libsignal/utilities/HTTP.kt b/app/src/main/java/org/session/libsignal/utilities/HTTP.kt new file mode 100644 index 0000000000..a459fa2921 --- /dev/null +++ b/app/src/main/java/org/session/libsignal/utilities/HTTP.kt @@ -0,0 +1,128 @@ +package org.session.libsignal.utilities + +import android.annotation.SuppressLint +import android.net.http.X509TrustManagerExtensions +import android.util.Log +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.timeout +import io.ktor.client.request.headers +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsBytes +import io.ktor.http.ContentType +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import java.security.cert.X509Certificate +import java.util.concurrent.TimeUnit +import javax.net.ssl.X509TrustManager + + +object HTTP { + var isConnectedToNetwork: (() -> Boolean) = { false } + + private val seedNodeClient: HttpClient by lazy { + HttpClient(CIO) + } + + private val serviceNodeClient: HttpClient by lazy { + HttpClient(CIO) { + engine { + https { + @SuppressLint("CustomX509TrustManager") + trustManager = object : X509TrustManager { + @SuppressLint("TrustAllX509TrustManager") + override fun checkClientTrusted(chain: Array?, authorizationType: String?) { } + + @SuppressLint("TrustAllX509TrustManager") + override fun checkServerTrusted(chain: Array?, authorizationType: String?) { } + + override fun getAcceptedIssuers(): Array { return arrayOf() } + } + } + + requestTimeout = TimeUnit.SECONDS.toMillis(timeout) + } + } + } + + private const val timeout: Long = 120 + + 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") + } + + /** + * Sync. Don't call from the main thread. + */ + suspend fun execute(verb: Verb, url: String, timeout: Long = HTTP.timeout, useSeedNodeConnection: Boolean = false): ByteArray { + return execute(verb = verb, url = url, body = null, timeout = timeout, useSeedNodeConnection = useSeedNodeConnection) + } + + /** + * Sync. Don't call from the main thread. + */ + suspend fun execute(verb: Verb, url: String, parameters: Map?, timeout: Long = HTTP.timeout, useSeedNodeConnection: Boolean = false): ByteArray { + return if (parameters != null) { + val body = JsonUtil.toJson(parameters).toByteArray() + execute(verb = verb, url = url, body = body, timeout = timeout, useSeedNodeConnection = useSeedNodeConnection) + } else { + execute(verb = verb, url = url, body = null, timeout = timeout, useSeedNodeConnection = useSeedNodeConnection) + } + } + + /** + * Sync. Don't call from the main thread. + */ + suspend fun execute(verb: Verb, url: String, body: ByteArray?, timeout: Long = HTTP.timeout, useSeedNodeConnection: Boolean = false): ByteArray { + val client = if (useSeedNodeConnection) seedNodeClient else serviceNodeClient + + try { + val response = client.request(url) { + method = HttpMethod.parse(verb.rawValue) + + headers { + remove("User-Agent") + remove("Accept-Language") + + append("User-Agent", "WhatsApp") + append("Accept-Language", "en-us") + } + + if (verb == Verb.PUT || verb == Verb.POST) { + check(body != null) { "Invalid request body." } + contentType(ContentType.Application.Json) + setBody(body) + } + + timeout { + requestTimeoutMillis = TimeUnit.SECONDS.toMillis(timeout) + } + } + + when (val code = response.status) { + HttpStatusCode.OK -> return response.bodyAsBytes() + else -> { + Log.d("Loki", "${verb.rawValue} request to $url failed with status code: $code.") + throw HTTPRequestFailedException(code.value, null) + } + } + + } catch (exception: Exception) { + Log.d("Loki", "${verb.rawValue} request to $url failed due to error: ${exception.localizedMessage}.", exception) + + if (!isConnectedToNetwork()) { throw HTTPNoNetworkException() } + + // Override the actual error so that we can correctly catch failed requests in OnionRequestAPI + throw HTTPRequestFailedException(0, null, "HTTP request failed due to: ${exception.message}") + } + } +} diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Hex.java b/app/src/main/java/org/session/libsignal/utilities/Hex.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/Hex.java rename to app/src/main/java/org/session/libsignal/utilities/Hex.java diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Hex.kt b/app/src/main/java/org/session/libsignal/utilities/Hex.kt similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/Hex.kt rename to app/src/main/java/org/session/libsignal/utilities/Hex.kt diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/HexEncoding.kt b/app/src/main/java/org/session/libsignal/utilities/HexEncoding.kt similarity index 90% rename from libsignal/src/main/java/org/session/libsignal/utilities/HexEncoding.kt rename to app/src/main/java/org/session/libsignal/utilities/HexEncoding.kt index 07b6ccced7..b998e8e6a7 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/HexEncoding.kt +++ b/app/src/main/java/org/session/libsignal/utilities/HexEncoding.kt @@ -4,7 +4,7 @@ import org.session.libsignal.crypto.IdentityKeyPair import org.session.libsignal.crypto.ecc.ECKeyPair fun ByteArray.toHexString(): String { - return joinToString("") { String.format("%02x", it) } + return Hex.toStringCondensed(this) } val IdentityKeyPair.hexEncodedPublicKey: String diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/IdPrefix.kt b/app/src/main/java/org/session/libsignal/utilities/IdPrefix.kt similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/IdPrefix.kt rename to app/src/main/java/org/session/libsignal/utilities/IdPrefix.kt diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/JsonUtil.java b/app/src/main/java/org/session/libsignal/utilities/JsonUtil.java similarity index 93% rename from libsignal/src/main/java/org/session/libsignal/utilities/JsonUtil.java rename to app/src/main/java/org/session/libsignal/utilities/JsonUtil.java index 1ed3ec67f3..872b716f23 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/JsonUtil.java +++ b/app/src/main/java/org/session/libsignal/utilities/JsonUtil.java @@ -31,6 +31,10 @@ public static T fromJson(byte[] serialized, Class clazz) throws IOExcepti return fromJson(new String(serialized), clazz); } + public static T fromJson(ByteArraySlice serialized, Class clazz) throws IOException { + return objectMapper.readValue(serialized.getData(), serialized.getOffset(), serialized.getLen(), clazz); + } + public static T fromJson(String serialized, TypeReference typeReference) throws IOException { return objectMapper.readValue(serialized, typeReference); } diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/KeyHelper.java b/app/src/main/java/org/session/libsignal/utilities/KeyHelper.java similarity index 75% rename from libsignal/src/main/java/org/session/libsignal/utilities/KeyHelper.java rename to app/src/main/java/org/session/libsignal/utilities/KeyHelper.java index 0c807d1b92..f4fe56f332 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/KeyHelper.java +++ b/app/src/main/java/org/session/libsignal/utilities/KeyHelper.java @@ -28,12 +28,8 @@ private KeyHelper() {} * @return the generated registration ID. */ public static int generateRegistrationId(boolean extendedRange) { - try { - SecureRandom secureRandom = SecureRandom.getInstance("SHA1PRNG"); - if (extendedRange) return secureRandom.nextInt(Integer.MAX_VALUE - 1) + 1; - else return secureRandom.nextInt(16380) + 1; - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } + SecureRandom secureRandom = new SecureRandom(); + if (extendedRange) return secureRandom.nextInt(Integer.MAX_VALUE - 1) + 1; + else return secureRandom.nextInt(16380) + 1; } } diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/ListenableFuture.java b/app/src/main/java/org/session/libsignal/utilities/ListenableFuture.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/ListenableFuture.java rename to app/src/main/java/org/session/libsignal/utilities/ListenableFuture.java diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Log.java b/app/src/main/java/org/session/libsignal/utilities/Log.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/Log.java rename to app/src/main/java/org/session/libsignal/utilities/Log.java diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/NoExternalStorageException.java b/app/src/main/java/org/session/libsignal/utilities/NoExternalStorageException.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/NoExternalStorageException.java rename to app/src/main/java/org/session/libsignal/utilities/NoExternalStorageException.java diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Pair.java b/app/src/main/java/org/session/libsignal/utilities/Pair.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/Pair.java rename to app/src/main/java/org/session/libsignal/utilities/Pair.java diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/PrettifiedDescription.kt b/app/src/main/java/org/session/libsignal/utilities/PrettifiedDescription.kt similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/PrettifiedDescription.kt rename to app/src/main/java/org/session/libsignal/utilities/PrettifiedDescription.kt diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/ProfileAvatarData.java b/app/src/main/java/org/session/libsignal/utilities/ProfileAvatarData.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/ProfileAvatarData.java rename to app/src/main/java/org/session/libsignal/utilities/ProfileAvatarData.java diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt b/app/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt rename to app/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/PushAttachmentData.java b/app/src/main/java/org/session/libsignal/utilities/PushAttachmentData.java similarity index 80% rename from libsignal/src/main/java/org/session/libsignal/utilities/PushAttachmentData.java rename to app/src/main/java/org/session/libsignal/utilities/PushAttachmentData.java index b0a6cd54d3..19d83f08fa 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/PushAttachmentData.java +++ b/app/src/main/java/org/session/libsignal/utilities/PushAttachmentData.java @@ -6,7 +6,6 @@ package org.session.libsignal.utilities; -import org.session.libsignal.messages.SignalServiceAttachment.ProgressListener; import org.session.libsignal.streams.OutputStreamFactory; import java.io.InputStream; @@ -17,16 +16,14 @@ public class PushAttachmentData { private final InputStream data; private final long dataSize; private final OutputStreamFactory outputStreamFactory; - private final ProgressListener listener; public PushAttachmentData(String contentType, InputStream data, long dataSize, - OutputStreamFactory outputStreamFactory, ProgressListener listener) + OutputStreamFactory outputStreamFactory) { this.contentType = contentType; this.data = data; this.dataSize = dataSize; this.outputStreamFactory = outputStreamFactory; - this.listener = listener; } public String getContentType() { @@ -44,8 +41,4 @@ public long getDataSize() { public OutputStreamFactory getOutputStreamFactory() { return outputStreamFactory; } - - public ProgressListener getListener() { - return listener; - } } diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Retrying.kt b/app/src/main/java/org/session/libsignal/utilities/Retrying.kt similarity index 95% rename from libsignal/src/main/java/org/session/libsignal/utilities/Retrying.kt rename to app/src/main/java/org/session/libsignal/utilities/Retrying.kt index 68d1b852a3..4ee17f52a4 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Retrying.kt +++ b/app/src/main/java/org/session/libsignal/utilities/Retrying.kt @@ -43,6 +43,7 @@ suspend fun retryWithUniformInterval(maxRetryCount: Int = 3, retryIntervalMi } catch (e: NonRetryableException) { throw e } catch (e: Exception) { + Log.w("", "Exception while performing retryWithUniformInterval:", e) if (retryCount == maxRetryCount) { throw e } else { diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/SettableFuture.java b/app/src/main/java/org/session/libsignal/utilities/SettableFuture.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/SettableFuture.java rename to app/src/main/java/org/session/libsignal/utilities/SettableFuture.java diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/SignalServiceAddress.java b/app/src/main/java/org/session/libsignal/utilities/SignalServiceAddress.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/SignalServiceAddress.java rename to app/src/main/java/org/session/libsignal/utilities/SignalServiceAddress.java diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt b/app/src/main/java/org/session/libsignal/utilities/Snode.kt similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt rename to app/src/main/java/org/session/libsignal/utilities/Snode.kt diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt b/app/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt rename to app/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Trimming.kt b/app/src/main/java/org/session/libsignal/utilities/Trimming.kt similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/Trimming.kt rename to app/src/main/java/org/session/libsignal/utilities/Trimming.kt diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Util.java b/app/src/main/java/org/session/libsignal/utilities/Util.java similarity index 93% rename from libsignal/src/main/java/org/session/libsignal/utilities/Util.java rename to app/src/main/java/org/session/libsignal/utilities/Util.java index 3b3b7aa5e6..dceb94dbfe 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Util.java +++ b/app/src/main/java/org/session/libsignal/utilities/Util.java @@ -69,13 +69,9 @@ public static boolean isEmpty(String value) { } public static byte[] getSecretBytes(int size) { - try { - byte[] secret = new byte[size]; - SecureRandom.getInstance("SHA1PRNG").nextBytes(secret); - return secret; - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } + byte[] secret = new byte[size]; + new SecureRandom().nextBytes(secret); + return secret; } public static String readFully(InputStream in) throws IOException { diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Validation.kt b/app/src/main/java/org/session/libsignal/utilities/Validation.kt similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/Validation.kt rename to app/src/main/java/org/session/libsignal/utilities/Validation.kt diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/guava/Absent.java b/app/src/main/java/org/session/libsignal/utilities/guava/Absent.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/guava/Absent.java rename to app/src/main/java/org/session/libsignal/utilities/guava/Absent.java diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/guava/Function.java b/app/src/main/java/org/session/libsignal/utilities/guava/Function.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/guava/Function.java rename to app/src/main/java/org/session/libsignal/utilities/guava/Function.java diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/guava/Optional.java b/app/src/main/java/org/session/libsignal/utilities/guava/Optional.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/guava/Optional.java rename to app/src/main/java/org/session/libsignal/utilities/guava/Optional.java diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/guava/Preconditions.java b/app/src/main/java/org/session/libsignal/utilities/guava/Preconditions.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/guava/Preconditions.java rename to app/src/main/java/org/session/libsignal/utilities/guava/Preconditions.java diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/guava/Present.java b/app/src/main/java/org/session/libsignal/utilities/guava/Present.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/guava/Present.java rename to app/src/main/java/org/session/libsignal/utilities/guava/Present.java diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/guava/Supplier.java b/app/src/main/java/org/session/libsignal/utilities/guava/Supplier.java similarity index 100% rename from libsignal/src/main/java/org/session/libsignal/utilities/guava/Supplier.java rename to app/src/main/java/org/session/libsignal/utilities/guava/Supplier.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java deleted file mode 100644 index 31f7b5f1d8..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ /dev/null @@ -1,535 +0,0 @@ -/* Copyright (C) 2013 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 . - */ -package org.thoughtcrime.securesms; - -import static nl.komponents.kovenant.android.KovenantAndroid.startKovenant; -import static nl.komponents.kovenant.android.KovenantAndroid.stopKovenant; - -import android.annotation.SuppressLint; -import android.app.Application; -import android.app.KeyguardManager; -import android.content.Context; -import android.content.Intent; -import android.os.AsyncTask; -import android.os.Handler; -import android.os.HandlerThread; -import android.os.PowerManager; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; -import androidx.core.content.pm.ShortcutInfoCompat; -import androidx.core.content.pm.ShortcutManagerCompat; -import androidx.core.graphics.drawable.IconCompat; -import androidx.hilt.work.HiltWorkerFactory; -import androidx.lifecycle.DefaultLifecycleObserver; -import androidx.lifecycle.LifecycleOwner; -import androidx.lifecycle.ProcessLifecycleOwner; -import androidx.work.Configuration; - -import com.google.firebase.messaging.FirebaseMessaging; -import com.squareup.phrase.Phrase; - -import org.conscrypt.Conscrypt; -import org.session.libsession.database.MessageDataProvider; -import org.session.libsession.messaging.MessagingModuleConfiguration; -import org.session.libsession.messaging.groups.GroupManagerV2; -import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager; -import org.session.libsession.messaging.notifications.TokenFetcher; -import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; -import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2; -import org.session.libsession.messaging.sending_receiving.pollers.Poller; -import org.session.libsession.snode.SnodeClock; -import org.session.libsession.snode.SnodeModule; -import org.session.libsession.utilities.Device; -import org.session.libsession.utilities.Environment; -import org.session.libsession.utilities.NonTranslatableStringConstants; -import org.session.libsession.utilities.ProfilePictureUtilities; -import org.session.libsession.utilities.SSKEnvironment; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.Toaster; -import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.WindowDebouncer; -import org.session.libsignal.utilities.HTTP; -import org.session.libsignal.utilities.JsonUtil; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.ThreadUtils; -import org.signal.aesgcmprovider.AesGcmProvider; -import org.thoughtcrime.securesms.components.TypingStatusSender; -import org.thoughtcrime.securesms.configs.ConfigUploader; -import org.thoughtcrime.securesms.database.EmojiSearchDatabase; -import org.thoughtcrime.securesms.database.LastSentTimestampCache; -import org.thoughtcrime.securesms.database.LokiAPIDatabase; -import org.thoughtcrime.securesms.database.Storage; -import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; -import org.thoughtcrime.securesms.database.model.EmojiSearchData; -import org.thoughtcrime.securesms.debugmenu.DebugActivity; -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.ExpiredGroupManager; -import org.thoughtcrime.securesms.groups.OpenGroupManager; -import org.thoughtcrime.securesms.groups.handler.AdminStateSync; -import org.thoughtcrime.securesms.groups.handler.CleanupInvitationHandler; -import org.thoughtcrime.securesms.groups.handler.DestroyedGroupSync; -import org.thoughtcrime.securesms.groups.GroupPollerManager; -import org.thoughtcrime.securesms.groups.handler.RemoveGroupMemberHandler; -import org.thoughtcrime.securesms.home.HomeActivity; -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.BackgroundPollManager; -import org.thoughtcrime.securesms.notifications.BackgroundPollWorker; -import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.notifications.PushRegistrationHandler; -import org.thoughtcrime.securesms.providers.BlobProvider; -import org.thoughtcrime.securesms.service.ExpiringMessageManager; -import org.thoughtcrime.securesms.service.KeyCachingService; -import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager; -import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository; -import org.thoughtcrime.securesms.util.AppVisibilityManager; -import org.thoughtcrime.securesms.util.Broadcaster; -import org.thoughtcrime.securesms.util.VersionDataFetcher; -import org.thoughtcrime.securesms.webrtc.CallMessageProcessor; -import org.webrtc.PeerConnectionFactory; -import org.webrtc.PeerConnectionFactory.InitializationOptions; - -import java.io.IOException; -import java.io.InputStream; -import java.security.Security; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.Timer; -import java.util.concurrent.Executors; - -import javax.inject.Inject; -import javax.inject.Provider; - -import dagger.Lazy; -import dagger.hilt.EntryPoints; -import dagger.hilt.android.HiltAndroidApp; -import kotlin.Deprecated; -import kotlin.Unit; -import network.loki.messenger.BuildConfig; -import network.loki.messenger.R; -import network.loki.messenger.libsession_util.util.Logger; - -/** - * Will be called once when the TextSecure process is created. - *

- * We're using this as an insertion point to patch up the Android PRNG disaster, - * to initialize the job manager, and to check for GCM registration freshness. - * - * @author Moxie Marlinspike - */ -@HiltAndroidApp -public class ApplicationContext extends Application implements DefaultLifecycleObserver, Toaster, Configuration.Provider { - - public static final String PREFERENCES_NAME = "SecureSMS-Preferences"; - - private static final String TAG = ApplicationContext.class.getSimpleName(); - - public Poller poller = null; - public Broadcaster broadcaster = null; - private WindowDebouncer conversationListDebouncer; - private HandlerThread conversationListHandlerThread; - private Handler conversationListHandler; - private PersistentLogger persistentLogger; - - @Inject HiltWorkerFactory workerFactory; - @Inject LokiAPIDatabase lokiAPIDatabase; - @Inject public Storage storage; - @Inject Device device; - @Inject MessageDataProvider messageDataProvider; - @Inject TextSecurePreferences textSecurePreferences; - @Inject ConfigFactory configFactory; - @Inject LastSentTimestampCache lastSentTimestampCache; - @Inject VersionDataFetcher versionDataFetcher; - @Inject PushRegistrationHandler pushRegistrationHandler; - @Inject TokenFetcher tokenFetcher; - @Inject GroupManagerV2 groupManagerV2; - @Inject SSKEnvironment.ProfileManagerProtocol profileManager; - CallMessageProcessor callMessageProcessor; - MessagingModuleConfiguration messagingModuleConfiguration; - @Inject ConfigUploader configUploader; - @Inject AdminStateSync adminStateSync; - @Inject DestroyedGroupSync destroyedGroupSync; - @Inject RemoveGroupMemberHandler removeGroupMemberHandler; - @Inject SnodeClock snodeClock; - @Inject ExpiringMessageManager expiringMessageManager; - @Inject TypingStatusRepository typingStatusRepository; - @Inject TypingStatusSender typingStatusSender; - @Inject ReadReceiptManager readReceiptManager; - @Inject Lazy messageNotifierLazy; - @Inject LokiAPIDatabase apiDB; - @Inject EmojiSearchDatabase emojiSearchDb; - @Inject LegacyClosedGroupPollerV2 legacyClosedGroupPollerV2; - @Inject LegacyGroupDeprecationManager legacyGroupDeprecationManager; - @Inject CleanupInvitationHandler cleanupInvitationHandler; - @Inject BackgroundPollManager backgroundPollManager; // Exists here only to start upon app starts - @Inject AppVisibilityManager appVisibilityManager; // Exists here only to start upon app starts - @Inject GroupPollerManager groupPollerManager; // Exists here only to start upon app starts - @Inject ExpiredGroupManager expiredGroupManager; // Exists here only to start upon app starts - - public volatile boolean isAppVisible; - public String KEYGUARD_LOCK_TAG = NonTranslatableStringConstants.APP_NAME + ":KeyguardLock"; - public String WAKELOCK_TAG = NonTranslatableStringConstants.APP_NAME + ":WakeLock"; - - @Override - public Object getSystemService(String name) { - if (MessagingModuleConfiguration.MESSAGING_MODULE_SERVICE.equals(name)) { - return messagingModuleConfiguration; - } - return super.getSystemService(name); - } - - public static ApplicationContext getInstance(Context context) { - return (ApplicationContext) context.getApplicationContext(); - } - - @Deprecated(message = "Use proper DI to inject this component") - public TextSecurePreferences getPrefs() { - return EntryPoints.get(getApplicationContext(), AppComponent.class).getPrefs(); - } - - @Deprecated(message = "Use proper DI to inject this component") - public DatabaseComponent getDatabaseComponent() { - return EntryPoints.get(getApplicationContext(), DatabaseComponent.class); - } - - @Deprecated(message = "Use proper DI to inject this component") - public MessageNotifier getMessageNotifier() { - return messageNotifierLazy.get(); - } - - public Handler getConversationListNotificationHandler() { - if (this.conversationListHandlerThread == null) { - conversationListHandlerThread = new HandlerThread("ConversationListHandler"); - conversationListHandlerThread.start(); - } - if (this.conversationListHandler == null) { - conversationListHandler = new Handler(conversationListHandlerThread.getLooper()); - } - return conversationListHandler; - } - - public WindowDebouncer getConversationListDebouncer() { - if (conversationListDebouncer == null) { - conversationListDebouncer = new WindowDebouncer(1000, new Timer()); - } - return conversationListDebouncer; - } - - public PersistentLogger getPersistentLogger() { - return this.persistentLogger; - } - - @Override - public void toast(@StringRes int stringRes, int toastLength, @NonNull Map parameters) { - Phrase builder = Phrase.from(this, stringRes); - for (Map.Entry entry : parameters.entrySet()) { - builder.put(entry.getKey(), entry.getValue()); - } - Toast.makeText(this, builder.format(), toastLength).show(); - } - - @Override - public void toast(@NonNull CharSequence message, int toastLength) { - Toast.makeText(this, message, toastLength).show(); - } - - @Override - public void onCreate() { - TextSecurePreferences.setPushSuffix(BuildConfig.PUSH_KEY_SUFFIX); - - DatabaseModule.init(this); - MessagingModuleConfiguration.configure(this); - super.onCreate(); - - messagingModuleConfiguration = new MessagingModuleConfiguration( - this, - storage, - device, - messageDataProvider, - configFactory, - lastSentTimestampCache, - this, - tokenFetcher, - groupManagerV2, - snodeClock, - textSecurePreferences, - legacyClosedGroupPollerV2, - legacyGroupDeprecationManager - ); - callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage); - Log.i(TAG, "onCreate()"); - startKovenant(); - initializeSecurityProvider(); - initializeLogging(); - initializeCrashHandling(); - NotificationChannels.create(this); - ProcessLifecycleOwner.get().getLifecycle().addObserver(this); - AppContext.INSTANCE.configureKovenant(); - broadcaster = new Broadcaster(this); - boolean useTestNet = textSecurePreferences.getEnvironment() == Environment.TEST_NET; - SnodeModule.Companion.configure(apiDB, broadcaster, useTestNet); - SSKEnvironment.Companion.configure(typingStatusRepository, readReceiptManager, profileManager, getMessageNotifier(), expiringMessageManager); - initializeWebRtc(); - initializeBlobProvider(); - resubmitProfilePictureIfNeeded(); - loadEmojiSearchIndexIfNeeded(); - EmojiSource.refresh(); - - NetworkConstraint networkConstraint = new NetworkConstraint.Factory(this).create(); - HTTP.INSTANCE.setConnectedToNetwork(networkConstraint::isMet); - - snodeClock.start(); - pushRegistrationHandler.run(); - configUploader.start(); - removeGroupMemberHandler.start(); - destroyedGroupSync.start(); - adminStateSync.start(); - cleanupInvitationHandler.start(); - - // add our shortcut debug menu if we are not in a release build - if (BuildConfig.BUILD_TYPE != "release") { - // add the config settings shortcut - Intent intent = new Intent(this, DebugActivity.class); - intent.setAction(Intent.ACTION_VIEW); - - ShortcutInfoCompat shortcut = new ShortcutInfoCompat.Builder(this, "shortcut_debug_menu") - .setShortLabel("Debug Menu") - .setLongLabel("Debug Menu") - .setIcon(IconCompat.createWithResource(this, R.drawable.ic_settings)) - .setIntent(intent) - .build(); - - ShortcutManagerCompat.pushDynamicShortcut(this, shortcut); - } - } - - @NonNull - @Override - public Configuration getWorkManagerConfiguration() { - return new Configuration.Builder() - .setWorkerFactory(workerFactory) - .build(); - } - - @Override - public void onStart(@NonNull LifecycleOwner owner) { - isAppVisible = true; - 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() == null) { - return; - } - - startPollingIfNeeded(); - - ThreadUtils.queue(()->{ - OpenGroupManager.INSTANCE.startPolling(); - return Unit.INSTANCE; - }); - - // fetch last version data - versionDataFetcher.startTimedVersionCheck(); - } - - @Override - public void onStop(@NonNull LifecycleOwner owner) { - isAppVisible = false; - Log.i(TAG, "App is no longer visible."); - KeyCachingService.onAppBackgrounded(this); - getMessageNotifier().setVisibleThread(-1); - if (poller != null) { - poller.stopIfNeeded(); - } - legacyClosedGroupPollerV2.stopAll(); - versionDataFetcher.stopTimedVersionCheck(); - } - - @Override - public void onTerminate() { - stopKovenant(); // Loki - OpenGroupManager.INSTANCE.stopPolling(); - versionDataFetcher.stopTimedVersionCheck(); - super.onTerminate(); - } - - @Deprecated(message = "Use proper DI to inject this component") - public ExpiringMessageManager getExpiringMessageManager() { - return expiringMessageManager; - } - - @Deprecated(message = "Use proper DI to inject this component") - public TypingStatusRepository getTypingStatusRepository() { - return typingStatusRepository; - } - - @Deprecated(message = "Use proper DI to inject this component") - public TypingStatusSender getTypingStatusSender() { - return typingStatusSender; - } - - @Deprecated(message = "Use proper DI to inject this component") - public TextSecurePreferences getTextSecurePreferences() { - return textSecurePreferences; - } - - @Deprecated(message = "Use proper DI to inject this component") - public ReadReceiptManager getReadReceiptManager() { - return readReceiptManager; - } - - - public boolean isAppVisible() { - return isAppVisible; - } - - // Loki - - private void initializeSecurityProvider() { - try { - Class.forName("org.signal.aesgcmprovider.AesGcmCipher"); - } catch (ClassNotFoundException e) { - Log.e(TAG, "Failed to find AesGcmCipher class"); - throw new ProviderInitializationException(); - } - - int aesPosition = Security.insertProviderAt(new AesGcmProvider(), 1); - Log.i(TAG, "Installed AesGcmProvider: " + aesPosition); - - if (aesPosition < 0) { - Log.e(TAG, "Failed to install AesGcmProvider()"); - throw new ProviderInitializationException(); - } - - int conscryptPosition = Security.insertProviderAt(Conscrypt.newProvider(), 2); - Log.i(TAG, "Installed Conscrypt provider: " + conscryptPosition); - - if (conscryptPosition < 0) { - Log.w(TAG, "Did not install Conscrypt provider. May already be present."); - } - } - - private void initializeLogging() { - if (persistentLogger == null) { - persistentLogger = new PersistentLogger(this); - } - Log.initialize(new AndroidLogger(), persistentLogger); - Logger.initLogger(); - } - - private void initializeCrashHandling() { - final Thread.UncaughtExceptionHandler originalHandler = Thread.getDefaultUncaughtExceptionHandler(); - Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionLogger(originalHandler)); - } - - private void initializeWebRtc() { - try { - PeerConnectionFactory.initialize(InitializationOptions.builder(this).createInitializationOptions()); - } catch (UnsatisfiedLinkError e) { - Log.w(TAG, e); - } - } - - private void initializeBlobProvider() { - AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { - BlobProvider.getInstance().onSessionStart(this); - }); - } - - private static class ProviderInitializationException extends RuntimeException { } - private void setUpPollingIfNeeded() { - String userPublicKey = textSecurePreferences.getLocalNumber(); - if (userPublicKey == null) return; - if(poller == null) { - poller = new Poller(configFactory, storage, lokiAPIDatabase); - } - } - - public void startPollingIfNeeded() { - setUpPollingIfNeeded(); - if (poller != null) { - poller.startIfNeeded(); - } - legacyClosedGroupPollerV2.start(); - } - - public void retrieveUserProfile() { - setUpPollingIfNeeded(); - if (poller != null) { - poller.retrieveUserProfile(); - } - } - - private void resubmitProfilePictureIfNeeded() { - ProfilePictureUtilities.INSTANCE.resubmitProfilePictureIfNeeded(this); - } - - private void loadEmojiSearchIndexIfNeeded() { - Executors.newSingleThreadExecutor().execute(() -> { - if (emojiSearchDb.query("face", 1).isEmpty()) { - try (InputStream inputStream = getAssets().open("emoji/emoji_search_index.json")) { - List searchIndex = Arrays.asList(JsonUtil.fromJson(inputStream, EmojiSearchData[].class)); - emojiSearchDb.setSearchIndex(searchIndex); - } catch (IOException e) { - Log.e("Loki", "Failed to load emoji search index"); - } - } - }); - } - // endregion - - // Method to wake up the screen and dismiss the keyguard - public void wakeUpDeviceAndDismissKeyguardIfRequired() { - // Get the KeyguardManager and PowerManager - KeyguardManager keyguardManager = (KeyguardManager)getSystemService(Context.KEYGUARD_SERVICE); - PowerManager powerManager = (PowerManager)getSystemService(Context.POWER_SERVICE); - - // Check if the phone is locked & if the screen is awake - boolean isPhoneLocked = keyguardManager.isKeyguardLocked(); - boolean isScreenAwake = powerManager.isInteractive(); - - if (!isScreenAwake) { - PowerManager.WakeLock wakeLock = powerManager.newWakeLock( - PowerManager.FULL_WAKE_LOCK - | PowerManager.ACQUIRE_CAUSES_WAKEUP - | PowerManager.ON_AFTER_RELEASE, - WAKELOCK_TAG); - - // Acquire the wake lock to wake up the device - wakeLock.acquire(3000); - } - - // Dismiss the keyguard. - // Note: This will not bypass any app-level (Session) lock; only the device-level keyguard. - // TODO: When moving to a minimum Android API of 27, replace these deprecated calls with new APIs. - if (isPhoneLocked) { - KeyguardManager.KeyguardLock keyguardLock = keyguardManager.newKeyguardLock(KEYGUARD_LOCK_TAG); - keyguardLock.disableKeyguard(); - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt new file mode 100644 index 0000000000..892fb0a630 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt @@ -0,0 +1,281 @@ +/* Copyright (C) 2013 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 . + */ +package org.thoughtcrime.securesms + +import android.app.Application +import android.content.Context +import android.content.Intent +import android.os.AsyncTask +import android.os.Handler +import android.os.HandlerThread +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.hilt.work.HiltWorkerFactory +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.work.Configuration +import dagger.Lazy +import dagger.hilt.EntryPoints +import dagger.hilt.android.HiltAndroidApp +import network.loki.messenger.BuildConfig +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.LogLevel +import network.loki.messenger.libsession_util.util.Logger +import nl.komponents.kovenant.android.startKovenant +import nl.komponents.kovenant.android.stopKovenant +import org.conscrypt.Conscrypt +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.configure +import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier +import org.session.libsession.snode.SnodeModule +import org.session.libsession.utilities.ProfilePictureUtilities.resubmitProfilePictureIfNeeded +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences.Companion.pushSuffix +import org.session.libsession.utilities.WindowDebouncer +import org.session.libsignal.utilities.HTTP.isConnectedToNetwork +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.AppContext.configureKovenant +import org.thoughtcrime.securesms.debugmenu.DebugActivity +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.dependencies.DatabaseModule.init +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponents +import org.thoughtcrime.securesms.emoji.EmojiSource.Companion.refresh +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.migration.DatabaseMigrationManager +import org.thoughtcrime.securesms.notifications.NotificationChannels +import org.thoughtcrime.securesms.providers.BlobUtils +import org.thoughtcrime.securesms.service.KeyCachingService +import org.webrtc.PeerConnectionFactory +import org.webrtc.PeerConnectionFactory.InitializationOptions +import java.security.Security +import java.util.Timer +import javax.inject.Inject +import kotlin.concurrent.Volatile + +/** + * Will be called once when the TextSecure process is created. + * + * + * We're using this as an insertion point to patch up the Android PRNG disaster, + * to initialize the job manager, and to check for GCM registration freshness. + * + * @author Moxie Marlinspike + */ +@HiltAndroidApp +class ApplicationContext : Application(), DefaultLifecycleObserver, Configuration.Provider { + var conversationListDebouncer: WindowDebouncer? = null + get() { + if (field == null) { + field = WindowDebouncer(1000, Timer()) + } + return field + } + private set + private var conversationListHandlerThread: HandlerThread? = null + private var conversationListHandler: Handler? = null + + @Inject lateinit var messagingModuleConfiguration: Lazy + @Inject lateinit var workerFactory: Lazy + @Inject lateinit var snodeModule: Lazy + @Inject lateinit var sskEnvironment: Lazy + + @Inject lateinit var startupComponents: Lazy + @Inject lateinit var persistentLogger: Lazy + @Inject lateinit var textSecurePreferences: Lazy + @Inject lateinit var migrationManager: Lazy + + @Volatile + var isAppVisible: Boolean = false + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory.get()) + .build() + + override fun getSystemService(name: String): Any? { + if (MessagingModuleConfiguration.MESSAGING_MODULE_SERVICE == name) { + return messagingModuleConfiguration.get() + } + + return super.getSystemService(name) + } + + @get:Deprecated(message = "Use proper DI to inject this component") + val databaseComponent: DatabaseComponent + get() = EntryPoints.get( + applicationContext, + DatabaseComponent::class.java + ) + + @get:Deprecated(message = "Use proper DI to inject this component") + @Inject lateinit var messageNotifier: MessageNotifier + + val conversationListNotificationHandler: Handler + get() { + if (this.conversationListHandlerThread == null) { + conversationListHandlerThread = HandlerThread("ConversationListHandler") + conversationListHandlerThread!!.start() + } + if (this.conversationListHandler == null) { + conversationListHandler = + Handler(conversationListHandlerThread!!.looper) + } + return conversationListHandler!! + } + + + override fun onCreate() { + pushSuffix = BuildConfig.PUSH_KEY_SUFFIX + + init(this) + configure(this) + super.onCreate() + + startKovenant() + initializeSecurityProvider() + initializeLogging() + initializeCrashHandling() + NotificationChannels.create(this) + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + configureKovenant() + SnodeModule.sharedLazy = snodeModule + SSKEnvironment.sharedLazy = sskEnvironment + + initializeWebRtc() + initializeBlobProvider() + resubmitProfilePictureIfNeeded() + refresh() + + val networkConstraint = NetworkConstraint.Factory(this).create() + isConnectedToNetwork = { networkConstraint.isMet } + + // add our shortcut debug menu if we are not in a release build + if (BuildConfig.BUILD_TYPE != "release") { + // add the config settings shortcut + val intent = Intent(this, DebugActivity::class.java) + intent.setAction(Intent.ACTION_VIEW) + + val shortcut = ShortcutInfoCompat.Builder(this, "shortcut_debug_menu") + .setShortLabel("Debug Menu") + .setLongLabel("Debug Menu") + .setIcon(IconCompat.createWithResource(this, R.drawable.ic_settings)) + .setIntent(intent) + .build() + + ShortcutManagerCompat.pushDynamicShortcut(this, shortcut) + } + + + // Once we have done initialisation, access the lazy dependencies so we make sure + // they are initialised. + workerFactory.get() + + startupComponents.get() + .onPostAppStarted() + } + + override fun onStart(owner: LifecycleOwner) { + isAppVisible = true + Log.i(TAG, "App is now visible.") + KeyCachingService.onAppForegrounded(this) + } + + override fun onStop(owner: LifecycleOwner) { + isAppVisible = false + Log.i(TAG, "App is no longer visible.") + KeyCachingService.onAppBackgrounded(this) + messageNotifier.setVisibleThread(-1) + } + + override fun onTerminate() { + stopKovenant() // Loki + super.onTerminate() + } + + + // Loki + private fun initializeSecurityProvider() { + val conscryptPosition = Security.insertProviderAt(Conscrypt.newProvider(), 0) + Log.i(TAG, "Installed Conscrypt provider: $conscryptPosition") + + if (conscryptPosition < 0) { + Log.w(TAG, "Did not install Conscrypt provider. May already be present.") + } + } + + private fun initializeLogging() { + Log.initialize(AndroidLogger(), persistentLogger.get()) + Logger.addLogger(object : Logger { + private val tag = "LibSession" + + override fun log(message: String, category: String, level: LogLevel) { + when (level) { + Logger.LOG_LEVEL_INFO -> Log.i(tag, "$category: $message") + Logger.LOG_LEVEL_DEBUG -> Log.d(tag, "$category: $message") + Logger.LOG_LEVEL_WARN -> Log.w(tag, "$category: $message") + Logger.LOG_LEVEL_ERROR -> Log.e(tag, "$category: $message") + Logger.LOG_LEVEL_CRITICAL -> Log.wtf(tag, "$category: $message") + Logger.LOG_LEVEL_OFF -> {} + else -> Log.v(tag, "$category: $message") + } + } + }) + } + + private fun initializeCrashHandling() { + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler(UncaughtExceptionLogger(originalHandler!!)) + } + + private fun initializeWebRtc() { + try { + PeerConnectionFactory.initialize( + InitializationOptions.builder(this).createInitializationOptions() + ) + } catch (e: UnsatisfiedLinkError) { + Log.w(TAG, e) + } + } + + private fun initializeBlobProvider() { + AsyncTask.THREAD_POOL_EXECUTOR.execute { + BlobUtils.getInstance().onSessionStart(this) + } + } + + private fun resubmitProfilePictureIfNeeded() { + resubmitProfilePictureIfNeeded(this) + } + + // endregion + + companion object { + const val PREFERENCES_NAME: String = "SecureSMS-Preferences" + + private val TAG: String = ApplicationContext::class.java.simpleName + + @JvmStatic + fun getInstance(context: Context): ApplicationContext { + return context.applicationContext as ApplicationContext + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java deleted file mode 100644 index 4e385cfe2b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java +++ /dev/null @@ -1,133 +0,0 @@ -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; -import android.content.Context; -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.os.Bundle; -import android.view.WindowManager; - -import androidx.annotation.Nullable; -import androidx.annotation.StyleRes; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; - -import org.session.libsession.utilities.TextSecurePreferences; -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; - -import network.loki.messenger.R; - -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 - 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()) { - - // 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 // ..otherwise just return their selected theme. - { - return userSelectedTheme; - } - } - - @StyleRes @Nullable - private Integer getAccentTheme() { - if (!getPreferences().hasPreference(SELECTED_ACCENT_COLOR)) return null; - ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences()); - return themeState.getAccentStyle(); - } - - @Override - public Resources.Theme getTheme() { - if (modifiedTheme != null) { - return modifiedTheme; - } - - // New themes - modifiedTheme = super.getTheme(); - modifiedTheme.applyStyle(getDesiredTheme(), true); - Integer accentTheme = getAccentTheme(); - if (accentTheme != null) { - modifiedTheme.applyStyle(accentTheme, true); - } - currentThemeState = ActivityUtilitiesKt.themeState(getPreferences()); - return modifiedTheme; - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setHomeButtonEnabled(true); - } - } - - @Override - protected void onResume() { - super.onResume(); - initializeScreenshotSecurity(true); - String name = getResources().getString(R.string.app_name); - Bitmap icon = BitmapFactory.decodeResource(getResources(), R.drawable.ic_launcher_foreground); - int color = getResources().getColor(R.color.app_icon_background); - setTaskDescription(new ActivityManager.TaskDescription(name, icon, color)); - 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 - protected void onPause() { - super.onPause(); - initializeScreenshotSecurity(false); - } - - @Override - public boolean onSupportNavigateUp() { - if (super.onSupportNavigateUp()) return true; - - onBackPressed(); - return true; - } - - private void initializeScreenshotSecurity(boolean isResume) { - if (!isResume) { - getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); - } else { - getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt new file mode 100644 index 0000000000..2a880b273f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.kt @@ -0,0 +1,169 @@ +package org.thoughtcrime.securesms + +import android.app.ActivityManager.TaskDescription +import android.content.res.Resources +import android.graphics.BitmapFactory +import android.graphics.Color +import android.os.Bundle +import android.view.View +import android.view.WindowManager +import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge +import androidx.annotation.StyleRes +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewGroupCompat +import network.loki.messenger.R +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.ThemeUtil +import org.thoughtcrime.securesms.util.ThemeState +import org.thoughtcrime.securesms.util.UiModeUtilities.isDayUiMode +import org.thoughtcrime.securesms.util.applySafeInsetsPaddings +import org.thoughtcrime.securesms.util.themeState + +private val DefaultLightScrim = Color.argb(0xe6, 0xFF, 0xFF, 0xFF) +private val DefaultDarkScrim = Color.argb(0x80, 0x1b, 0x1b, 0x1b) + +abstract class BaseActionBarActivity : AppCompatActivity() { + private var currentThemeState: ThemeState? = null + + private var modifiedTheme: Resources.Theme? = null + + // This can not be dep injected as it is required very early during activity creation + private val preferences: TextSecurePreferences + get() = (applicationContext as ApplicationContext).textSecurePreferences.get() + + // Whether to apply default window insets to the decor view + open val applyDefaultWindowInsets: Boolean + get() = true + + @get:StyleRes + private val desiredTheme: Int + get() { + val themeState = preferences.themeState() + val userSelectedTheme = themeState.theme + + // If the user has configured Session to follow the system light/dark theme mode then do so.. + if (themeState.followSystem) { + // Use light or dark versions of the user's theme based on light-mode / dark-mode settings + + val isDayUi = isDayUiMode(this) + return if (userSelectedTheme == R.style.Ocean_Dark || userSelectedTheme == R.style.Ocean_Light) { + if (isDayUi) R.style.Ocean_Light else R.style.Ocean_Dark + } else { + if (isDayUi) R.style.Classic_Light else R.style.Classic_Dark + } + } else // ..otherwise just return their selected theme. + { + return userSelectedTheme + } + } + + @get:StyleRes + private val accentTheme: Int? + get() { + if (!preferences.hasPreference(TextSecurePreferences.SELECTED_ACCENT_COLOR)) return null + val themeState = preferences.themeState() + return themeState.accentStyle + } + + // Whether we should apply scrim automatically to the navigation bar + // If set to true, the system will detect if a scrim is needed based on the content + // If set to false, no scrim will be applied + open val applyAutoScrimForNavigationBar: Boolean + get() = true + + override fun getTheme(): Resources.Theme { + if (modifiedTheme != null) { + return modifiedTheme!! + } + + // New themes + modifiedTheme = super.getTheme() + modifiedTheme!!.applyStyle(desiredTheme, true) + val accentTheme = accentTheme + if (accentTheme != null) { + modifiedTheme!!.applyStyle(accentTheme, true) + } + currentThemeState = preferences.themeState() + return modifiedTheme!! + } + + override fun onCreate(savedInstanceState: Bundle?) { + val detectDarkMode = { _: Resources -> ThemeUtil.isDarkTheme(this) } + + // The code above does this: + // If applyAutoScrimForNavigationBar is set to true, we use auto system bar style and the + // system will detect if it needs to apply a scrim so that a contrast is enforced. The end result + // could be that the scrim is present or not, depending on the color on the screen. + // However, if applyAutoScrimForNavigationBar is set to false, we use the specific + // SystemBarStyle where the contrast isn't enforced. This means that the scrim is always NOT applied. + val navigationBarStyle = when { + applyAutoScrimForNavigationBar -> { + SystemBarStyle.auto(DefaultLightScrim, DefaultDarkScrim, detectDarkMode) + } + detectDarkMode(resources) -> SystemBarStyle.dark(Color.TRANSPARENT) + else -> SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT) + } + + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto(DefaultLightScrim, DefaultDarkScrim, detectDarkMode), + navigationBarStyle = navigationBarStyle + ) + super.onCreate(savedInstanceState) + + val actionBar = supportActionBar + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true) + actionBar.setHomeButtonEnabled(true) + } + + + // Apply a fix that views not getting inset dispatch when the inset was consumed by siblings on API <= 29 + // See: https://developer.android.com/develop/ui/views/layout/edge-to-edge#backward-compatible-dispatching + ViewGroupCompat.installCompatInsetsDispatch(window.decorView) + + if (applyDefaultWindowInsets) { + findViewById(android.R.id.content)?.applySafeInsetsPaddings() + } + } + + override fun onResume() { + super.onResume() + + initializeScreenshotSecurity(true) + val name = resources.getString(R.string.app_name) + val icon = BitmapFactory.decodeResource(resources, R.drawable.ic_launcher_foreground) + val color = resources.getColor(R.color.app_icon_background) + setTaskDescription(TaskDescription(name, icon, color)) + if (currentThemeState != preferences.themeState()) { + recreate() + } + + } + + override fun onPause() { + super.onPause() + initializeScreenshotSecurity(false) + } + + override fun onSupportNavigateUp(): Boolean { + if (super.onSupportNavigateUp()) return true + + onBackPressed() + return true + } + + private fun initializeScreenshotSecurity(isResume: Boolean) { + if (!isResume) { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + + companion object { + private val TAG: String = BaseActionBarActivity::class.java.simpleName + + private const val MIGRATION_DIALOG_TAG = "migration_dialog" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/FullComposeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/FullComposeActivity.kt new file mode 100644 index 0000000000..fd21f248b2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/FullComposeActivity.kt @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms + +import android.os.Bundle +import android.view.Window +import android.view.WindowManager +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.Composable +import org.thoughtcrime.securesms.ui.setComposeContent + +/** + * Base class for activities that use Compose UI for their full content. + * + * It fine-tunes options so that Compose can take over the entire screen. + * + * Note: you should use [FullComposeScreenLockActivity] by default, who handles the authentication + * and routing logic. This class is only for activities that do not need these logic which should + * be rare. + */ +abstract class FullComposeActivity : BaseActionBarActivity() { + @Composable + abstract fun ComposeContent() + + final override val applyDefaultWindowInsets: Boolean + get() = false + + override fun onCreate(savedInstanceState: Bundle?) { + applyCommonPropertiesForCompose() + + super.onCreate(savedInstanceState) + + setComposeContent { + ComposeContent() + } + } + + companion object { + /** + * Apply some common properties for activities that display compose as full content. + */ + fun AppCompatActivity.applyCommonPropertiesForCompose() { + // Disable action bar for compose + supportRequestWindowFeature(Window.FEATURE_NO_TITLE) + + // Deprecated note: this flag is set for older devices that do not support IME insets + // For recent Android versions this simply doesn't work and you have to do the IME insets + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/FullComposeScreenLockActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/FullComposeScreenLockActivity.kt new file mode 100644 index 0000000000..2d04cfbe01 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/FullComposeScreenLockActivity.kt @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms + +import android.os.Bundle +import android.view.Window +import android.view.WindowManager +import androidx.compose.runtime.Composable +import org.thoughtcrime.securesms.FullComposeActivity.Companion.applyCommonPropertiesForCompose +import org.thoughtcrime.securesms.ui.setComposeContent + +/** + * Base class for activities that use Compose UI for their full content. + * + * It fine-tunes options so that Compose can take over the entire screen. + */ +abstract class FullComposeScreenLockActivity : ScreenLockActionBarActivity() { + @Composable + abstract fun ComposeContent() + + final override val applyDefaultWindowInsets: Boolean + get() = false + + override fun onCreate(savedInstanceState: Bundle?) { + applyCommonPropertiesForCompose() + + super.onCreate(savedInstanceState) + } + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + super.onCreate(savedInstanceState, ready) + + setComposeContent { + ComposeContent() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt new file mode 100644 index 0000000000..bb8daa7705 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms + + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.CTAFeature +import org.thoughtcrime.securesms.ui.DialogButtonData +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.LongMessageProCTA +import org.thoughtcrime.securesms.ui.SimpleSessionProCTA +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InputBarDialogs( + inputBarDialogsState: InputbarViewModel.InputBarDialogsState, + sendCommand: (InputbarViewModel.Commands) -> Unit +){ + SessionMaterialTheme { + // Simple dialogs + if (inputBarDialogsState.showSimpleDialog != null) { + val buttons = mutableListOf() + if(inputBarDialogsState.showSimpleDialog.positiveText != null) { + buttons.add( + DialogButtonData( + text = GetString(inputBarDialogsState.showSimpleDialog.positiveText), + color = if (inputBarDialogsState.showSimpleDialog.positiveStyleDanger) LocalColors.current.danger + else LocalColors.current.text, + qaTag = inputBarDialogsState.showSimpleDialog.positiveQaTag, + onClick = inputBarDialogsState.showSimpleDialog.onPositive + ) + ) + } + if(inputBarDialogsState.showSimpleDialog.negativeText != null){ + buttons.add( + DialogButtonData( + text = GetString(inputBarDialogsState.showSimpleDialog.negativeText), + qaTag = inputBarDialogsState.showSimpleDialog.negativeQaTag, + onClick = inputBarDialogsState.showSimpleDialog.onNegative + ) + ) + } + + AlertDialog( + onDismissRequest = { + // hide dialog + sendCommand(InputbarViewModel.Commands.HideSimpleDialog) + }, + title = annotatedStringResource(inputBarDialogsState.showSimpleDialog.title), + text = annotatedStringResource(inputBarDialogsState.showSimpleDialog.message), + showCloseButton = inputBarDialogsState.showSimpleDialog.showXIcon, + buttons = buttons + ) + } + + // Pro CTA + if (inputBarDialogsState.sessionProCharLimitCTA) { + LongMessageProCTA( + onDismissRequest = {sendCommand(InputbarViewModel.Commands.HideSessionProCTA)} + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt new file mode 100644 index 0000000000..8e38d7b685 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt @@ -0,0 +1,199 @@ +package org.thoughtcrime.securesms + +import android.app.Application +import androidx.lifecycle.ViewModel +import com.squareup.phrase.Phrase +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.LIMIT_KEY +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.ui.SimpleDialogData +import org.thoughtcrime.securesms.util.NumberUtil + +// the amount of character left at which point we should show an indicator +private const val CHARACTER_LIMIT_THRESHOLD = 200 + +abstract class InputbarViewModel( + private val application: Application, + private val proStatusManager: ProStatusManager +): ViewModel() { + protected val _inputBarState = MutableStateFlow(InputBarState()) + val inputBarState: StateFlow get() = _inputBarState + + private val _inputBarStateDialogsState = MutableStateFlow(InputBarDialogsState()) + val inputBarStateDialogsState: StateFlow = _inputBarStateDialogsState + + fun onTextChanged(text: CharSequence) { + // check the character limit + val maxChars = proStatusManager.getCharacterLimit() + val charsLeft = maxChars - text.length + + // update the char limit state based on characters left + val charLimitState = if(charsLeft <= CHARACTER_LIMIT_THRESHOLD){ + InputBarCharLimitState( + count = charsLeft, + countFormatted = NumberUtil.getFormattedNumber(charsLeft.toLong()), + danger = charsLeft < 0, + showProBadge = proStatusManager.isPostPro() && !proStatusManager.isCurrentUserPro() // only show the badge for non pro users POST pro launch + ) + } else { + null + } + + _inputBarState.update { it.copy(charLimitState = charLimitState) } + } + + fun validateMessageLength(): Boolean { + // the message is too long if we have a negative char left in the input state + val charsLeft = _inputBarState.value.charLimitState?.count ?: 0 + return if(charsLeft < 0){ + // the user is trying to send a message that is too long - we should display a dialog + // we currently have different logic for PRE and POST Pro launch + // which we can remove once Pro is out - currently we can switch this fro the debug menu + if(!proStatusManager.isPostPro() || proStatusManager.isCurrentUserPro()){ + showMessageTooLongSendDialog() + } else { + showSessionProCTA() + } + + false + } else { + true + } + } + + fun onCharLimitTapped(){ + // we currently have different logic for PRE and POST Pro launch + // which we can remove once Pro is out - currently we can switch this fro the debug menu + if(!proStatusManager.isPostPro() || proStatusManager.isCurrentUserPro()){ + handleCharLimitTappedForProUser() + } else { + handleCharLimitTappedForRegularUser() + } + } + + private fun handleCharLimitTappedForProUser(){ + if((_inputBarState.value.charLimitState?.count ?: 0) < 0){ + showMessageTooLongDialog() + } else { + showMessageLengthDialog() + } + } + + private fun handleCharLimitTappedForRegularUser(){ + showSessionProCTA() + } + + fun showSessionProCTA(){ + _inputBarStateDialogsState.update { + it.copy(sessionProCharLimitCTA = true) + } + } + + fun showMessageLengthDialog(){ + _inputBarStateDialogsState.update { + val charsLeft = _inputBarState.value.charLimitState?.count ?: 0 + it.copy( + showSimpleDialog = SimpleDialogData( + title = application.getString(R.string.modalMessageCharacterDisplayTitle), + message = application.resources.getQuantityString( + R.plurals.modalMessageCharacterDisplayDescription, + charsLeft, // quantity for plural + proStatusManager.getCharacterLimit(), // 1st arg: total character limit + charsLeft, // 2nd arg: chars left + ), + positiveStyleDanger = false, + positiveText = application.getString(R.string.okay), + onPositive = ::hideSimpleDialog + + ) + ) + } + } + + fun showMessageTooLongDialog(){ + _inputBarStateDialogsState.update { + it.copy( + showSimpleDialog = SimpleDialogData( + title = application.getString(R.string.modalMessageTooLongTitle), + message = Phrase.from(application.getString(R.string.modalMessageCharacterTooLongDescription)) + .put(LIMIT_KEY, proStatusManager.getCharacterLimit()) + .format(), + positiveStyleDanger = false, + positiveText = application.getString(R.string.okay), + onPositive = ::hideSimpleDialog + ) + ) + } + } + + fun showMessageTooLongSendDialog(){ + _inputBarStateDialogsState.update { + it.copy( + showSimpleDialog = SimpleDialogData( + title = application.getString(R.string.modalMessageTooLongTitle), + message = Phrase.from(application.getString(R.string.modalMessageTooLongDescription)) + .put(LIMIT_KEY, proStatusManager.getCharacterLimit()) + .format(), + positiveStyleDanger = false, + positiveText = application.getString(R.string.okay), + onPositive = ::hideSimpleDialog + ) + ) + } + } + + private fun hideSimpleDialog(){ + _inputBarStateDialogsState.update { + it.copy(showSimpleDialog = null) + } + } + + fun onInputBarCommand(command: Commands) { + when (command) { + is Commands.HideSimpleDialog -> { + hideSimpleDialog() + } + + is Commands.HideSessionProCTA -> { + _inputBarStateDialogsState.update { + it.copy(sessionProCharLimitCTA = false) + } + } + } + } + + data class InputBarCharLimitState( + val count: Int, + val countFormatted: String, + val danger: Boolean, + val showProBadge: Boolean + ) + + sealed interface InputBarContentState { + data object Hidden : InputBarContentState + data object Visible : InputBarContentState + data class Disabled(val text: String, val onClick: (() -> Unit)? = null) : InputBarContentState + } + + data class InputBarState( + val contentState: InputBarContentState = InputBarContentState.Visible, + // Note: These input media controls are with regard to whether the user can attach multimedia files + // or record voice messages to be sent to a recipient - they are NOT things like video or audio + // playback controls. + val enableAttachMediaControls: Boolean = true, + val charLimitState: InputBarCharLimitState? = null, + ) + + data class InputBarDialogsState( + val showSimpleDialog: SimpleDialogData? = null, + val sessionProCharLimitCTA: Boolean = false + ) + + sealed interface Commands { + data object HideSimpleDialog : Commands + data object HideSessionProCTA : Commands + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java deleted file mode 100644 index 731f3ec75e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ /dev/null @@ -1,806 +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 . - */ -package org.thoughtcrime.securesms; - -import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; - -import android.annotation.SuppressLint; -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.VERSION; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.view.GestureDetector; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.Window; -import android.view.WindowInsets; -import android.view.WindowInsetsController; -import android.widget.FrameLayout; -import android.widget.TextView; -import android.widget.Toast; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.core.util.Pair; -import androidx.lifecycle.ViewModelProvider; -import androidx.loader.app.LoaderManager; -import androidx.loader.content.Loader; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewpager.widget.PagerAdapter; -import androidx.viewpager.widget.ViewPager; -import com.bumptech.glide.Glide; -import com.bumptech.glide.RequestManager; -import com.squareup.phrase.Phrase; -import java.io.IOException; -import java.util.Locale; -import java.util.WeakHashMap; - -import dagger.hilt.android.AndroidEntryPoint; -import kotlin.Unit; -import network.loki.messenger.R; - -import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager; -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; -import org.session.libsession.utilities.recipients.RecipientModifiedListener; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.components.MediaView; -import org.thoughtcrime.securesms.components.dialogs.DeleteMediaPreviewDialog; -import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord; -import org.thoughtcrime.securesms.database.loaders.PagingMediaLoader; -import org.thoughtcrime.securesms.database.model.MmsMessageRecord; -import org.thoughtcrime.securesms.media.MediaOverviewActivity; -import org.thoughtcrime.securesms.mediapreview.MediaPreviewViewModel; -import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter; -import org.thoughtcrime.securesms.mms.Slide; -import org.thoughtcrime.securesms.permissions.Permissions; -import org.thoughtcrime.securesms.util.AttachmentUtil; -import org.thoughtcrime.securesms.util.DateUtils; -import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment; -import org.thoughtcrime.securesms.util.SaveAttachmentTask; - -import javax.inject.Inject; - -/** - * Activity for displaying media attachments in-app - */ -@AndroidEntryPoint -public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity implements RecipientModifiedListener, - LoaderManager.LoaderCallbacks>, - MediaRailAdapter.RailItemListener -{ - - private final static String TAG = MediaPreviewActivity.class.getSimpleName(); - - private static final int UI_ANIMATION_DELAY = 300; - - public static final String ADDRESS_EXTRA = "address"; - public static final String DATE_EXTRA = "date"; - public static final String SIZE_EXTRA = "size"; - public static final String CAPTION_EXTRA = "caption"; - public static final String OUTGOING_EXTRA = "outgoing"; - public static final String LEFT_IS_RECENT_EXTRA = "left_is_recent"; - - private View rootContainer; - private ViewPager mediaPager; - private View detailsContainer; - private TextView caption; - private View captionContainer; - private RecyclerView albumRail; - private MediaRailAdapter albumRailAdapter; - private ViewGroup playbackControlsContainer; - private Uri initialMediaUri; - private String initialMediaType; - private long initialMediaSize; - private String initialCaption; - private Recipient conversationRecipient; - private boolean leftIsRecent; - private GestureDetector clickDetector; - private MediaPreviewViewModel viewModel; - private ViewPagerListener viewPagerListener; - - @Inject - LegacyGroupDeprecationManager deprecationManager; - - private int restartItem = -1; - - private boolean isFullscreen = false; - private final Handler hideHandler = new Handler(Looper.myLooper()); - private final Runnable showRunnable = () -> { - getSupportActionBar().show(); - }; - private final Runnable hideRunnable = () -> { - if (VERSION.SDK_INT >= 30) { - rootContainer.getWindowInsetsController().hide(WindowInsets.Type.statusBars() | WindowInsets.Type.navigationBars()); - rootContainer.getWindowInsetsController().setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); - } else { - rootContainer.setSystemUiVisibility( - View.SYSTEM_UI_FLAG_LOW_PROFILE | - View.SYSTEM_UI_FLAG_FULLSCREEN | - View.SYSTEM_UI_FLAG_LAYOUT_STABLE | - View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | - View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | - 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; - if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) { - previewIntent = new Intent(context, MediaPreviewActivity.class); - previewIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - .setDataAndType(slide.getUri(), slide.getContentType()) - .putExtra(ADDRESS_EXTRA, threadRecipient.getAddress()) - .putExtra(OUTGOING_EXTRA, mms.isOutgoing()) - .putExtra(DATE_EXTRA, mms.getTimestamp()) - .putExtra(SIZE_EXTRA, slide.asAttachment().getSize()) - .putExtra(CAPTION_EXTRA, slide.getCaption().orNull()) - .putExtra(LEFT_IS_RECENT_EXTRA, false); - } - return previewIntent; - } - - - @SuppressWarnings("ConstantConditions") - @Override - protected void onCreate(Bundle bundle, boolean ready) { - viewModel = new ViewModelProvider(this).get(MediaPreviewViewModel.class); - - setContentView(R.layout.media_preview_activity); - - initializeViews(); - initializeResources(); - initializeObservers(); - } - - private void toggleFullscreen() { - if (isFullscreen) { - exitFullscreen(); - } else { - enterFullscreen(); - } - } - - private void enterFullscreen() { - getSupportActionBar().hide(); - isFullscreen = true; - hideHandler.removeCallbacks(showRunnable); - hideHandler.postDelayed(hideRunnable, UI_ANIMATION_DELAY); - } - - private void exitFullscreen() { - if (Build.VERSION.SDK_INT >= 30) { - rootContainer.getWindowInsetsController().show(WindowInsets.Type.statusBars() | WindowInsets.Type.navigationBars()); - } else { - rootContainer.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION); - } - isFullscreen = false; - hideHandler.removeCallbacks(hideRunnable); - hideHandler.postDelayed(showRunnable, UI_ANIMATION_DELAY); - } - - @Override - public boolean dispatchTouchEvent(MotionEvent ev) { - clickDetector.onTouchEvent(ev); - return super.dispatchTouchEvent(ev); - } - - @SuppressLint("MissingSuperCall") - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); - } - - @Override - public void onModified(Recipient recipient) { - Util.runOnMain(this::updateActionBar); - } - - @Override - public void onRailItemClicked(int distanceFromActive) { - mediaPager.setCurrentItem(mediaPager.getCurrentItem() + distanceFromActive); - } - - @Override - public void onRailItemDeleteClicked(int distanceFromActive) { - throw new UnsupportedOperationException("Callback unsupported."); - } - - @SuppressWarnings("ConstantConditions") - private void updateActionBar() { - MediaItem mediaItem = getCurrentMediaItem(); - - if (mediaItem != null) { - CharSequence relativeTimeSpan; - - if (mediaItem.date > 0) { - relativeTimeSpan = DateUtils.INSTANCE.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), mediaItem.date); - } else { - relativeTimeSpan = getString(R.string.draft); - } - - if (mediaItem.outgoing) getSupportActionBar().setTitle(getString(R.string.you)); - else if (mediaItem.recipient != null) getSupportActionBar().setTitle(mediaItem.recipient.toShortString()); - else getSupportActionBar().setTitle(""); - - getSupportActionBar().setSubtitle(relativeTimeSpan); - } - } - - @Override - public void onResume() { - super.onResume(); - initializeMedia(); - } - - @Override - public void onPause() { - super.onPause(); - restartItem = cleanupMedia(); - } - - @Override - protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - setIntent(intent); - initializeResources(); - } - - private void initializeViews() { - rootContainer = findViewById(R.id.media_preview_root); - mediaPager = findViewById(R.id.media_pager); - mediaPager.setOffscreenPageLimit(1); - - albumRail = findViewById(R.id.media_preview_album_rail); - albumRailAdapter = new MediaRailAdapter(Glide.with(this), this, false); - - albumRail.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)); - albumRail.setAdapter(albumRailAdapter); - - detailsContainer = findViewById(R.id.media_preview_details_container); - caption = findViewById(R.id.media_preview_caption); - captionContainer = findViewById(R.id.media_preview_caption_container); - playbackControlsContainer = findViewById(R.id.media_preview_playback_controls_container); - - setSupportActionBar(findViewById(R.id.search_toolbar)); - ActionBar actionBar = getSupportActionBar(); - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setHomeButtonEnabled(true); - } - - private void initializeResources() { - Address address = getIntent().getParcelableExtra(ADDRESS_EXTRA); - - initialMediaUri = getIntent().getData(); - initialMediaType = getIntent().getType(); - initialMediaSize = getIntent().getLongExtra(SIZE_EXTRA, 0); - initialCaption = getIntent().getStringExtra(CAPTION_EXTRA); - leftIsRecent = getIntent().getBooleanExtra(LEFT_IS_RECENT_EXTRA, false); - restartItem = -1; - - if (address != null) { - conversationRecipient = Recipient.from(this, address, true); - } else { - conversationRecipient = null; - } - } - - private void initializeObservers() { - viewModel.getPreviewData().observe(this, previewData -> { - if (previewData == null || mediaPager == null || mediaPager.getAdapter() == null) { - return; - } - - View playbackControls = ((MediaItemAdapter) mediaPager.getAdapter()).getPlaybackControls(mediaPager.getCurrentItem()); - - if (previewData.getAlbumThumbnails().isEmpty() && previewData.getCaption() == null && playbackControls == null) { - detailsContainer.setVisibility(View.GONE); - } else { - detailsContainer.setVisibility(View.VISIBLE); - } - - albumRail.setVisibility(previewData.getAlbumThumbnails().isEmpty() ? View.GONE : View.VISIBLE); - albumRailAdapter.setMedia(previewData.getAlbumThumbnails(), previewData.getActivePosition()); - albumRail.smoothScrollToPosition(previewData.getActivePosition()); - - captionContainer.setVisibility(previewData.getCaption() == null ? View.GONE : View.VISIBLE); - caption.setText(previewData.getCaption()); - - if (playbackControls != null) { - ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - playbackControls.setLayoutParams(params); - - playbackControlsContainer.removeAllViews(); - playbackControlsContainer.addView(playbackControls); - } else { - playbackControlsContainer.removeAllViews(); - } - }); - - clickDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() { - @Override - public boolean onSingleTapUp(MotionEvent e) { - if (e.getY() < detailsContainer.getTop()) { - detailsContainer.setVisibility(detailsContainer.getVisibility() == View.VISIBLE ? View.GONE : View.VISIBLE); - } - toggleFullscreen(); - return super.onSingleTapUp(e); - } - }); - } - - private void initializeMedia() { - if (!isContentTypeSupported(initialMediaType)) { - Log.w(TAG, "Unsupported media type sent to MediaPreviewActivity, finishing."); - Toast.makeText(getApplicationContext(), R.string.attachmentsErrorNotSupported, Toast.LENGTH_LONG).show(); - finish(); - } - - Log.i(TAG, "Loading Part URI: " + initialMediaUri); - - if (conversationRecipient != null) { - getSupportLoaderManager().restartLoader(0, null, this); - } else { - adapter = new SingleItemPagerAdapter(this, Glide.with(this), getWindow(), initialMediaUri, initialMediaType, initialMediaSize); - mediaPager.setAdapter(adapter); - - if (initialCaption != null) { - detailsContainer.setVisibility(View.VISIBLE); - captionContainer.setVisibility(View.VISIBLE); - caption.setText(initialCaption); - } - } - } - - private int cleanupMedia() { - int restartItem = mediaPager.getCurrentItem(); - - mediaPager.removeAllViews(); - mediaPager.setAdapter(null); - - return restartItem; - } - - private void showOverview() { - startActivity(MediaOverviewActivity.createIntent(this, conversationRecipient.getAddress())); - } - - private void forward() { - MediaItem mediaItem = getCurrentMediaItem(); - - if (mediaItem != null) { - Intent composeIntent = new Intent(this, ShareActivity.class); - composeIntent.putExtra(Intent.EXTRA_STREAM, mediaItem.uri); - composeIntent.setType(mediaItem.type); - startActivity(composeIntent); - } - } - - @SuppressWarnings("CodeBlock2Expr") - @SuppressLint("InlinedApi") - private void saveToDisk() { - Log.w("ACL", "Asked to save to disk!"); - MediaItem mediaItem = getCurrentMediaItem(); - if (mediaItem == null) return; - - SaveAttachmentTask.showOneTimeWarningDialogOrSave(this, 1, () -> { - Permissions.with(this) - .request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) - .maxSdkVersion(Build.VERSION_CODES.P) - .withPermanentDenialDialog(getPermanentlyDeniedStorageText()) - .onAnyDenied(() -> { - Toast.makeText(this, getPermanentlyDeniedStorageText(), Toast.LENGTH_LONG).show(); - }) - .onAllGranted(() -> { - SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this); - long saveDate = (mediaItem.date > 0) ? mediaItem.date : SnodeAPI.getNowWithOffset(); - saveTask.executeOnExecutor( - AsyncTask.THREAD_POOL_EXECUTOR, - new Attachment(mediaItem.uri, mediaItem.type, saveDate, null)); - if (!mediaItem.outgoing) { - sendMediaSavedNotificationIfNeeded(); - } - }) - .execute(); - return Unit.INSTANCE; - }); - } - - private String getPermanentlyDeniedStorageText(){ - return Phrase.from(getApplicationContext(), R.string.permissionsStorageDeniedLegacy) - .put(APP_NAME_KEY, getString(R.string.app_name)) - .format().toString(); - } - - private void sendMediaSavedNotificationIfNeeded() { - if (conversationRecipient.isGroupOrCommunityRecipient()) return; - DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset())); - MessageSender.send(message, conversationRecipient.getAddress()); - } - - @SuppressLint("StaticFieldLeak") - private void deleteMedia() { - MediaItem mediaItem = getCurrentMediaItem(); - if (mediaItem == null || mediaItem.attachment == null) { - return; - } - - DeleteMediaPreviewDialog.show(this, () -> { - new AsyncTask() { - @Override - protected Void doInBackground(Void... voids) { - DatabaseAttachment attachment = mediaItem.attachment; - if (attachment != null) { - AttachmentUtil.deleteAttachment(getApplicationContext(), attachment); - } - return null; - } - }.execute(); - - finish(); - }); - } - - @Override - public boolean onPrepareOptionsMenu(Menu menu) { - super.onPrepareOptionsMenu(menu); - - menu.clear(); - MenuInflater inflater = this.getMenuInflater(); - inflater.inflate(R.menu.media_preview, menu); - - final boolean isDeprecatedLegacyGroup = conversationRecipient != null && - conversationRecipient.isLegacyGroupRecipient() && - deprecationManager.getDeprecationState().getValue() == LegacyGroupDeprecationManager.DeprecationState.DEPRECATED; - - if (!isMediaInDb() || isDeprecatedLegacyGroup) { - menu.findItem(R.id.media_preview__overview).setVisible(false); - menu.findItem(R.id.delete).setVisible(false); - } - - return true; - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - super.onOptionsItemSelected(item); - - switch (item.getItemId()) { - // TODO / WARNING: R.id values are NON-CONSTANT in Gradle 8.0+ - what would be the best way to address this?! -AL 2024/08/26 - case R.id.media_preview__overview: showOverview(); return true; - case R.id.media_preview__forward: forward(); return true; - case R.id.save: saveToDisk(); return true; - case R.id.delete: deleteMedia(); return true; - case android.R.id.home: finish(); return true; - } - - return false; - } - - private boolean isMediaInDb() { - return conversationRecipient != null; - } - - private @Nullable MediaItem getCurrentMediaItem() { - if (adapter == null) return null; - return adapter.getMediaItemFor(mediaPager.getCurrentItem()); - } - - public static boolean isContentTypeSupported(final String contentType) { - return contentType != null && (contentType.startsWith("image/") || contentType.startsWith("video/")); - } - - @Override - public @NonNull Loader> onCreateLoader(int id, Bundle args) { - return new PagingMediaLoader(this, conversationRecipient, initialMediaUri, leftIsRecent); - } - - @Override - public void onLoadFinished(@NonNull Loader> loader, @Nullable Pair data) { - if (data == null) return; - - mediaPager.removeOnPageChangeListener(viewPagerListener); - - adapter = new CursorPagerAdapter(this, Glide.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); } - } - - @Override - public void onLoaderReset(@NonNull Loader> loader) { /* Do nothing */ } - - private class ViewPagerListener implements ViewPager.OnPageChangeListener { - - private int currentPage = -1; - - @Override - public void onPageSelected(int position) { - if (currentPage != -1 && currentPage != position) onPageUnselected(currentPage); - currentPage = position; - - if (adapter == null) return; - - 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) { - if (adapter == null) return; - - try { - MediaItem item = adapter.getMediaItemFor(position); - if (item.recipient != null) item.recipient.removeListener(MediaPreviewActivity.this); - } catch (CursorIndexOutOfBoundsException e) { - throw new RuntimeException("position = " + position + " leftIsRecent = " + leftIsRecent, e); - } - - adapter.pause(position); - } - - @Override - public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { - /* Do nothing */ - } - - @Override - public void onPageScrollStateChanged(int state) { /* Do nothing */ } - } - - private static class SingleItemPagerAdapter extends MediaItemAdapter { - - private final RequestManager glideRequests; - private final Window window; - private final Uri uri; - private final String mediaType; - private final long size; - - private final LayoutInflater inflater; - - SingleItemPagerAdapter(@NonNull Context context, @NonNull RequestManager glideRequests, - @NonNull Window window, @NonNull Uri uri, @NonNull String mediaType, - long size) - { - this.glideRequests = glideRequests; - this.window = window; - this.uri = uri; - this.mediaType = mediaType; - this.size = size; - this.inflater = LayoutInflater.from(context); - } - - @Override - public int getCount() { - return 1; - } - - @Override - public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { - return view == object; - } - - @Override - public @NonNull Object instantiateItem(@NonNull ViewGroup container, int position) { - View itemView = inflater.inflate(R.layout.media_view_page, container, false); - MediaView mediaView = itemView.findViewById(R.id.media_view); - - try { - mediaView.set(glideRequests, window, uri, mediaType, size, true); - } catch (IOException e) { - Log.w(TAG, e); - } - - container.addView(itemView); - - return itemView; - } - - @Override - public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { - MediaView mediaView = ((FrameLayout)object).findViewById(R.id.media_view); - mediaView.cleanup(); - - container.removeView((FrameLayout)object); - } - - @Override - public MediaItem getMediaItemFor(int position) { - return new MediaItem(null, null, uri, mediaType, -1, true); - } - - @Override - public void pause(int position) { /* Do nothing */ } - - @Override - public @Nullable View getPlaybackControls(int position) { - return null; - } - } - - private static class CursorPagerAdapter extends MediaItemAdapter { - - private final WeakHashMap mediaViews = new WeakHashMap<>(); - - private final Context context; - private final RequestManager glideRequests; - private final Window window; - private final Cursor cursor; - private final boolean leftIsRecent; - - private int autoPlayPosition; - - CursorPagerAdapter(@NonNull Context context, @NonNull RequestManager glideRequests, - @NonNull Window window, @NonNull Cursor cursor, int autoPlayPosition, - boolean leftIsRecent) - { - this.context = context.getApplicationContext(); - this.glideRequests = glideRequests; - this.window = window; - this.cursor = cursor; - this.autoPlayPosition = autoPlayPosition; - this.leftIsRecent = leftIsRecent; - } - - @Override - public int getCount() { - return cursor.getCount(); - } - - @Override - public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { - return view == object; - } - - @Override - public @NonNull Object instantiateItem(@NonNull ViewGroup container, int position) { - View itemView = LayoutInflater.from(context).inflate(R.layout.media_view_page, container, false); - MediaView mediaView = itemView.findViewById(R.id.media_view); - boolean autoplay = position == autoPlayPosition; - int cursorPosition = getCursorPosition(position); - - autoPlayPosition = -1; - - cursor.moveToPosition(cursorPosition); - - MediaRecord mediaRecord = MediaRecord.from(context, cursor); - - try { - //noinspection ConstantConditions - mediaView.set(glideRequests, window, mediaRecord.getAttachment().getDataUri(), - mediaRecord.getAttachment().getContentType(), mediaRecord.getAttachment().getSize(), autoplay); - } catch (IOException e) { - Log.w(TAG, e); - } - - mediaViews.put(position, mediaView); - container.addView(itemView); - - return itemView; - } - - @Override - public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { - MediaView mediaView = ((FrameLayout)object).findViewById(R.id.media_view); - mediaView.cleanup(); - - mediaViews.remove(position); - container.removeView((FrameLayout)object); - } - - public MediaItem getMediaItemFor(int position) { - cursor.moveToPosition(getCursorPosition(position)); - MediaRecord mediaRecord = MediaRecord.from(context, cursor); - Address address = mediaRecord.getAddress(); - - if (mediaRecord.getAttachment().getDataUri() == null) throw new AssertionError(); - - return new MediaItem(address != null ? Recipient.from(context, address,true) : null, - mediaRecord.getAttachment(), - mediaRecord.getAttachment().getDataUri(), - mediaRecord.getContentType(), - mediaRecord.getDate(), - mediaRecord.isOutgoing()); - } - - @Override - public void pause(int position) { - MediaView mediaView = mediaViews.get(position); - if (mediaView != null) mediaView.pause(); - } - - @Override - public @Nullable View getPlaybackControls(int position) { - MediaView mediaView = mediaViews.get(position); - if (mediaView != null) return mediaView.getPlaybackControls(); - return null; - } - - private int getCursorPosition(int position) { - int unclamped = leftIsRecent ? position : cursor.getCount() - 1 - position; - return Math.max(Math.min(unclamped, cursor.getCount() - 1), 0); - } - } - - private static class MediaItem { - private final @Nullable Recipient recipient; - private final @Nullable DatabaseAttachment attachment; - private final @NonNull Uri uri; - private final @NonNull String type; - private final long date; - private final boolean outgoing; - - private MediaItem(@Nullable Recipient recipient, - @Nullable DatabaseAttachment attachment, - @NonNull Uri uri, - @NonNull String type, - long date, - boolean outgoing) - { - this.recipient = recipient; - this.attachment = attachment; - this.uri = uri; - this.type = type; - this.date = date; - this.outgoing = outgoing; - } - } - - 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/MediaPreviewActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt new file mode 100644 index 0000000000..3f9280f4f9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt @@ -0,0 +1,862 @@ +/* + * 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 . + */ +package org.thoughtcrime.securesms + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.database.Cursor +import android.database.CursorIndexOutOfBoundsException +import android.net.Uri +import android.os.AsyncTask +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.view.Window +import android.widget.Toast +import androidx.activity.viewModels +import androidx.core.graphics.ColorUtils +import androidx.core.graphics.drawable.toDrawable +import androidx.core.util.Pair +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope +import androidx.loader.app.LoaderManager +import androidx.loader.content.Loader +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestManager +import com.squareup.phrase.Phrase +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import network.loki.messenger.R +import network.loki.messenger.databinding.MediaPreviewActivityBinding +import network.loki.messenger.databinding.MediaViewPageBinding +import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager +import org.session.libsession.messaging.messages.control.DataExtractionNotification +import org.session.libsession.messaging.messages.control.DataExtractionNotification.Kind.MediaSaved +import org.session.libsession.messaging.sending_receiving.MessageSender.send +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment +import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.Util.runOnMain +import org.session.libsession.utilities.getColorFromAttr +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.RecipientModifiedListener +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.ShareActivity +import org.thoughtcrime.securesms.components.MediaView +import org.thoughtcrime.securesms.components.dialogs.DeleteMediaPreviewDialog +import org.thoughtcrime.securesms.conversation.v2.DimensionUnit +import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord +import org.thoughtcrime.securesms.database.loaders.PagingMediaLoader +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.media.MediaOverviewActivity.Companion.createIntent +import org.thoughtcrime.securesms.mediapreview.MediaPreviewViewModel +import org.thoughtcrime.securesms.mediapreview.MediaPreviewViewModel.PreviewData +import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter +import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter.RailItemListener +import org.thoughtcrime.securesms.mms.Slide +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.util.AttachmentUtil +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.FilenameUtils.getFilenameFromUri +import org.thoughtcrime.securesms.util.SaveAttachmentTask +import org.thoughtcrime.securesms.util.SaveAttachmentTask.Companion.showOneTimeWarningDialogOrSave +import java.io.IOException +import java.util.Locale +import java.util.WeakHashMap +import javax.inject.Inject +import kotlin.math.max +import kotlin.math.min + +/** + * Activity for displaying media attachments in-app + */ +@AndroidEntryPoint +class MediaPreviewActivity : ScreenLockActionBarActivity(), RecipientModifiedListener, + LoaderManager.LoaderCallbacks?>, + RailItemListener, MediaView.FullscreenToggleListener { + private lateinit var binding: MediaPreviewActivityBinding + private var initialMediaUri: Uri? = null + private var initialMediaType: String? = null + private var initialMediaSize: Long = 0 + private var initialCaption: String? = null + private var conversationRecipient: Recipient? = null + private var leftIsRecent = false + private val viewModel: MediaPreviewViewModel by viewModels() + private var viewPagerListener: ViewPagerListener? = null + + @Inject + lateinit var deprecationManager: LegacyGroupDeprecationManager + + private var isFullscreen = false + + @Inject + lateinit var dateUtils: DateUtils + + override val applyDefaultWindowInsets: Boolean + get() = false + + private var adapter: CursorPagerAdapter? = null + private var albumRailAdapter: MediaRailAdapter? = null + + private var windowInsetBottom = 0 + private var railHeight = 0 + + override fun onCreate(bundle: Bundle?, ready: Boolean) { + binding = MediaPreviewActivityBinding.inflate( + layoutInflater + ) + + setContentView(binding.root) + + initializeViews() + initializeResources() + initializeObservers() + initializeMedia() + + // make the toolbar translucent so that the video can be seen below in landscape - 70% of regular toolbar color + supportActionBar?.setBackgroundDrawable( + ColorUtils.setAlphaComponent( + getColorFromAttr( + android.R.attr.colorPrimary + ), (0.7f * 255).toInt() + ).toDrawable()) + + // handle edge to edge display + ViewCompat.setOnApplyWindowInsetsListener(findViewById(android.R.id.content)) { view, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()) + windowInsetBottom = insets.bottom + + binding.toolbar.updatePadding(top = insets.top) + binding.mediaPreviewAlbumRailContainer.updatePadding(bottom = max(insets.bottom, binding.mediaPreviewAlbumRailContainer.paddingBottom)) + + updateControlsPosition() + + // on older android version this acts as a safety when the system intercepts the first tap and only + // shows the system bars but ignores our code to show the toolbar and rail back + val systemBarsVisible: Boolean = windowInsets.isVisible(WindowInsetsCompat.Type.systemBars()) + if (systemBarsVisible && isFullscreen) { + exitFullscreen() + } + + windowInsets.inset(insets) + } + + + // Set up system UI visibility listener + window.decorView.setOnSystemUiVisibilityChangeListener { visibility -> + // Check if system bars became visible + val systemBarsVisible = (visibility and View.SYSTEM_UI_FLAG_FULLSCREEN) == 0 + if (systemBarsVisible && isFullscreen) { + // System bars appeared - exit fullscreen and show our UI + exitFullscreen() + } + } + } + + /** + * Updates the media controls' position based on the rail's position + */ + private fun updateControlsPosition() { + // the ypos of the controls is either the window bottom inset, or the rail height if there is a rail + // since the rail height takes the window inset into account with its padding + val totalBottomPadding = max( + windowInsetBottom, + railHeight + resources.getDimensionPixelSize(R.dimen.medium_spacing) + ) + + adapter?.setControlsYPosition(totalBottomPadding) + } + + override fun toggleFullscreen() { + if (isFullscreen) exitFullscreen() else enterFullscreen() + } + + override fun setFullscreen(displayFullscreen: Boolean) { + if (displayFullscreen) enterFullscreen() else exitFullscreen() + } + + private fun enterFullscreen() { + supportActionBar?.hide() + hideAlbumRail() + isFullscreen = true + WindowCompat.getInsetsController(window, window.decorView) + .hide(WindowInsetsCompat.Type.systemBars()) + } + + private fun exitFullscreen() { + supportActionBar?.show() + showAlbumRail() + WindowCompat.getInsetsController(window, window.decorView) + .show(WindowInsetsCompat.Type.systemBars()) + isFullscreen = false + } + + private fun hideAlbumRail() { + val rail = binding.mediaPreviewAlbumRailContainer + rail.animate().cancel() + rail.animate() + .translationY(rail.height.toFloat()) + .alpha(0f) + .setDuration(200) + .withEndAction { rail.visibility = View.GONE } + .start() + } + + private fun showAlbumRail() { + // never show the rail in landscape + if(isLandscape()) return + + val rail = binding.mediaPreviewAlbumRailContainer + rail.animate().cancel() + rail.visibility = View.VISIBLE + rail.animate() + .translationY(0f) + .alpha(1f) + .setDuration(200) + .start() + } + + @SuppressLint("MissingSuperCall") + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) + } + + override fun onModified(recipient: Recipient) { + runOnMain { this.updateActionBar() } + } + + override fun onRailItemClicked(distanceFromActive: Int) { + binding.mediaPager.currentItem = binding.mediaPager.currentItem + distanceFromActive + } + + override fun onRailItemDeleteClicked(distanceFromActive: Int) { + throw UnsupportedOperationException("Callback unsupported.") + } + + private fun updateActionBar() { + val mediaItem = currentMediaItem + + if (mediaItem != null) { + val relativeTimeSpan: CharSequence = if (mediaItem.date > 0) { + dateUtils.getDisplayFormattedTimeSpanString(mediaItem.date) + } else { + getString(R.string.draft) + } + + if (mediaItem.outgoing) supportActionBar?.title = getString(R.string.you) + else if (mediaItem.recipient != null) supportActionBar?.title = + mediaItem.recipient.name + else supportActionBar?.title = "" + + supportActionBar?.subtitle = relativeTimeSpan + } + } + + public override fun onPause() { + super.onPause() + + adapter?.pause(binding.mediaPager.currentItem) + } + + override fun onDestroy() { + adapter?.cleanUp() + super.onDestroy() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + initializeResources() + } + + fun onMediaPaused(videoUri: Uri, position: Long){ + viewModel.savePlaybackPosition(videoUri, position) + } + + fun getLastPlaybackPosition(videoUri: Uri): Long { + return viewModel.getSavedPlaybackPosition(videoUri) + } + + private fun initializeViews() { + binding.mediaPager.offscreenPageLimit = 1 + + albumRailAdapter = MediaRailAdapter(Glide.with(this), this, false) + + binding.mediaPreviewAlbumRail.layoutManager = + LinearLayoutManager( + this, + LinearLayoutManager.HORIZONTAL, + false + ) + binding.mediaPreviewAlbumRail.adapter = albumRailAdapter + + setSupportActionBar(findViewById(R.id.toolbar)) + + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setHomeButtonEnabled(true) + } + + private fun initializeResources() { + val address = intent.getParcelableExtra

( + ADDRESS_EXTRA + ) + + initialMediaUri = intent.data + initialMediaType = intent.type + initialMediaSize = intent.getLongExtra(SIZE_EXTRA, 0) + initialCaption = intent.getStringExtra(CAPTION_EXTRA) + leftIsRecent = intent.getBooleanExtra(LEFT_IS_RECENT_EXTRA, false) + + conversationRecipient = if (address != null) { + Recipient.from( + this, + address, + true + ) + } else { + null + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + // always hide the rail in landscape + if (isLandscape()) { + hideAlbumRail() + } else { + if (!isFullscreen) { + showAlbumRail() + } + } + + // Re-apply fullscreen if we were already in it + if (isFullscreen) { + enterFullscreen() + } else { + exitFullscreen() + } + } + + private fun isLandscape(): Boolean { + return resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + } + + private fun initializeObservers() { + viewModel.previewData.observe( + this, + Observer { previewData: PreviewData? -> + if (previewData == null || binding == null || binding.mediaPager.adapter == null) { + return@Observer + } + + binding.mediaPreviewAlbumRailContainer.visibility = + if (previewData.albumThumbnails.isEmpty()) View.GONE else View.VISIBLE + albumRailAdapter?.setMedia(previewData.albumThumbnails, previewData.activePosition) + + // recalculate controls position if we have rail data + if(previewData.albumThumbnails.isNotEmpty()) { + binding.mediaPreviewAlbumRailContainer.viewTreeObserver.addOnGlobalLayoutListener( + object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + binding.mediaPreviewAlbumRailContainer.viewTreeObserver.removeOnGlobalLayoutListener( + this + ) + railHeight = binding.mediaPreviewAlbumRailContainer.height + updateControlsPosition() + } + } + ) + } + + binding.mediaPreviewAlbumRail.smoothScrollToPosition(previewData.activePosition) + }) + } + + private fun initializeMedia() { + if (!isContentTypeSupported(initialMediaType)) { + Log.w(TAG, "Unsupported media type sent to MediaPreviewActivity, finishing.") + Toast.makeText( + applicationContext, + R.string.attachmentsErrorNotSupported, + Toast.LENGTH_LONG + ).show() + finish() + } + + Log.i( + TAG, + "Loading Part URI: $initialMediaUri" + ) + + if (conversationRecipient != null) { + LoaderManager.getInstance(this).restartLoader(0, null, this) + } else { + finish() + } + } + + private fun showOverview() { + conversationRecipient?.address?.let { startActivity(createIntent(this, it)) } + } + + private fun forward() { + val mediaItem = currentMediaItem + + if (mediaItem != null) { + val composeIntent = Intent( + this, + ShareActivity::class.java + ) + composeIntent.putExtra(Intent.EXTRA_STREAM, mediaItem.uri) + composeIntent.setType(mediaItem.mimeType) + startActivity(composeIntent) + } + } + + @SuppressLint("InlinedApi") + private fun saveToDisk() { + val mediaItem = currentMediaItem + if (mediaItem == null) { + Log.w(TAG, "Cannot save a null MediaItem to disk - bailing.") + return + } + + // If we have an attachment then we can take the filename from it, otherwise we have to take the + // more expensive route of looking up or synthesizing a filename from the MediaItem's Uri. + var mediaFilename: String? = null + if (mediaItem.attachment != null) { + mediaFilename = mediaItem.attachment.filename + } + + if (mediaFilename == null || mediaFilename.isEmpty()) { + mediaFilename = + getFilenameFromUri(this@MediaPreviewActivity, mediaItem.uri, mediaItem.mimeType) + } + + val outputFilename = mediaFilename // We need a final value for the saveTask, below + Log.i( + TAG, + "About to save media as: $outputFilename" + ) + + showOneTimeWarningDialogOrSave(this, 1) { + Permissions.with(this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .maxSdkVersion(Build.VERSION_CODES.P) // Note: P is API 28 + .withPermanentDenialDialog(permanentlyDeniedStorageText) + .onAnyDenied { + Toast.makeText( + this, + permanentlyDeniedStorageText, Toast.LENGTH_LONG + ).show() + } + .onAllGranted { + val saveTask = SaveAttachmentTask(this@MediaPreviewActivity) + val saveDate = if (mediaItem.date > 0) mediaItem.date else nowWithOffset + saveTask.executeOnExecutor( + AsyncTask.THREAD_POOL_EXECUTOR, + SaveAttachmentTask.Attachment( + mediaItem.uri, + mediaItem.mimeType, + saveDate, + outputFilename + ) + ) + if (!mediaItem.outgoing) { + sendMediaSavedNotificationIfNeeded() + } + } + .execute() + Unit + } + } + + private val permanentlyDeniedStorageText: String + get() = Phrase.from( + applicationContext, + R.string.permissionsStorageDeniedLegacy + ) + .put(APP_NAME_KEY, getString(R.string.app_name)) + .format().toString() + + private fun sendMediaSavedNotificationIfNeeded() { + if (conversationRecipient == null || conversationRecipient?.isGroupOrCommunityRecipient == true) return + val message = DataExtractionNotification( + MediaSaved( + nowWithOffset + ) + ) + send(message, conversationRecipient!!.address) + } + + @SuppressLint("StaticFieldLeak") + private fun deleteMedia() { + val mediaItem = currentMediaItem + if (mediaItem?.attachment == null) { + return + } + + DeleteMediaPreviewDialog.show(this){ + lifecycleScope.launch(Dispatchers.Default) { + AttachmentUtil.deleteAttachment(applicationContext, mediaItem.attachment) + withContext(Dispatchers.Main){ finish() } + } + } + } + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + super.onPrepareOptionsMenu(menu) + + menu.clear() + val inflater = this.menuInflater + inflater.inflate(R.menu.media_preview, menu) + + val isDeprecatedLegacyGroup = conversationRecipient != null && + conversationRecipient?.isLegacyGroupRecipient == true && + deprecationManager.deprecationState.value == LegacyGroupDeprecationManager.DeprecationState.DEPRECATED + + if (!isMediaInDb || isDeprecatedLegacyGroup) { + menu.findItem(R.id.media_preview__overview).setVisible(false) + menu.findItem(R.id.delete).setVisible(false) + } + + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + super.onOptionsItemSelected(item) + + when (item.itemId) { + R.id.media_preview__overview -> { + showOverview() + return true + } + + R.id.media_preview__forward -> { + forward() + return true + } + + R.id.save -> { + saveToDisk() + return true + } + + R.id.delete -> { + deleteMedia() + return true + } + + android.R.id.home -> { + finish() + return true + } + } + + return false + } + + private val isMediaInDb: Boolean + get() = conversationRecipient != null + + private val currentMediaItem: MediaItem? + get() { + if (adapter == null) return null + + try { + return adapter!!.getMediaItemFor(binding.mediaPager.currentItem) + } catch (e: Exception) { + Log.w(TAG, "Error getting current media item", e) + return null + } + } + + override fun onCreateLoader(id: Int, args: Bundle?): Loader?> { + return PagingMediaLoader( + this, + conversationRecipient!!, initialMediaUri!!, leftIsRecent + ) + } + + override fun onLoadFinished(loader: Loader?>, data: Pair?) { + if (data == null) return + + viewPagerListener?.let{ binding.mediaPager.unregisterOnPageChangeCallback(it) } + + adapter = CursorPagerAdapter( + this, Glide.with(this), + window, data.first, data.second, leftIsRecent + ) + binding.mediaPager.adapter = adapter + + updateControlsPosition() + + viewModel.setCursor(this, data.first, leftIsRecent) + + val item = max(min(data.second, adapter!!.itemCount - 1), 0) + + viewPagerListener = ViewPagerListener() + binding.mediaPager.registerOnPageChangeCallback(viewPagerListener!!) + + try { + binding.mediaPager.setCurrentItem(item, false) + } catch (e: CursorIndexOutOfBoundsException) { + throw RuntimeException( + "data.second = " + data.second + " leftIsRecent = " + leftIsRecent, e + ) + } + + if (item == 0) { + viewPagerListener?.onPageSelected(0) + } + } + + override fun onLoaderReset(loader: Loader?>) { /* Do nothing */ + } + + private inner class ViewPagerListener : ViewPager2.OnPageChangeCallback() { + private var currentPage = -1 + + override fun onPageSelected(position: Int) { + if (currentPage != -1 && currentPage != position) onPageUnselected(currentPage) + currentPage = position + + if (adapter == null) return + + try { + val item = adapter!!.getMediaItemFor(position) + if (item.recipient != null) item.recipient.addListener(this@MediaPreviewActivity) + viewModel.setActiveAlbumRailItem(this@MediaPreviewActivity, position) + updateActionBar() + } catch (e: Exception){ + finish() + } + } + + + fun onPageUnselected(position: Int) { + if (adapter == null) return + + try { + val item = adapter!!.getMediaItemFor(position) + if (item.recipient != null) item.recipient.removeListener(this@MediaPreviewActivity) + } catch (e: CursorIndexOutOfBoundsException) { + throw RuntimeException("position = $position leftIsRecent = $leftIsRecent", e) + } catch (e: Exception){ + finish() + } + + adapter!!.pause(position) + } + + override fun onPageScrolled( + position: Int, + positionOffset: Float, + positionOffsetPixels: Int + ) { + /* Do nothing */ + } + + override fun onPageScrollStateChanged(state: Int) { /* Do nothing */ + } + } + + private class CursorPagerAdapter( + context: Context, private val glideRequests: RequestManager, + private val window: Window, private val cursor: Cursor, private var autoPlayPosition: Int, + private val leftIsRecent: Boolean + ) : MediaItemAdapter() { + private val mediaViews = WeakHashMap() + + private val context: Context = context + + private var controlsYPosition: Int = 0 + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return object : RecyclerView.ViewHolder( + MediaViewPageBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ).root + ) {} + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val binding = MediaViewPageBinding.bind(holder.itemView) + + val autoplay = position == autoPlayPosition + val cursorPosition = getCursorPosition(position) + + autoPlayPosition = -1 + + cursor.moveToPosition(cursorPosition) + + val mediaRecord = MediaRecord.from(context, cursor) + + // Set fullscreen toggle listener + (context as? MediaPreviewActivity)?.let { binding.mediaView.setFullscreenToggleListener(it) } + + try { + if (mediaRecord.attachment.dataUri == null) throw AssertionError() + binding.mediaView.set(glideRequests, window, mediaRecord.attachment.dataUri!!, mediaRecord.attachment.contentType, mediaRecord.attachment.size, autoplay) + binding.mediaView.setControlsYPosition(controlsYPosition) + + // try to resume where we were if we have a saved playback position + val playbackPosition = (context as? MediaPreviewActivity)?.getLastPlaybackPosition(mediaRecord.attachment.dataUri!!) + if(playbackPosition != 0L) binding.mediaView.seek(playbackPosition) + } catch (e: IOException) { + Log.w(TAG, e) + } + + mediaViews[position] = binding.mediaView + } + + override fun getItemCount(): Int { + return cursor.count + } + + override fun getMediaItemFor(position: Int): MediaItem { + cursor.moveToPosition(getCursorPosition(position)) + val mediaRecord = MediaRecord.from(context, cursor) + val address = mediaRecord.address + + if (mediaRecord.attachment.dataUri == null) throw AssertionError() + + return MediaItem( + if (address != null) Recipient.from(context, address, true) else null, + mediaRecord.attachment, + mediaRecord.attachment.dataUri!!, + mediaRecord.contentType, + mediaRecord.date, + mediaRecord.isOutgoing + ) + } + + override fun pause(position: Int) { + val mediaView = mediaViews[position] + val playbackPosition = mediaView?.pause() ?: 0L + // save the last playback position on pause + (context as? MediaPreviewActivity)?.onMediaPaused(getMediaItemFor(position).uri, playbackPosition) + } + + override fun cleanUp() { + mediaViews.forEach{ + it.value.cleanup() + } + } + + fun getCursorPosition(position: Int): Int { + val unclamped = if (leftIsRecent) position else cursor.count - 1 - position + return max(min(unclamped, cursor.count - 1), 0) + } + + override fun setControlsYPosition(position: Int){ + controlsYPosition = position + + // Update all existing MediaViews immediately + mediaViews.values.forEach { mediaView -> + mediaView.setControlsYPosition(position) + } + } + } + + class MediaItem( + val recipient: Recipient?, + val attachment: DatabaseAttachment?, + val uri: Uri, + val mimeType: String, + val date: Long, + val outgoing: Boolean + ) + + internal abstract class MediaItemAdapter : + RecyclerView.Adapter() { + abstract fun getMediaItemFor(position: Int): MediaItem + abstract fun pause(position: Int) + abstract fun cleanUp() + abstract fun setControlsYPosition(position: Int) + } + + companion object { + private val TAG: String = MediaPreviewActivity::class.java.simpleName + + const val ADDRESS_EXTRA: String = "address" + const val DATE_EXTRA: String = "date" + const val SIZE_EXTRA: String = "size" + const val CAPTION_EXTRA: String = "caption" + const val OUTGOING_EXTRA: String = "outgoing" + const val LEFT_IS_RECENT_EXTRA: String = "left_is_recent" + + fun getPreviewIntent(context: Context?, args: MediaPreviewArgs): Intent? { + return getPreviewIntent( + context, args.slide, + args.mmsRecord, args.thread + ) + } + + fun getPreviewIntent( + context: Context?, + slide: Slide, + mms: MmsMessageRecord, + threadRecipient: Recipient + ): Intent? { + var previewIntent: Intent? = null + if (isContentTypeSupported(slide.contentType) && slide.uri != null) { + previewIntent = Intent(context, MediaPreviewActivity::class.java) + previewIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .setDataAndType(slide.uri, slide.contentType) + .putExtra(ADDRESS_EXTRA, threadRecipient.address) + .putExtra(OUTGOING_EXTRA, mms.isOutgoing) + .putExtra(DATE_EXTRA, mms.timestamp) + .putExtra(SIZE_EXTRA, slide.asAttachment().size) + .putExtra(CAPTION_EXTRA, slide.caption.orNull()) + .putExtra(LEFT_IS_RECENT_EXTRA, false) + } + return previewIntent + } + + + fun isContentTypeSupported(contentType: String?): Boolean { + return contentType != null && (contentType.startsWith("image/") || contentType.startsWith( + "video/" + )) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewArgs.kt b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewArgs.kt index 00e2c3d6d8..3a812cbc6d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewArgs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewArgs.kt @@ -6,6 +6,6 @@ import org.thoughtcrime.securesms.mms.Slide data class MediaPreviewArgs( val slide: Slide, - val mmsRecord: MmsMessageRecord?, - val thread: Recipient?, + val mmsRecord: MmsMessageRecord, + val thread: Recipient, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt deleted file mode 100644 index d5e551d02a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.thoughtcrime.securesms - -import android.content.Context -import androidx.annotation.StringRes -import androidx.appcompat.app.AlertDialog -import network.loki.messenger.R -import org.session.libsession.LocalisedTimeUtil -import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY -import org.thoughtcrime.securesms.ui.getSubbedString -import java.util.concurrent.TimeUnit -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -fun showMuteDialog( - context: Context, - onMuteDuration: (Long) -> Unit -): AlertDialog = context.showSessionDialog { - title(R.string.notificationsMute) - - items(Option.entries.mapIndexed { index, entry -> - - if (entry.stringRes == R.string.notificationsMute) { - context.getString(R.string.notificationsMute) - } else { - val largeTimeUnitString = LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( - context, - Option.entries[index].duration.milliseconds - ) - context.getSubbedString(entry.stringRes, TIME_LARGE_KEY to largeTimeUnitString) - } - }.toTypedArray()) { - // Note: We add the current timestamp to the mute duration to get the un-mute timestamp - // that gets stored in the database via ConversationMenuHelper.mute(). - // Also: This is a kludge, but we ADD one second to the mute duration because otherwise by - // the time the view for how long the conversation is muted for gets set then it's actually - // less than the entire duration - so 1 hour becomes 59 minutes, 1 day becomes 23 hours etc. - // As we really want to see the actual set time (1 hour / 1 day etc.) then we'll bump it by - // 1 second which is neither here nor there in the grand scheme of things. - val muteTime = Option.entries[it].duration - val muteTimeFromNow = if (muteTime == Long.MAX_VALUE) muteTime - else muteTime + System.currentTimeMillis() + 1.seconds.inWholeMilliseconds - onMuteDuration(muteTimeFromNow) - } -} - -private enum class Option(@StringRes val stringRes: Int, val duration: Long) { - ONE_HOUR(R.string.notificationsMuteFor, duration = TimeUnit.HOURS.toMillis(1)), - TWO_HOURS(R.string.notificationsMuteFor, duration = TimeUnit.HOURS.toMillis(2)), - ONE_DAY(R.string.notificationsMuteFor, duration = TimeUnit.DAYS.toMillis(1)), - SEVEN_DAYS(R.string.notificationsMuteFor, duration = TimeUnit.DAYS.toMillis(7)), - FOREVER(R.string.notificationsMute, duration = Long.MAX_VALUE ); -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java deleted file mode 100644 index 16b5856766..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java +++ /dev/null @@ -1,324 +0,0 @@ -/* - * Copyright (C) 2011 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 . - */ -package org.thoughtcrime.securesms; - -import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; - -import android.animation.Animator; -import android.app.KeyguardManager; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.graphics.PorterDuff; -import android.os.Bundle; -import android.os.IBinder; -import android.view.View; -import android.view.animation.Animation; -import android.view.animation.BounceInterpolator; -import android.view.animation.TranslateAnimation; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.TextView; -import androidx.core.hardware.fingerprint.FingerprintManagerCompat; -import androidx.core.os.CancellationSignal; -import com.squareup.phrase.Phrase; -import java.security.Signature; -import network.loki.messenger.R; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.components.AnimatingToggle; -import org.thoughtcrime.securesms.crypto.BiometricSecretProvider; -import org.thoughtcrime.securesms.service.KeyCachingService; -import org.thoughtcrime.securesms.util.AnimationCompleteListener; - -//TODO Rename to ScreenLockActivity and refactor to Kotlin. -public class PassphrasePromptActivity extends BaseActionBarActivity { - - private static final String TAG = PassphrasePromptActivity.class.getSimpleName(); - - private ImageView fingerprintPrompt; - private Button lockScreenButton; - - private AnimatingToggle visibilityToggle; - - private FingerprintManagerCompat fingerprintManager; - private CancellationSignal fingerprintCancellationSignal; - private FingerprintListener fingerprintListener; - - private final BiometricSecretProvider biometricSecretProvider = new BiometricSecretProvider(); - - private boolean authenticated; - private boolean failure; - private boolean hasSignatureObject = true; - - private KeyCachingService keyCachingService; - - @Override - public void onCreate(Bundle savedInstanceState) { - Log.i(TAG, "onCreate()"); - super.onCreate(savedInstanceState); - - setContentView(R.layout.prompt_passphrase_activity); - initializeResources(); - - // Start and bind to the KeyCachingService instance. - Intent bindIntent = new Intent(this, KeyCachingService.class); - startService(bindIntent); - bindService(bindIntent, new ServiceConnection() { - @Override - public void onServiceConnected(ComponentName name, IBinder service) { - keyCachingService = ((KeyCachingService.KeySetBinder)service).getService(); - } - @Override - public void onServiceDisconnected(ComponentName name) { - keyCachingService.setMasterSecret(new Object()); - keyCachingService = null; - } - }, Context.BIND_AUTO_CREATE); - } - - @Override - public void onResume() { - super.onResume(); - - setLockTypeVisibility(); - - if (TextSecurePreferences.isScreenLockEnabled(this) && !authenticated && !failure) { - resumeScreenLock(); - } - - failure = false; - } - - @Override - public void onPause() { - super.onPause(); - - if (TextSecurePreferences.isScreenLockEnabled(this)) { - pauseScreenLock(); - } - } - - @Override - protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - setIntent(intent); - } - - @Override - public void onActivityResult(int requestCode, int resultcode, Intent data) { - super.onActivityResult(requestCode, resultcode, data); - if (requestCode != 1) return; - - if (resultcode == RESULT_OK) { - handleAuthenticated(); - } else { - Log.w(TAG, "Authentication failed"); - failure = true; - } - } - - private void handleAuthenticated() { - authenticated = true; - //TODO Replace with a proper call. - if (keyCachingService != null) { - keyCachingService.setMasterSecret(new Object()); - } - - // Finish and proceed with the next intent. - Intent nextIntent = getIntent().getParcelableExtra("next_intent"); - if (nextIntent != null) { - try { - startActivity(nextIntent); - } catch (java.lang.SecurityException e) { - Log.w(TAG, "Access permission not passed from PassphraseActivity, retry sharing.", e); - } - } - finish(); - } - - private void initializeResources() { - - TextView statusTitle = findViewById(R.id.app_lock_status_title); - if (statusTitle != null) { - Context c = getApplicationContext(); - String lockedTxt = Phrase.from(c, R.string.lockAppLocked) - .put(APP_NAME_KEY, c.getString(R.string.app_name)) - .format().toString(); - statusTitle.setText(lockedTxt); - } - - visibilityToggle = findViewById(R.id.button_toggle); - fingerprintPrompt = findViewById(R.id.fingerprint_auth_container); - lockScreenButton = findViewById(R.id.lock_screen_auth_container); - fingerprintManager = FingerprintManagerCompat.from(this); - fingerprintCancellationSignal = new CancellationSignal(); - fingerprintListener = new FingerprintListener(); - - fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp); - fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_primary), PorterDuff.Mode.SRC_IN); - - lockScreenButton.setOnClickListener(v -> resumeScreenLock()); - } - - private void setLockTypeVisibility() { - if (TextSecurePreferences.isScreenLockEnabled(this)) { - if (fingerprintManager.isHardwareDetected() && fingerprintManager.hasEnrolledFingerprints()) { - fingerprintPrompt.setVisibility(View.VISIBLE); - lockScreenButton.setVisibility(View.GONE); - } else { - fingerprintPrompt.setVisibility(View.GONE); - lockScreenButton.setVisibility(View.VISIBLE); - } - } else { - fingerprintPrompt.setVisibility(View.GONE); - lockScreenButton.setVisibility(View.GONE); - } - } - - private void resumeScreenLock() { - KeyguardManager keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE); - - assert keyguardManager != null; - - if (!keyguardManager.isKeyguardSecure()) { - Log.w(TAG ,"Keyguard not secure..."); - TextSecurePreferences.setScreenLockEnabled(getApplicationContext(), false); - TextSecurePreferences.setScreenLockTimeout(getApplicationContext(), 0); - handleAuthenticated(); - return; - } - - if (fingerprintManager.isHardwareDetected() && fingerprintManager.hasEnrolledFingerprints()) { - Log.i(TAG, "Listening for fingerprints..."); - fingerprintCancellationSignal = new CancellationSignal(); - Signature signature; - try { - signature = biometricSecretProvider.getOrCreateBiometricSignature(this); - hasSignatureObject = true; - } catch (Exception e) { - signature = null; - hasSignatureObject = false; - Log.e(TAG, "Error getting / creating signature", e); - } - fingerprintManager.authenticate( - signature == null ? null : new FingerprintManagerCompat.CryptoObject(signature), - 0, - fingerprintCancellationSignal, - fingerprintListener, - null - ); - } else { - Log.i(TAG, "firing intent..."); - Intent intent = keyguardManager.createConfirmDeviceCredentialIntent("Unlock Session", ""); - startActivityForResult(intent, 1); - } - } - - private void pauseScreenLock() { - if (fingerprintCancellationSignal != null) { - fingerprintCancellationSignal.cancel(); - } - } - - private class FingerprintListener extends FingerprintManagerCompat.AuthenticationCallback { - @Override - public void onAuthenticationError(int errMsgId, CharSequence errString) { - Log.w(TAG, "Authentication error: " + errMsgId + " " + errString); - onAuthenticationFailed(); - } - - @Override - public void onAuthenticationSucceeded(FingerprintManagerCompat.AuthenticationResult result) { - Log.i(TAG, "onAuthenticationSucceeded"); - if (result.getCryptoObject() == null || result.getCryptoObject().getSignature() == null) { - if (hasSignatureObject) { - // authentication failed - onAuthenticationFailed(); - } else { - fingerprintPrompt.setImageResource(R.drawable.ic_check_white_48dp); - fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.green_500), PorterDuff.Mode.SRC_IN); - fingerprintPrompt.animate().setInterpolator(new BounceInterpolator()).scaleX(1.1f).scaleY(1.1f).setDuration(500).setListener(new AnimationCompleteListener() { - @Override - public void onAnimationEnd(Animator animation) { - handleAuthenticated(); - - fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp); - fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_primary), PorterDuff.Mode.SRC_IN); - } - }).start(); - } - return; - } - // Signature object now successfully unlocked - boolean authenticationSucceeded = false; - try { - Signature signature = result.getCryptoObject().getSignature(); - byte[] random = biometricSecretProvider.getRandomData(); - signature.update(random); - byte[] signed = signature.sign(); - authenticationSucceeded = biometricSecretProvider.verifySignature(random, signed); - } catch (Exception e) { - Log.e(TAG, "onAuthentication signature generation and verification failed", e); - } - if (!authenticationSucceeded) { - onAuthenticationFailed(); - return; - } - - fingerprintPrompt.setImageResource(R.drawable.ic_check_white_48dp); - fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.green_500), PorterDuff.Mode.SRC_IN); - fingerprintPrompt.animate().setInterpolator(new BounceInterpolator()).scaleX(1.1f).scaleY(1.1f).setDuration(500).setListener(new AnimationCompleteListener() { - @Override - public void onAnimationEnd(Animator animation) { - handleAuthenticated(); - - fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp); - fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_primary), PorterDuff.Mode.SRC_IN); - } - }).start(); - } - - @Override - public void onAuthenticationFailed() { - Log.w(TAG, "onAuthenticationFailed()"); - - fingerprintPrompt.setImageResource(R.drawable.ic_close_white_48dp); - fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.red_500), PorterDuff.Mode.SRC_IN); - - TranslateAnimation shake = new TranslateAnimation(0, 30, 0, 0); - shake.setDuration(50); - shake.setRepeatCount(7); - shake.setAnimationListener(new Animation.AnimationListener() { - @Override - public void onAnimationStart(Animation animation) {} - - @Override - public void onAnimationEnd(Animation animation) { - fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp); - fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_primary), PorterDuff.Mode.SRC_IN); - } - - @Override - public void onAnimationRepeat(Animation animation) {} - }); - - fingerprintPrompt.startAnimation(shake); - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActionBarActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActionBarActivity.java deleted file mode 100644 index 1c9f4b2e57..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActionBarActivity.java +++ /dev/null @@ -1,187 +0,0 @@ -package org.thoughtcrime.securesms; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -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.landing.LandingActivity; -import org.thoughtcrime.securesms.service.KeyCachingService; - -import java.util.Locale; - -//TODO AC: Rename to ScreenLockActionBarActivity. -public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarActivity { - private static final String TAG = PassphraseRequiredActionBarActivity.class.getSimpleName(); - - public static final String LOCALE_EXTRA = "locale_extra"; - - private static final int STATE_NORMAL = 0; - private static final int STATE_PROMPT_PASSPHRASE = 1; //TODO AC: Rename to STATE_SCREEN_LOCKED - private static final int STATE_UPGRADE_DATABASE = 2; //TODO AC: Rename to STATE_MIGRATE_DATA - private static final int STATE_WELCOME_SCREEN = 3; - - private BroadcastReceiver clearKeyReceiver; - - @Override - protected void onCreate(Bundle savedInstanceState) { - Log.i(TAG, "onCreate(" + savedInstanceState + ")"); - onPreCreate(); - - final boolean locked = KeyCachingService.isLocked(this) && - TextSecurePreferences.isScreenLockEnabled(this) && - TextSecurePreferences.getLocalNumber(this) != null; - routeApplicationState(locked); - - super.onCreate(savedInstanceState); - - if (!isFinishing()) { - initializeClearKeyReceiver(); - onCreate(savedInstanceState, true); - } - } - - protected void onPreCreate() {} - protected void onCreate(Bundle savedInstanceState, boolean ready) {} - - @Override - protected void onPause() { - Log.i(TAG, "onPause()"); - super.onPause(); - } - - @Override - protected void onDestroy() { - Log.i(TAG, "onDestroy()"); - super.onDestroy(); - removeClearKeyReceiver(this); - } - - public void onMasterSecretCleared() { - Log.i(TAG, "onMasterSecretCleared()"); - if (ApplicationContext.getInstance(this).isAppVisible()) routeApplicationState(true); - else finish(); - } - - protected T initFragment(@IdRes int target, - @NonNull T fragment) - { - return initFragment(target, fragment, null); - } - - protected T initFragment(@IdRes int target, - @NonNull T fragment, - @Nullable Locale locale) - { - return initFragment(target, fragment, locale, null); - } - - protected T initFragment(@IdRes int target, - @NonNull T fragment, - @Nullable Locale locale, - @Nullable Bundle extras) - { - Bundle args = new Bundle(); - args.putSerializable(LOCALE_EXTRA, locale); - - if (extras != null) { - args.putAll(extras); - } - - fragment.setArguments(args); - getSupportFragmentManager().beginTransaction() - .replace(target, fragment) - .commitAllowingStateLoss(); - return fragment; - } - - private void routeApplicationState(boolean locked) { - Intent intent = getIntentForState(getApplicationState(locked)); - if (intent != null) { - startActivity(intent); - finish(); - } - } - - private Intent getIntentForState(int state) { - Log.i(TAG, "routeApplicationState(), state: " + state); - - switch (state) { - case STATE_PROMPT_PASSPHRASE: return getPromptPassphraseIntent(); - case STATE_UPGRADE_DATABASE: return getUpgradeDatabaseIntent(); - case STATE_WELCOME_SCREEN: return getWelcomeIntent(); - default: return null; - } - } - - private int getApplicationState(boolean locked) { - if (TextSecurePreferences.getLocalNumber(this) == null) { - return STATE_WELCOME_SCREEN; - } else if (locked) { - return STATE_PROMPT_PASSPHRASE; - } else if (DatabaseUpgradeActivity.isUpdate(this)) { - return STATE_UPGRADE_DATABASE; - } else { - return STATE_NORMAL; - } - } - - private Intent getPromptPassphraseIntent() { - return getRoutedIntent(PassphrasePromptActivity.class, getIntent()); - } - - private Intent getUpgradeDatabaseIntent() { - return getRoutedIntent(DatabaseUpgradeActivity.class, getConversationListIntent()); - } - - private Intent getWelcomeIntent() { - return getRoutedIntent(LandingActivity.class, getConversationListIntent()); - } - - private Intent getConversationListIntent() { - return new Intent(this, HomeActivity.class); - } - - private Intent getRoutedIntent(Class destination, @Nullable Intent nextIntent) { - final Intent intent = new Intent(this, destination); - if (nextIntent != null) intent.putExtra("next_intent", nextIntent); - return intent; - } - - private void initializeClearKeyReceiver() { - Log.i(TAG, "initializeClearKeyReceiver()"); - this.clearKeyReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - Log.i(TAG, "onReceive() for clear key event"); - onMasterSecretCleared(); - } - }; - - IntentFilter filter = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT); - ContextCompat.registerReceiver( - this, - clearKeyReceiver, filter, - KeyCachingService.KEY_PERMISSION, - null, - ContextCompat.RECEIVER_NOT_EXPORTED - ); - } - - private void removeClearKeyReceiver(Context context) { - if (clearKeyReceiver != null) { - context.unregisterReceiver(clearKeyReceiver); - clearKeyReceiver = null; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt new file mode 100644 index 0000000000..c1125f7fba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActionBarActivity.kt @@ -0,0 +1,349 @@ +package org.thoughtcrime.securesms + +import android.content.BroadcastReceiver +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.os.Bundle +import androidx.annotation.IdRes +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.core.content.IntentCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber +import org.session.libsession.utilities.TextSecurePreferences.Companion.isScreenLockEnabled +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.home.HomeActivity +import org.thoughtcrime.securesms.migration.DatabaseMigrationManager +import org.thoughtcrime.securesms.migration.DatabaseMigrationStateActivity +import org.thoughtcrime.securesms.onboarding.landing.LandingActivity +import org.thoughtcrime.securesms.service.KeyCachingService +import org.thoughtcrime.securesms.util.FileProviderUtil +import org.thoughtcrime.securesms.util.FilenameUtils +import java.io.File +import java.io.FileOutputStream +import java.util.Locale + +abstract class ScreenLockActionBarActivity : BaseActionBarActivity() { + + private val migrationManager: DatabaseMigrationManager + get() = (applicationContext as ApplicationContext).migrationManager.get() + + companion object { + private val TAG = ScreenLockActionBarActivity::class.java.simpleName + + const val LOCALE_EXTRA: String = "locale_extra" + + private const val STATE_NORMAL = 0 + private const val STATE_SCREEN_LOCKED = 1 + private const val STATE_UPGRADE_DATABASE = 2 + private const val STATE_WELCOME_SCREEN = 3 + private const val STATE_DATABASE_MIGRATE = 4 // This is different from STATE_UPGRADE_DATABASE as it is used to migrate database in a whole rather than the internal db schema upgrades + + private fun getStateName(state: Int): String { + return when (state) { + STATE_NORMAL -> "STATE_NORMAL" + STATE_SCREEN_LOCKED -> "STATE_SCREEN_LOCKED" + STATE_UPGRADE_DATABASE -> "STATE_UPGRADE_DATABASE" + STATE_WELCOME_SCREEN -> "STATE_WELCOME_SCREEN" + else -> "UNKNOWN_STATE" + } + } + + // If we're sharing files we need to cache the data from the share Intent to maintain control of it + private val cachedIntentFiles = mutableListOf() + + // Called from ConversationActivity.onDestroy() to clean up any cached files that might exist + fun cleanupCachedFiles() { + val numFilesToDelete = cachedIntentFiles.size + var numFilesDeleted = 0 + for (file in cachedIntentFiles) { + if (file.exists()) { + val success = file.delete() + if (success) { numFilesDeleted++ } + } + } + if (numFilesDeleted < numFilesToDelete) { + val failCount = numFilesToDelete - numFilesDeleted + Log.w(TAG, "Failed to delete $failCount cached shared file(s).") + } else if (numFilesToDelete > 0 && numFilesDeleted == numFilesToDelete) { + Log.i(TAG, "Cached shared files deleted.") + } + cachedIntentFiles.clear() + } + } + + private var clearKeyReceiver: BroadcastReceiver? = null + + override fun onCreate(savedInstanceState: Bundle?) { + Log.i(TAG, "ScreenLockActionBarActivity.onCreate(" + savedInstanceState + ")") + + val locked = KeyCachingService.isLocked(this) && isScreenLockEnabled(this) && getLocalNumber(this) != null + routeApplicationState(locked) + + super.onCreate(savedInstanceState) + + if (!isFinishing) { + initializeClearKeyReceiver() + onCreate(savedInstanceState, true) + } + } + + protected open fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {} + + override fun onPause() { + Log.i(TAG, "onPause()") + super.onPause() + } + + override fun onDestroy() { + Log.i(TAG, "ScreenLockActionBarActivity.onDestroy()") + super.onDestroy() + removeClearKeyReceiver(this) + } + + fun onMasterSecretCleared() { + Log.i(TAG, "onMasterSecretCleared()") + if (ApplicationContext.getInstance(this).isAppVisible) routeApplicationState(true) + else finish() + } + + protected fun initFragment(@IdRes target: Int, fragment: T): T? { + return initFragment(target, fragment, null) + } + + protected fun initFragment(@IdRes target: Int, fragment: T, locale: Locale?): T? { + return initFragment(target, fragment, locale, null) + } + + protected fun initFragment(@IdRes target: Int, fragment: T, locale: Locale?, extras: Bundle?): T? { + val args = Bundle() + args.putSerializable(LOCALE_EXTRA, locale) + + if (extras != null) { args.putAll(extras) } + + fragment!!.setArguments(args) + supportFragmentManager.beginTransaction() + .replace(target, fragment) + .commitAllowingStateLoss() + return fragment + } + + private fun routeApplicationState(locked: Boolean) { + + lifecycleScope.launch { + val state = getApplicationState(locked) + + // Note: getIntentForState is suspend because it _may_ perform a file copy for any + // incoming file to be shared - so we do this off the main thread. + val intent = getIntentForState(state) + + if (intent != null) { + startActivity(intent) + finish() + } + } + } + + private suspend fun getIntentForState(state: Int): Intent? { + Log.i(TAG, "routeApplicationState() - ${getStateName(state)}") + + return when (state) { + STATE_SCREEN_LOCKED -> getScreenUnlockIntent() // Note: This is a suspend function + STATE_UPGRADE_DATABASE -> getUpgradeDatabaseIntent() + STATE_WELCOME_SCREEN -> getWelcomeIntent() + STATE_DATABASE_MIGRATE -> getRoutedIntent(DatabaseMigrationStateActivity::class.java, getConversationListIntent()) + else -> null + } + } + + private fun getApplicationState(locked: Boolean): Int { + return if (migrationManager.migrationState.value.shouldShowUI) { + STATE_DATABASE_MIGRATE + } else if (getLocalNumber(this) == null) { + STATE_WELCOME_SCREEN + } else if (locked) { + STATE_SCREEN_LOCKED + } else if (DatabaseUpgradeActivity.isUpdate(this)) { + STATE_UPGRADE_DATABASE + } else { + STATE_NORMAL + } + } + + private val DatabaseMigrationManager.MigrationState.shouldShowUI: Boolean + get() = this is DatabaseMigrationManager.MigrationState.Migrating || this is DatabaseMigrationManager.MigrationState.Error + + private suspend fun getScreenUnlockIntent(): Intent { + // If this is an attempt to externally share something while the app is locked then we need + // to rewrite the intent to reference a cached copy of the shared file. + // Note: We CANNOT just add `Intent.FLAG_GRANT_READ_URI_PERMISSION` to this intent as we + // pass it around because we don't have permission to do that (i.e., it doesn't work). + if (intent.action == "android.intent.action.SEND") { + val rewrittenIntent = rewriteShareIntentUris(intent) + return getRoutedIntent(ScreenLockActivity::class.java, rewrittenIntent) + } else { + return getRoutedIntent(ScreenLockActivity::class.java, intent) + } + } + + // Unused at present - but useful for debugging! + private fun printIntentExtras(i: Intent, prefix: String = "") { + val bundle = i.extras + if (bundle != null) { + for (key in bundle.keySet()) { + Log.w(TAG, "${prefix}: Key: " + key + " --> Value: " + bundle.get(key)) + } + } + } + + // Unused at present - but useful for debugging! + private fun printIntentClipData(i: Intent, prefix: String = "") { + i.clipData?.let { clipData -> + for (i in 0 until clipData.itemCount) { + val item = clipData.getItemAt(i) + if (item.uri != null) { Log.i(TAG, "${prefix}: Item $i has uri: ${item.uri}") } + if (item.text != null) { Log.i(TAG, "${prefix}: Item $i has text: ${item.text}") } + } + } + } + + // Rewrite the original share Intent, copying any URIs it contains to our app's private cache, + // and return a new "rewritten" Intent that references the local copies of URIs via our FileProvider. + // We do this to prevent a SecurityException being thrown regarding ephemeral permissions to + // view the shared URI which may be available to THIS ScreenLockActivity, but which is NOT + // then valid on the actual ShareActivity which we transfer the Intent through to. With a + // rewritten copy of the original Intent that references our own cached copy of the URI we have + // full control over it. + // Note: We delete any cached file(s) in ConversationActivity.onDestroy. + private suspend fun rewriteShareIntentUris(originalIntent: Intent): Intent? = withContext(Dispatchers.IO) { + val rewrittenIntent = Intent(originalIntent) + + // Clear original clipData + rewrittenIntent.clipData = null + rewrittenIntent.removeExtra(Intent.EXTRA_STREAM) + + // Grab and rewrite the original intent's clipData - adding it to our rewrittenIntent as we go + val originalClipData = originalIntent.clipData + ?: IntentCompat.getParcelableExtra(originalIntent, Intent.EXTRA_STREAM, Uri::class.java)?.let { uri -> + // If the original intent has a single Uri in the Intent.EXTRA_STREAM extra, we create a ClipData + // with that Uri to mimic the original clipData structure. + ClipData.newUri(contentResolver, "Shared data", uri) + } + + + originalClipData?.let { clipData -> + var newClipData: ClipData? = null + for (i in 0 until clipData.itemCount) { + val item = clipData.getItemAt(i) + val originalUri = item.uri + + if (originalUri != null) { + // Get a suitable filename and copy the file to our cache directory.. + val filename = FilenameUtils.getFilenameFromUri(this@ScreenLockActionBarActivity, originalUri) + val localUri = copyFileToCache(originalUri, filename) + + if (localUri != null) { + // ..then create the new ClipData with the localUri and filename. + if (newClipData == null) { + newClipData = ClipData.newUri(contentResolver, filename, localUri) + + // Make sure to also set the "android.intent.extra.STREAM" extra + rewrittenIntent.putExtra(Intent.EXTRA_STREAM, localUri) + } else { + newClipData.addItem(ClipData.Item(localUri)) + } + } else { + Log.e(TAG, "Could not rewrite Uri - bailing.") + return@withContext null + } + } + } + + if (newClipData != null) { + Log.i(TAG, "Adding newClipData to rewrittenIntent.") + rewrittenIntent.clipData = newClipData + rewrittenIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + + rewrittenIntent + } + + private suspend fun copyFileToCache(uri: Uri, filename: String): Uri? = withContext(Dispatchers.IO) { + try { + val inputStream = contentResolver.openInputStream(uri) + if (inputStream == null) { + Log.w(TAG, "Could not open input stream to cache shared content - aborting.") + return@withContext null + } + + // Create a File in your cache directory using the retrieved name + val tempFile = File(cacheDir, filename) + inputStream.use { input -> + FileOutputStream(tempFile).use { output -> + input.copyTo(output) + } + } + + // Verify the file actually exists and isn't empty + if (!tempFile.exists() || tempFile.length() == 0L) { + Log.w(TAG, "Failed to copy the file to cache or the file is empty.") + return@withContext null + } + + // Record the file so you can delete it when you're done + cachedIntentFiles.add(tempFile) + + // Return a FileProvider Uri that references this cached file + FileProviderUtil.getUriFor(this@ScreenLockActionBarActivity, tempFile) + } catch (e: Exception) { + Log.e(TAG, "Error copying file to cache", e) + null + } + } + + private fun getUpgradeDatabaseIntent(): Intent { return getRoutedIntent(DatabaseUpgradeActivity::class.java, getConversationListIntent()) } + + private fun getWelcomeIntent(): Intent { return getRoutedIntent(LandingActivity::class.java, getConversationListIntent()) } + + private fun getConversationListIntent(): Intent { return Intent(this, HomeActivity::class.java) } + + private fun getRoutedIntent(destination: Class<*>?, nextIntent: Intent?): Intent { + val intent = Intent(this, destination) + if (nextIntent != null) { intent.putExtra("next_intent", nextIntent) } + return intent + } + + private fun initializeClearKeyReceiver() { + Log.i(TAG, "initializeClearKeyReceiver()") + this.clearKeyReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + Log.i(TAG, "onReceive() for clear key event") + onMasterSecretCleared() + } + } + + val filter = IntentFilter(KeyCachingService.CLEAR_KEY_EVENT) + ContextCompat.registerReceiver( + this, + clearKeyReceiver, filter, + KeyCachingService.KEY_PERMISSION, + null, + ContextCompat.RECEIVER_NOT_EXPORTED + ) + } + + private fun removeClearKeyReceiver(context: Context) { + if (clearKeyReceiver != null) { + context.unregisterReceiver(clearKeyReceiver) + clearKeyReceiver = null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActivity.kt new file mode 100644 index 0000000000..1c5ba1e8b3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ScreenLockActivity.kt @@ -0,0 +1,314 @@ +/* + * Copyright (C) 2011 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 . + */ +package org.thoughtcrime.securesms + +import android.app.KeyguardManager +import android.content.ComponentName +import android.content.Intent +import android.content.ServiceConnection +import android.graphics.PorterDuff +import android.os.Bundle +import android.os.IBinder +import android.view.View +import android.view.animation.Animation +import android.view.animation.BounceInterpolator +import android.view.animation.TranslateAnimation +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.biometric.BiometricPrompt +import androidx.biometric.BiometricManager +import androidx.core.content.ContextCompat +import com.squareup.phrase.Phrase +import java.lang.Exception +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences.Companion.isScreenLockEnabled +import org.session.libsession.utilities.TextSecurePreferences.Companion.setScreenLockEnabled +import org.session.libsession.utilities.TextSecurePreferences.Companion.setScreenLockTimeout +import org.session.libsession.utilities.ThemeUtil +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.crypto.BiometricSecretProvider +import org.thoughtcrime.securesms.service.KeyCachingService +import org.thoughtcrime.securesms.service.KeyCachingService.KeySetBinder + +class ScreenLockActivity : BaseActionBarActivity() { + private val TAG: String = ScreenLockActivity::class.java.simpleName + + private lateinit var fingerprintPrompt: ImageView + + private var biometricPrompt: BiometricPrompt? = null + private var promptInfo: BiometricPrompt.PromptInfo? = null + private val biometricSecretProvider = BiometricSecretProvider() + + private var authenticated = false + private var failure = false + private var hasSignatureObject = true + + private var keyCachingService: KeyCachingService? = null + + private var accentColor: Int = -1 + private var errorColor: Int = -1 + + public override fun onCreate(savedInstanceState: Bundle?) { + Log.i(TAG, "Creating ScreenLockActivity") + super.onCreate(savedInstanceState) + + accentColor = ThemeUtil.getThemedColor(this, R.attr.accentColor) + errorColor = ThemeUtil.getThemedColor(this, R.attr.danger) + + setContentView(R.layout.screen_lock_activity) + initializeResources() + + // Start and bind to the KeyCachingService instance. + val bindIntent = Intent(this, KeyCachingService::class.java) + startService(bindIntent) + bindService(bindIntent, object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder) { + keyCachingService = (service as KeySetBinder).service + } + + override fun onServiceDisconnected(name: ComponentName?) { + keyCachingService?.setMasterSecret(Any()) + keyCachingService = null + } + }, BIND_AUTO_CREATE) + + // Set up biometric prompt and prompt info + val context = this + val executor = ContextCompat.getMainExecutor(context) + biometricPrompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() { + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + Log.w(TAG, "Authentication error: $errorCode $errString") + + when (errorCode) { + // User cancelled the biometric overlay by clicking "Cancel" or clicking off it + BiometricPrompt.ERROR_NEGATIVE_BUTTON, + BiometricPrompt.ERROR_USER_CANCELED -> { + onAuthenticationFailed() + finish() + } + + // User made 5 incorrect biometric login attempts so they get a timeout + // Note: The SYSTEM provides the localised error "Too many attempts. Try again later.". + BiometricPrompt.ERROR_LOCKOUT -> { + Toast.makeText(context, errString, Toast.LENGTH_SHORT).show() + finish() + } + + // User made a large number of incorrect biometric login attempts and Android disabled + // the fingerprint sensor until they lock the device then log back in via non-biometric means. + // Note: The SYSTEM provides the localised error "Too many attempts. Fingerprint sensor disabled." + BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> { + Toast.makeText(context, errString, Toast.LENGTH_SHORT).show() + finish() + } + + else -> { + Log.w(TAG, "Unhandled authentication error: $errorCode $errString") + finish() + } + } + } + + override fun onAuthenticationFailed() { + Log.w(TAG, "onAuthenticationFailed()") + showAuthenticationFailedUI() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + Log.i(TAG, "onAuthenticationSucceeded") + + val signature = result.cryptoObject?.signature + + if (signature == null) { + // If we expected a signature but didn't get one, treat this as failure + if (hasSignatureObject) { + onAuthenticationFailed() + } else { + // If there was no signature needed, handle as success + showAuthenticationSuccessUI() + } + return + } + + // Perform signature verification + try { + val random = biometricSecretProvider.getRandomData() + signature.update(random) + val signed = signature.sign() + val verified = biometricSecretProvider.verifySignature(random, signed) + + if (!verified) { + onAuthenticationFailed() + return + } + + showAuthenticationSuccessUI() + } catch (e: Exception) { + Log.e(TAG, "Signature verification failed", e) + onAuthenticationFailed() + } + } + }) + + promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle("Unlock Session") // TODO: Need a string for this, like `lockAppUnlock` -> "Unlock {app_name}" or similar - have informed Rebecca + .setNegativeButtonText(this.applicationContext.getString(R.string.cancel)) + // If we needed it, we could also add things like `setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)` here + .build() + } + + override fun onResume() { + super.onResume() + setLockTypeVisibility() + if (isScreenLockEnabled(this) && !authenticated && !failure) { resumeScreenLock() } + failure = false + } + + override fun onPause() { + super.onPause() + biometricPrompt?.cancelAuthentication() + } + + private fun resumeScreenLock() { + val keyguardManager = getSystemService(KEYGUARD_SERVICE) as KeyguardManager + + // Note: `isKeyguardSecure` just returns whether the keyguard is locked via a pin, pattern, + // or password - in which case it's actually correct to allow the user in, as we have nothing + // to authenticate against! (we use the system authentication - not our own custom auth.). + if (!keyguardManager.isKeyguardSecure) { + Log.w(TAG, "Keyguard not secure...") + setScreenLockEnabled(applicationContext, false) + setScreenLockTimeout(applicationContext, 0) + handleAuthenticated() + return + } + + // Attempt to get a signature for biometric authentication + val signature = biometricSecretProvider.getOrCreateBiometricSignature(this) + hasSignatureObject = (signature != null) + + if (signature != null) { + // Biometrics are enrolled and the key is available + val cryptoObject = BiometricPrompt.CryptoObject(signature) + biometricPrompt?.authenticate(promptInfo!!, cryptoObject) + } else { + // No biometric key available (no biometrics enrolled or key cannot be created) + // Fallback to device credentials (PIN, pattern, or password) + // TODO: Need a string for this, like `lockAppUnlock` -> "Unlock {app_name}" or similar - have informed Rebecca + val intent = keyguardManager.createConfirmDeviceCredentialIntent("Unlock Session", "") + startActivityForResult(intent, 1) + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + } + + public override fun onActivityResult(requestCode: Int, resultcode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultcode, data) + if (requestCode != 1) return + + if (resultcode == RESULT_OK) { + handleAuthenticated() + } else { + Log.w(TAG, "Authentication failed") + failure = true + } + } + + private fun showAuthenticationFailedUI() { + fingerprintPrompt.setImageResource(R.drawable.ic_x) + fingerprintPrompt.background?.setColorFilter(errorColor, PorterDuff.Mode.SRC_IN) + + // Define and perform a "shake" animation on authentication failed + val shake = TranslateAnimation(0f, 30f, 0f, 0f).apply { + duration = 50 + repeatCount = 7 + setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) {} + + override fun onAnimationEnd(animation: Animation?) { + fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp) + fingerprintPrompt.background?.setColorFilter(accentColor, PorterDuff.Mode.SRC_IN) + } + + override fun onAnimationRepeat(animation: Animation?) {} + }) + } + fingerprintPrompt.startAnimation(shake) + } + + private fun showAuthenticationSuccessUI() { + Log.i(TAG, "Authentication successful.") + + fingerprintPrompt.setImageResource(R.drawable.ic_check) + fingerprintPrompt.background?.setColorFilter(accentColor, PorterDuff.Mode.SRC_IN) + + // Animate and call handleAuthenticated() on animation end + fingerprintPrompt.animate() + ?.setInterpolator(BounceInterpolator()) + ?.scaleX(1.1f) + ?.scaleY(1.1f) + ?.setDuration(500) + ?.withEndAction { + fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp) + fingerprintPrompt.background?.setColorFilter(accentColor, PorterDuff.Mode.SRC_IN) + handleAuthenticated() + } + ?.start() + } + + private fun handleAuthenticated() { + authenticated = true + keyCachingService?.setMasterSecret(Any()) + + // The 'nextIntent' will take us to the MainActivity if this is a standard unlock, or it will + // take us to the ShareActivity if this is an external share. + val nextIntent = intent.getParcelableExtra("next_intent") + if (nextIntent == null) { + Log.w(TAG, "Got a null nextIntent - cannot proceed.") + } else { + startActivity(nextIntent) + } + + finish() + } + + private fun setLockTypeVisibility() { + val screenLockEnabled = TextSecurePreferences.isScreenLockEnabled(this) + val biometricManager = BiometricManager.from(this) + val authenticationPossible = biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS + fingerprintPrompt.visibility = if (screenLockEnabled && authenticationPossible) View.VISIBLE else View.GONE + } + + private fun initializeResources() { + val statusTitle = findViewById(R.id.app_lock_status_title) + statusTitle?.text = Phrase.from(applicationContext, R.string.lockAppLocked) + .put(APP_NAME_KEY, getString(R.string.app_name)) + .format().toString() + + fingerprintPrompt = findViewById(R.id.fingerprint_auth_container) + + fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp) + fingerprintPrompt.background?.setColorFilter(accentColor, PorterDuff.Mode.SRC_IN) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt index e948f9da3c..171a2a7049 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt @@ -17,6 +17,7 @@ import android.widget.Space import android.widget.TextView import androidx.annotation.AttrRes import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes import androidx.annotation.LayoutRes import androidx.annotation.StringRes import androidx.annotation.StyleRes @@ -111,8 +112,6 @@ class SessionDialogBuilder(val context: Context) { 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, currentSelected: Int = 0, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.java deleted file mode 100644 index 9bef3e48d9..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.java +++ /dev/null @@ -1,335 +0,0 @@ -/* - * Copyright (C) 2014-2017 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 . - */ - -package org.thoughtcrime.securesms; - -import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.Intent; -import android.database.Cursor; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Bundle; -import android.os.Parcel; -import android.provider.OpenableColumns; -import android.view.MenuItem; -import android.view.View; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.squareup.phrase.Phrase; - -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.DistributionTypes; -import org.session.libsession.utilities.ViewUtil; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.components.SearchToolbar; -import org.thoughtcrime.securesms.contacts.ContactSelectionListFragment; -import org.thoughtcrime.securesms.contacts.ContactSelectionListLoader.DisplayMode; -import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.mms.PartAuthority; -import org.thoughtcrime.securesms.providers.BlobProvider; -import org.thoughtcrime.securesms.util.MediaUtil; - -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; - -import dagger.hilt.android.AndroidEntryPoint; -import network.loki.messenger.R; - -/** - * An activity to quickly share content with contacts - * - * @author Jake McGinty - */ -@AndroidEntryPoint -public class ShareActivity extends PassphraseRequiredActionBarActivity - implements ContactSelectionListFragment.OnContactSelectedListener { - private static final String TAG = ShareActivity.class.getSimpleName(); - - public static final String EXTRA_THREAD_ID = "thread_id"; - public static final String EXTRA_ADDRESS_MARSHALLED = "address_marshalled"; - public static final String EXTRA_DISTRIBUTION_TYPE = "distribution_type"; - - private ContactSelectionListFragment contactsFragment; - private SearchToolbar searchToolbar; - private ImageView searchAction; - private View progressWheel; - private Uri resolvedExtra; - private CharSequence resolvedPlaintext; - private String mimeType; - private boolean isPassingAlongMedia; - - private ResolveMediaTask resolveTask; - - @Override - protected void onCreate(Bundle icicle, boolean ready) { - if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) { - getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, DisplayMode.FLAG_ALL); - } - - getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false); - - setContentView(R.layout.share_activity); - - initializeToolbar(); - initializeResources(); - initializeSearch(); - initializeMedia(); - } - - @Override - protected void onNewIntent(Intent intent) { - Log.i(TAG, "onNewIntent()"); - super.onNewIntent(intent); - setIntent(intent); - initializeMedia(); - } - - @Override - public void onPause() { - super.onPause(); - if (!isPassingAlongMedia && resolvedExtra != null) { - BlobProvider.getInstance().delete(this, resolvedExtra); - - if (!isFinishing()) { - finish(); - } - } - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - onBackPressed(); - return true; - } - return super.onOptionsItemSelected(item); - } - - @Override - public void onBackPressed() { - if (searchToolbar.isVisible()) searchToolbar.collapse(); - else super.onBackPressed(); - } - - private void initializeToolbar() { - TextView tootlbarTitle = findViewById(R.id.title); - tootlbarTitle.setText( - Phrase.from(getApplicationContext(), R.string.shareToSession) - .put(APP_NAME_KEY, getString(R.string.app_name)) - .format().toString() - ); - } - - private void initializeResources() { - progressWheel = findViewById(R.id.progress_wheel); - searchToolbar = findViewById(R.id.search_toolbar); - searchAction = findViewById(R.id.search_action); - contactsFragment = (ContactSelectionListFragment) getSupportFragmentManager().findFragmentById(R.id.contact_selection_list_fragment); - contactsFragment.setOnContactSelectedListener(this); - } - - private void initializeSearch() { - searchAction.setOnClickListener(v -> searchToolbar.display(searchAction.getX() + (searchAction.getWidth() / 2), - searchAction.getY() + (searchAction.getHeight() / 2))); - - searchToolbar.setListener(new SearchToolbar.SearchListener() { - @Override - public void onSearchTextChange(String text) { - if (contactsFragment != null) { - contactsFragment.setQueryFilter(text); - } - } - - @Override - public void onSearchClosed() { - if (contactsFragment != null) { - contactsFragment.resetQueryFilter(); - } - } - }); - } - - private void initializeMedia() { - final Context context = this; - isPassingAlongMedia = false; - - Uri streamExtra = getIntent().getParcelableExtra(Intent.EXTRA_STREAM); - CharSequence charSequenceExtra = getIntent().getCharSequenceExtra(Intent.EXTRA_TEXT); - mimeType = getMimeType(streamExtra); - - if (streamExtra != null && PartAuthority.isLocalUri(streamExtra)) { - isPassingAlongMedia = true; - resolvedExtra = streamExtra; - handleResolvedMedia(getIntent(), false); - } else if (charSequenceExtra != null && mimeType != null && mimeType.startsWith("text/")) { - resolvedPlaintext = charSequenceExtra; - handleResolvedMedia(getIntent(), false); - } else { - contactsFragment.getView().setVisibility(View.GONE); - progressWheel.setVisibility(View.VISIBLE); - resolveTask = new ResolveMediaTask(context); - resolveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, streamExtra); - } - } - - private void handleResolvedMedia(Intent intent, boolean animate) { - long threadId = intent.getLongExtra(EXTRA_THREAD_ID, -1); - int distributionType = intent.getIntExtra(EXTRA_DISTRIBUTION_TYPE, -1); - Address address = null; - - if (intent.hasExtra(EXTRA_ADDRESS_MARSHALLED)) { - Parcel parcel = Parcel.obtain(); - byte[] marshalled = intent.getByteArrayExtra(EXTRA_ADDRESS_MARSHALLED); - parcel.unmarshall(marshalled, 0, marshalled.length); - parcel.setDataPosition(0); - address = parcel.readParcelable(getClassLoader()); - parcel.recycle(); - } - - boolean hasResolvedDestination = threadId != -1 && address != null && distributionType != -1; - - if (!hasResolvedDestination && animate) { - ViewUtil.fadeIn(contactsFragment.getView(), 300); - ViewUtil.fadeOut(progressWheel, 300); - } else if (!hasResolvedDestination) { - contactsFragment.getView().setVisibility(View.VISIBLE); - progressWheel.setVisibility(View.GONE); - } else { - createConversation(threadId, address, distributionType); - } - } - - private void createConversation(long threadId, Address address, int distributionType) { - final Intent intent = getBaseShareIntent(ConversationActivityV2.class); - intent.putExtra(ConversationActivityV2.ADDRESS, address); - intent.putExtra(ConversationActivityV2.THREAD_ID, threadId); - - isPassingAlongMedia = true; - startActivity(intent); - } - - private Intent getBaseShareIntent(final @NonNull Class target) { - final Intent intent = new Intent(this, target); - - if (resolvedExtra != null) { - intent.setDataAndType(resolvedExtra, mimeType); - } else if (resolvedPlaintext != null) { - intent.putExtra(Intent.EXTRA_TEXT, resolvedPlaintext); - intent.setType("text/plain"); - } - - return intent; - } - - private String getMimeType(@Nullable Uri uri) { - if (uri != null) { - final String mimeType = MediaUtil.getMimeType(getApplicationContext(), uri); - if (mimeType != null) return mimeType; - } - return MediaUtil.getCorrectedMimeType(getIntent().getType()); - } - - @Override - public void onContactSelected(String number) { - Recipient recipient = Recipient.from(this, Address.fromExternal(this, number), true); - long existingThread = DatabaseComponent.get(this).threadDatabase().getThreadIdIfExistsFor(recipient); - createConversation(existingThread, recipient.getAddress(), DistributionTypes.DEFAULT); - } - - @Override - public void onContactDeselected(String number) { - } - - @Override - protected void onDestroy() { - super.onDestroy(); - if (resolveTask != null) resolveTask.cancel(true); - } - - @SuppressLint("StaticFieldLeak") - private class ResolveMediaTask extends AsyncTask { - private final Context context; - - ResolveMediaTask(Context context) { - this.context = context; - } - - @Override - protected Uri doInBackground(Uri... uris) { - try { - if (uris.length != 1 || uris[0] == null) { - return null; - } - - InputStream inputStream; - - if ("file".equals(uris[0].getScheme())) { - inputStream = new FileInputStream(uris[0].getPath()); - } else { - inputStream = context.getContentResolver().openInputStream(uris[0]); - } - - if (inputStream == null) { - return null; - } - - Cursor cursor = getContentResolver().query(uris[0], new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}, null, null, null); - String fileName = null; - Long fileSize = null; - - try { - if (cursor != null && cursor.moveToFirst()) { - try { - fileName = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)); - fileSize = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); - } catch (IllegalArgumentException e) { - Log.w(TAG, e); - } - } - } finally { - if (cursor != null) cursor.close(); - } - - return BlobProvider.getInstance() - .forData(inputStream, fileSize == null ? 0 : fileSize) - .withMimeType(mimeType) - .withFileName(fileName) - .createForMultipleSessionsOnDisk(context, e -> Log.w(TAG, "Failed to write to disk.", e)); - } catch (IOException ioe) { - Log.w(TAG, ioe); - return null; - } - } - - @Override - protected void onPostExecute(Uri uri) { - resolvedExtra = uri; - handleResolvedMedia(getIntent(), true); - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt new file mode 100644 index 0000000000..627151140c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2014-2017 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 . + */ +package org.thoughtcrime.securesms + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +// An activity to quickly share content with contacts. +@AndroidEntryPoint +class ShareActivity : FullComposeScreenLockActivity() { + + private val viewModel: ShareViewModel by viewModels() + + companion object { + const val EXTRA_THREAD_ID = "thread_id" + const val EXTRA_ADDRESS_MARSHALLED = "address_marshalled" + const val EXTRA_DISTRIBUTION_TYPE = "distribution_type" + } + + + @Composable + override fun ComposeContent() { + ShareScreen( + viewModel = viewModel, + onBack = { finish() }, + ) + } + + override fun onCreate(icicle: Bundle?, ready: Boolean) { + super.onCreate(icicle, ready) + + initializeMedia() + + lifecycleScope.launch { + viewModel.uiEvents.collect { + when (it) { + is ShareViewModel.ShareUIEvent.GoToScreen -> { + startActivity(it.intent) + } + } + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + initializeMedia() + } + + public override fun onPause() { + super.onPause() + if (viewModel.onPause()) { + if (!isFinishing) { finish() } + } + } + + private fun initializeMedia() { + val streamExtra = intent.getParcelableExtra(Intent.EXTRA_STREAM) + var charSequenceExtra: CharSequence? = null + try { + charSequenceExtra = intent.getCharSequenceExtra(Intent.EXTRA_TEXT) + } + catch (e: Exception) { + // It's not necessarily an issue if there's no text extra when sharing files - but we do + // have to catch any failed attempt. + } + + viewModel.initialiseMedia(streamExtra, charSequenceExtra, intent) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/ShareScreen.kt new file mode 100644 index 0000000000..0fa83bf402 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ShareScreen.kt @@ -0,0 +1,222 @@ +package org.thoughtcrime.securesms + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.thoughtcrime.securesms.groups.compose.MemberItem +import org.thoughtcrime.securesms.ui.SearchBar +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.primaryBlue +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUIElement + + +@Composable +fun ShareScreen( + viewModel: ShareViewModel, + onBack: () -> Unit, +) { + val state by viewModel.uiState.collectAsState() + val hasConversations by viewModel.hasAnyConversations.collectAsState() + + ShareList( + state = state, + contacts = viewModel.contacts.collectAsState().value, + hasConversations = hasConversations, + onContactItemClicked = viewModel::onContactItemClicked, + searchQuery = viewModel.searchQuery.collectAsState().value, + onSearchQueryChanged = viewModel::onSearchQueryChanged, + onSearchQueryClear = {viewModel.onSearchQueryChanged("") }, + onBack = onBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareList( + state: ShareViewModel.UIState, + contacts: List, + hasConversations: Boolean, + onContactItemClicked: (address: Address) -> Unit, + searchQuery: String, + onSearchQueryChanged: (String) -> Unit, + onSearchQueryClear: () -> Unit, + onBack: () -> Unit, +) { + Scaffold( + topBar = { + BackAppBar( + title = Phrase.from(LocalContext.current, R.string.shareToSession) + .put(APP_NAME_KEY, stringResource(R.string.app_name)) + .format().toString(), + onBack = onBack, + ) + }, + ) { paddings -> + Crossfade(state.showLoader) { showLoader -> + if (showLoader) { + Box( + modifier = Modifier.fillMaxSize() + .padding(top = paddings.calculateTopPadding()) + .consumeWindowInsets(paddings), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + Column( + modifier = Modifier + .padding(top = paddings.calculateTopPadding()) + .consumeWindowInsets(paddings), + ) { + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + SearchBar( + query = searchQuery, + onValueChanged = onSearchQueryChanged, + onClear = onSearchQueryClear, + placeholder = stringResource(R.string.search), + modifier = Modifier + .padding(horizontal = LocalDimensions.current.smallSpacing) + .qaTag(R.string.AccessibilityId_groupNameSearch), + backgroundColor = LocalColors.current.backgroundSecondary, + ) + + val scrollState = rememberLazyListState() + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + if (!hasConversations) { + Text( + text = stringResource(id = R.string.conversationsNone), + modifier = Modifier.padding(top = LocalDimensions.current.spacing) + .align(Alignment.CenterHorizontally), + style = LocalType.current.base.copy(color = LocalColors.current.textSecondary) + ) + } else { + LazyColumn( + state = scrollState, + contentPadding = PaddingValues(bottom = paddings.calculateBottomPadding()), + ) { + + items(contacts) { contacts -> + // Each member's view + MemberItem( + address = contacts.address, + onClick = onContactItemClicked, + title = contacts.name, + showProBadge = contacts.showProBadge, + showAsAdmin = false, + avatarUIData = contacts.avatarUIData + ) + } + } + } + } + } + } + } +} + +@Preview +@Composable +private fun PreviewSelectContacts() { + val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + val contacts = List(20) { + ConversationItem( + address = Address.fromSerialized(random), + name = "User $it", + showProBadge = true, + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), + ) + } + + PreviewTheme { + ShareList( + state = ShareViewModel.UIState(false), + contacts = contacts, + hasConversations = true, + onContactItemClicked = {}, + searchQuery = "", + onSearchQueryChanged = {}, + onSearchQueryClear = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewSelectEmptyContacts() { + val contacts = emptyList() + + PreviewTheme { + ShareList( + state = ShareViewModel.UIState(false), + contacts = contacts, + hasConversations = false, + onContactItemClicked = {}, + searchQuery = "", + onSearchQueryChanged = {}, + onSearchQueryClear = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewSelectEmptyContactsWithSearch() { + val contacts = emptyList() + + PreviewTheme { + ShareList( + state = ShareViewModel.UIState(true), + contacts = contacts, + hasConversations = false, + onContactItemClicked = {}, + searchQuery = "Test", + onSearchQueryChanged = {}, + onSearchQueryClear = {}, + onBack = {}, + ) + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt new file mode 100644 index 0000000000..0fde79f347 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt @@ -0,0 +1,303 @@ +package org.thoughtcrime.securesms + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Parcel +import android.provider.OpenableColumns +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.ShareActivity.Companion.EXTRA_ADDRESS_MARSHALLED +import org.thoughtcrime.securesms.ShareActivity.Companion.EXTRA_DISTRIBUTION_TYPE +import org.thoughtcrime.securesms.ShareActivity.Companion.EXTRA_THREAD_ID +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.home.search.getSearchName +import org.thoughtcrime.securesms.mms.PartAuthority +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.providers.BlobUtils +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUtils +import org.thoughtcrime.securesms.util.MediaUtil +import java.io.FileInputStream +import java.io.IOException +import javax.inject.Inject + +@HiltViewModel +class ShareViewModel @Inject constructor( + configFactory: ConfigFactory, + @ApplicationContext private val context: Context, + private val storage: StorageProtocol, + private val threadDatabase: ThreadDatabase, + private val avatarUtils: AvatarUtils, + private val proStatusManager: ProStatusManager, + private val deprecationManager: LegacyGroupDeprecationManager, +): ViewModel(){ + private val TAG = ShareViewModel::class.java.simpleName + + private var resolvedExtra: Uri? = null + private var resolvedPlaintext: CharSequence? = null + private var mimeType: String? = null + private var isPassingAlongMedia = false + + // Input: The search query + private val mutableSearchQuery = MutableStateFlow("") + // Output: The search query + val searchQuery: StateFlow get() = mutableSearchQuery + + // Output: the contact items to display and select from + @OptIn(FlowPreview::class) + val contacts: StateFlow> = combine( + getConversations(), + mutableSearchQuery.debounce(100L), + ::filterContacts + ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val hasAnyConversations: StateFlow = + getConversations() + .map { it.isNotEmpty() } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + private val _uiEvents = MutableSharedFlow(extraBufferCapacity = 1) + val uiEvents: SharedFlow get() = _uiEvents + + private val _uiState = MutableStateFlow(UIState(false)) + val uiState: StateFlow get() = _uiState + + + @OptIn(ExperimentalCoroutinesApi::class) + private fun getConversations():Flow>> = flow { + val cursor = threadDatabase.conversationList + val result = mutableSetOf>() + threadDatabase.readerFor(cursor).use { reader -> + while (reader.next != null) { + val thread = reader.current + result.add(Pair(thread.recipient, thread.lastMessage?.timestamp ?: 0)) + } + } + + emit(result) + } + + private suspend fun filterContacts( + contacts: Collection>, + query: String, + ): List { + return contacts.filter { + if(it.first.isLegacyGroupRecipient && deprecationManager.isDeprecated) return@filter false // ignore legacy group when deprecated + if(it.first.isCommunityRecipient) { // ignore communities without write access + val threadId = storage.getThreadId(it.first) ?: return@filter false + val openGroup = storage.getOpenGroup(threadId) ?: return@filter false + if(!openGroup.canWrite) return@filter false + } + if(it.first.isBlocked) return@filter false // ignore blocked contacts + + val name = if(it.first.isLocalNumber) context.getString(R.string.noteToSelf) + else it.first.getSearchName() + + (query.isBlank() || name.contains(query, ignoreCase = true)) + }.sortedWith( + compareBy> { !it.first.isLocalNumber } // NTS come first + .thenByDescending { it.second } // then order by last message time + ).map { + ConversationItem( + name = if(it.first.isLocalNumber) context.getString(R.string.noteToSelf) + else it.first.getSearchName(), + address = it.first.address, + avatarUIData = avatarUtils.getUIDataFromRecipient(it.first), + showProBadge = proStatusManager.shouldShowProBadge(it.first.address) + ) + } + } + + fun onSearchQueryChanged(query: String) { + mutableSearchQuery.value = query + } + + fun onPause(): Boolean{ + if (!isPassingAlongMedia && resolvedExtra != null) { + BlobUtils.getInstance().delete(context, resolvedExtra!!) + return true + } + + return false + } + + fun initialiseMedia(streamExtra: Uri?, charSequenceExtra: CharSequence?, intent: Intent){ + isPassingAlongMedia = false + + mimeType = getMimeType(streamExtra, intent.type) + + if (streamExtra != null && PartAuthority.isLocalUri(streamExtra)) { + isPassingAlongMedia = true + resolvedExtra = streamExtra + handleResolvedMedia(intent) + } else if (charSequenceExtra != null && mimeType != null && mimeType!!.startsWith("text/")) { + resolvedPlaintext = charSequenceExtra + handleResolvedMedia(intent) + } else { + _uiState.update { it.copy(showLoader = true) } + resolveMedia(intent, streamExtra) + } + } + + private fun handleResolvedMedia(intent: Intent) { + val threadId = intent.getLongExtra(EXTRA_THREAD_ID, -1) + val distributionType = intent.getIntExtra(EXTRA_DISTRIBUTION_TYPE, -1) + var address: Address? = null + + if (intent.hasExtra(EXTRA_ADDRESS_MARSHALLED)) { + val parcel = Parcel.obtain() + val marshalled = intent.getByteArrayExtra(EXTRA_ADDRESS_MARSHALLED) + parcel.unmarshall(marshalled!!, 0, marshalled.size) + parcel.setDataPosition(0) + address = parcel.readParcelable(context.classLoader) + parcel.recycle() + } + + val hasResolvedDestination = threadId != -1L && address != null && distributionType != -1 + + if (!hasResolvedDestination) { + _uiState.update { it.copy(showLoader = false) } + } else { + createConversation(threadId, address) + } + } + + private fun resolveMedia(intent: Intent, vararg uris: Uri?){ + viewModelScope.launch(Dispatchers.Default){ + resolvedExtra = getUri(*uris) + handleResolvedMedia(intent) + } + } + + private fun getUri(vararg uris: Uri?): Uri? { + try { + if (uris.size != 1 || uris[0] == null) { + Log.w(TAG, "Invalid URI passed to ResolveMediaTask - bailing.") + return null + } else { + Log.i(TAG, "Resolved URI: " + uris[0]!!.toString() + " - " + uris[0]!!.path) + } + + var inputStream = if ("file" == uris[0]!!.scheme) { + FileInputStream(uris[0]!!.path) + } else { + context.contentResolver.openInputStream(uris[0]!!) + } + + if (inputStream == null) { + Log.w(TAG, "Failed to create input stream during ShareActivity - bailing.") + return null + } + + val cursor = context.contentResolver.query(uris[0]!!, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null) + var fileName: String? = null + var fileSize: Long? = null + + try { + if (cursor != null && cursor.moveToFirst()) { + try { + fileName = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)) + fileSize = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)) + } catch (e: IllegalArgumentException) { + Log.w(TAG, e) + } + } + } finally { + cursor?.close() + } + + return BlobUtils.getInstance() + .forData(inputStream, if (fileSize == null) 0 else fileSize) + .withMimeType(mimeType!!) + .withFileName(fileName!!) + .createForMultipleSessionsOnDisk(context, BlobUtils.ErrorListener { e: IOException? -> Log.w(TAG, "Failed to write to disk.", e) }) + .get() + } catch (ioe: Exception) { + Log.w(TAG, ioe) + return null + } + } + + private fun getMimeType(uri: Uri?, intentType: String?): String? { + if (uri != null) { + val mimeType = MediaUtil.getMimeType(context, uri) + if (mimeType != null) return mimeType + } + return MediaUtil.getJpegCorrectedMimeTypeIfRequired(intentType) + } + + fun onContactItemClicked(address: Address) { + + viewModelScope.launch(Dispatchers.Default) { + val recipient = Recipient.from(context, address, true) + val existingThread = threadDatabase.getThreadIdIfExistsFor(recipient) + createConversation(existingThread, recipient.address) + } + } + + + private fun createConversation(threadId: Long, address: Address?) { + val intent = getBaseShareIntent(ConversationActivityV2::class.java) + intent.putExtra(ConversationActivityV2.ADDRESS, address) + intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) + + isPassingAlongMedia = true + _uiEvents.tryEmit(ShareUIEvent.GoToScreen(intent)) + } + + private fun getBaseShareIntent(target: Class<*>): Intent { + val intent = Intent(context, target) + + if (resolvedExtra != null) { + intent.setDataAndType(resolvedExtra, mimeType) + } else if (resolvedPlaintext != null) { + intent.putExtra(Intent.EXTRA_TEXT, resolvedPlaintext) + intent.setType("text/plain") + } + + return intent + } + + sealed interface ShareUIEvent { + data class GoToScreen(val intent: Intent) : ShareUIEvent + } + + data class UIState( + val showLoader: Boolean + ) +} + +data class ConversationItem( + val address: Address, + val name: String, + val avatarUIData: AvatarUIData, + val showProBadge: Boolean, + val lastMessageSent: Long? = null +) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShortcutLauncherActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ShortcutLauncherActivity.java deleted file mode 100644 index 2090e64925..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/ShortcutLauncherActivity.java +++ /dev/null @@ -1,54 +0,0 @@ -package org.thoughtcrime.securesms; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.TaskStackBuilder; -import androidx.appcompat.app.AppCompatActivity; -import android.widget.Toast; - -import org.session.libsession.utilities.Address; -import org.thoughtcrime.securesms.home.HomeActivity; -import org.session.libsession.utilities.recipients.Recipient; -import org.thoughtcrime.securesms.util.CommunicationActions; - -import network.loki.messenger.R; - -public class ShortcutLauncherActivity extends AppCompatActivity { - - private static final String KEY_SERIALIZED_ADDRESS = "serialized_address"; - - public static Intent createIntent(@NonNull Context context, @NonNull Address address) { - Intent intent = new Intent(context, ShortcutLauncherActivity.class); - intent.setAction(Intent.ACTION_MAIN); - intent.putExtra(KEY_SERIALIZED_ADDRESS, address.serialize()); - - return intent; - } - - @SuppressLint("StaticFieldLeak") - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - String serializedAddress = getIntent().getStringExtra(KEY_SERIALIZED_ADDRESS); - - if (serializedAddress == null) { - Toast.makeText(this, R.string.invalidShortcut, Toast.LENGTH_SHORT).show(); - startActivity(new Intent(this, HomeActivity.class)); - finish(); - return; - } - - Address address = Address.fromSerialized(serializedAddress); - Recipient recipient = Recipient.from(this, address, true); - TaskStackBuilder backStack = TaskStackBuilder.create(this) - .addNextIntent(new Intent(this, HomeActivity.class)); - - CommunicationActions.startConversation(this, recipient, null, backStack); - finish(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShortcutLauncherActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/ShortcutLauncherActivity.kt new file mode 100644 index 0000000000..7c078510d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ShortcutLauncherActivity.kt @@ -0,0 +1,67 @@ +package org.thoughtcrime.securesms + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.TaskStackBuilder +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.home.HomeActivity + +class ShortcutLauncherActivity : AppCompatActivity() { + @SuppressLint("StaticFieldLeak") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val serializedAddress = intent.getStringExtra(KEY_SERIALIZED_ADDRESS) + + if (serializedAddress == null) { + Toast.makeText(this, R.string.invalidShortcut, Toast.LENGTH_SHORT).show() + startActivity(Intent(this, HomeActivity::class.java)) + finish() + return + } + + val backStack = TaskStackBuilder.create(this) + .addNextIntent(Intent(this, HomeActivity::class.java)) + + // start the appropriate conversation activity and finish this one + lifecycleScope.launch(Dispatchers.Default) { + val context = this@ShortcutLauncherActivity + + val address = fromSerialized(serializedAddress) + val recipient = Recipient.from(context, address, true) + val threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + + val intent = Intent(context, ConversationActivityV2::class.java) + intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) + intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) + + backStack.addNextIntent(intent) + backStack.startActivities() + finish() + } + } + + companion object { + private const val KEY_SERIALIZED_ADDRESS = "serialized_address" + + fun createIntent(context: Context, address: Address): Intent { + val intent = Intent(context, ShortcutLauncherActivity::class.java) + intent.setAction(Intent.ACTION_MAIN) + intent.putExtra(KEY_SERIALIZED_ADDRESS, address.toString()) + + return intent + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentServer.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentServer.java deleted file mode 100644 index 176a8c290f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentServer.java +++ /dev/null @@ -1,372 +0,0 @@ -package org.thoughtcrime.securesms.attachments; - - -import android.content.Context; -import android.net.Uri; -import androidx.annotation.NonNull; - -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.mms.PartAuthority; - -import org.session.libsignal.utilities.Hex; -import org.session.libsession.utilities.Util; -import org.session.libsession.messaging.sending_receiving.attachments.Attachment; - -import java.io.BufferedOutputStream; -import java.io.BufferedReader; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.net.InetAddress; -import java.net.ServerSocket; -import java.net.Socket; -import java.net.SocketException; -import java.net.SocketTimeoutException; -import java.net.UnknownHostException; -import java.security.MessageDigest; -import java.util.Locale; -import java.util.Map; -import java.util.Properties; -import java.util.StringTokenizer; - -/** - * @author Stefan "frostymarvelous" Froelich - */ -public class AttachmentServer implements Runnable { - - private static final String TAG = AttachmentServer.class.getSimpleName(); - - private final Context context; - private final Attachment attachment; - private final ServerSocket socket; - private final int port; - private final String auth; - - private volatile boolean isRunning; - - public AttachmentServer(Context context, Attachment attachment) - throws IOException - { - try { - 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(); - this.auth = Hex.toStringCondensed(Util.getSecretBytes(16)); - - this.socket.setSoTimeout(5000); - } catch (UnknownHostException e) { - throw new AssertionError(e); - } - } - - public Uri getUri() { - return Uri.parse(String.format(Locale.ROOT, "http://127.0.0.1:%d/%s", port, auth)); - } - - public void start() { - isRunning = true; - new Thread(this).start(); - } - - public void stop() { - isRunning = false; - } - - @Override - public void run() { - while (isRunning) { - Socket client = null; - - try { - client = socket.accept(); - - if (client != null) { - StreamToMediaPlayerTask task = new StreamToMediaPlayerTask(client, "/" + auth); - - if (task.processRequest()) { - task.execute(); - } - } - - } catch (SocketTimeoutException e) { - Log.w(TAG, e); - } catch (IOException e) { - Log.e(TAG, "Error connecting to client", e); - } finally { - try {if (client != null) client.close();} catch (IOException e) {} - } - } - - Log.d(TAG, "Proxy interrupted. Shutting down."); - } - - - private class StreamToMediaPlayerTask { - - private final @NonNull Socket client; - private final @NonNull String auth; - - private long cbSkip; - private Properties parameters; - private Properties request; - private Properties requestHeaders; -// private String filePath; - - public StreamToMediaPlayerTask(@NonNull Socket client, @NonNull String auth) { - this.client = client; - this.auth = auth; - } - - public boolean processRequest() throws IOException { - InputStream is = client.getInputStream(); - final int bufferSize = 8192; - byte[] buffer = new byte[bufferSize]; - int splitByte = 0; - int readLength = 0; - - { - int read = is.read(buffer, 0, bufferSize); - while (read > 0) { - readLength += read; - splitByte = findHeaderEnd(buffer, readLength); - if (splitByte > 0) - break; - read = is.read(buffer, readLength, bufferSize - readLength); - } - } - - // Create a BufferedReader for parsing the header. - ByteArrayInputStream hbis = new ByteArrayInputStream(buffer, 0, readLength); - BufferedReader hin = new BufferedReader(new InputStreamReader(hbis)); - - request = new Properties(); - parameters = new Properties(); - requestHeaders = new Properties(); - - try { - decodeHeader(hin, request, parameters, requestHeaders); - } catch (InterruptedException e1) { - Log.e(TAG, "Exception: " + e1.getMessage()); - e1.printStackTrace(); - } - - for (Map.Entry e : requestHeaders.entrySet()) { - Log.i(TAG, "Header: " + e.getKey() + " : " + e.getValue()); - } - - String range = requestHeaders.getProperty("range"); - - if (range != null) { - Log.i(TAG, "range is: " + range); - range = range.substring(6); - int charPos = range.indexOf('-'); - if (charPos > 0) { - range = range.substring(0, charPos); - } - cbSkip = Long.parseLong(range); - Log.i(TAG, "range found!! " + cbSkip); - } - - if (!"GET".equals(request.get("method"))) { - Log.e(TAG, "Only GET is supported: " + request.get("method")); - return false; - } - - String receivedAuth = request.getProperty("uri"); - - if (receivedAuth == null || !MessageDigest.isEqual(receivedAuth.getBytes(), auth.getBytes())) { - Log.w(TAG, "Bad auth token!"); - return false; - } - -// filePath = request.getProperty("uri"); - - return true; - } - - protected void execute() throws IOException { - InputStream inputStream = PartAuthority.getAttachmentStream(context, attachment.getDataUri()); - long fileSize = attachment.getSize(); - - String headers = ""; - if (cbSkip > 0) {// It is a seek or skip request if there's a Range - // header - headers += "HTTP/1.1 206 Partial Content\r\n"; - headers += "Content-Type: " + attachment.getContentType() + "\r\n"; - headers += "Accept-Ranges: bytes\r\n"; - headers += "Content-Length: " + (fileSize - cbSkip) + "\r\n"; - headers += "Content-Range: bytes " + cbSkip + "-" + (fileSize - 1) + "/" + fileSize + "\r\n"; - headers += "Connection: Keep-Alive\r\n"; - headers += "\r\n"; - } else { - headers += "HTTP/1.1 200 OK\r\n"; - headers += "Content-Type: " + attachment.getContentType() + "\r\n"; - headers += "Accept-Ranges: bytes\r\n"; - headers += "Content-Length: " + fileSize + "\r\n"; - headers += "Connection: Keep-Alive\r\n"; - headers += "\r\n"; - } - - Log.i(TAG, "headers: " + headers); - - OutputStream output = null; - byte[] buff = new byte[64 * 1024]; - try { - output = new BufferedOutputStream(client.getOutputStream(), 32 * 1024); - output.write(headers.getBytes()); - - inputStream.skip(cbSkip); -// dataSource.skipFully(data, cbSkip);//try to skip as much as possible - - // Loop as long as there's stuff to send and client has not closed - int cbRead; - while (!client.isClosed() && (cbRead = inputStream.read(buff, 0, buff.length)) != -1) { - output.write(buff, 0, cbRead); - } - } - catch (SocketException socketException) { - Log.e(TAG, "SocketException() thrown, proxy client has probably closed. This can exit harmlessly"); - } - catch (Exception e) { - Log.e(TAG, "Exception thrown from streaming task:"); - Log.e(TAG, e.getClass().getName() + " : " + e.getLocalizedMessage()); - } - - // Cleanup - try { - if (output != null) { - output.close(); - } - client.close(); - } - catch (IOException e) { - Log.e(TAG, "IOException while cleaning up streaming task:"); - Log.e(TAG, e.getClass().getName() + " : " + e.getLocalizedMessage()); - e.printStackTrace(); - } - } - - /** - * Find byte index separating header from body. It must be the last byte of - * the first two sequential new lines. - **/ - private int findHeaderEnd(final byte[] buf, int rlen) { - int splitbyte = 0; - while (splitbyte + 3 < rlen) { - if (buf[splitbyte] == '\r' && buf[splitbyte + 1] == '\n' - && buf[splitbyte + 2] == '\r' && buf[splitbyte + 3] == '\n') - return splitbyte + 4; - splitbyte++; - } - return 0; - } - - - /** - * Decodes the sent headers and loads the data into java Properties' key - - * value pairs - **/ - private void decodeHeader(BufferedReader in, Properties pre, - Properties parms, Properties header) throws InterruptedException { - try { - // Read the request line - String inLine = in.readLine(); - if (inLine == null) - return; - StringTokenizer st = new StringTokenizer(inLine); - if (!st.hasMoreTokens()) - Log.e(TAG, "BAD REQUEST: Syntax error. Usage: GET /example/file.html"); - - String method = st.nextToken(); - pre.put("method", method); - - if (!st.hasMoreTokens()) - Log.e(TAG, "BAD REQUEST: Missing URI. Usage: GET /example/file.html"); - - String uri = st.nextToken(); - - // Decode parameters from the URI - int qmi = uri.indexOf('?'); - if (qmi >= 0) { - decodeParms(uri.substring(qmi + 1), parms); - uri = decodePercent(uri.substring(0, qmi)); - } else - uri = decodePercent(uri); - - // If there's another token, it's protocol version, - // followed by HTTP headers. Ignore version but parse headers. - // NOTE: this now forces header names lowercase since they are - // case insensitive and vary by client. - if (st.hasMoreTokens()) { - String line = in.readLine(); - while (line != null && line.trim().length() > 0) { - int p = line.indexOf(':'); - if (p >= 0) - header.put(line.substring(0, p).trim().toLowerCase(), - line.substring(p + 1).trim()); - line = in.readLine(); - } - } - - pre.put("uri", uri); - } catch (IOException ioe) { - Log.e(TAG, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage()); - } - } - - /** - * Decodes parameters in percent-encoded URI-format ( e.g. - * "name=Jack%20Daniels&pass=Single%20Malt" ) and adds them to given - * Properties. NOTE: this doesn't support multiple identical keys due to the - * simplicity of Properties -- if you need multiples, you might want to - * replace the Properties with a Hashtable of Vectors or such. - */ - private void decodeParms(String parms, Properties p) - throws InterruptedException { - if (parms == null) - return; - - StringTokenizer st = new StringTokenizer(parms, "&"); - while (st.hasMoreTokens()) { - String e = st.nextToken(); - int sep = e.indexOf('='); - if (sep >= 0) - p.put(decodePercent(e.substring(0, sep)).trim(), - decodePercent(e.substring(sep + 1))); - } - } - - /** - * Decodes the percent encoding scheme.
- * For example: "an+example%20string" -> "an example string" - */ - private String decodePercent(String str) throws InterruptedException { - try { - StringBuffer sb = new StringBuffer(); - for (int i = 0; i < str.length(); i++) { - char c = str.charAt(i); - switch (c) { - case '+': - sb.append(' '); - break; - case '%': - sb.append((char) Integer.parseInt( - str.substring(i + 1, i + 3), 16)); - i += 2; - break; - default: - sb.append(c); - break; - } - } - return sb.toString(); - } catch (Exception e) { - Log.e(TAG, "BAD REQUEST: Bad percent-encoding."); - return null; - } - } - } -} \ No newline at end of file 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 72d8c2aabe..0ca5aacf4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -3,9 +3,7 @@ package org.thoughtcrime.securesms.attachments import android.content.Context 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.MarkAsDeletedMessage import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId @@ -29,15 +27,16 @@ import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.Database import org.thoughtcrime.securesms.database.MessagingDatabase import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.events.PartProgressEvent import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.util.MediaUtil import java.io.IOException import java.io.InputStream +import javax.inject.Provider -class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), MessageDataProvider { +class DatabaseAttachmentProvider(context: Context, helper: Provider) : Database(context, helper), MessageDataProvider { override fun getAttachmentStream(attachmentId: Long): SessionServiceAttachmentStream? { val attachmentDatabase = DatabaseComponent.get(context).attachmentDatabase() @@ -91,17 +90,17 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) return messagingDatabase.getMessageFor(timestamp, author)!!.body } - override fun getAttachmentIDsFor(messageID: Long): List { + override fun getAttachmentIDsFor(mmsMessageId: Long): List { return DatabaseComponent.get(context) .attachmentDatabase() - .getAttachmentsForMessage(messageID).mapNotNull { + .getAttachmentsForMessage(mmsMessageId).mapNotNull { if (it.isQuote) return@mapNotNull null it.attachmentId.rowId } } - override fun getLinkPreviewAttachmentIDFor(messageID: Long): Long? { - val message = DatabaseComponent.get(context).mmsDatabase().getOutgoingMessage(messageID) + override fun getLinkPreviewAttachmentIDFor(mmsMessageId: Long): Long? { + val message = DatabaseComponent.get(context).mmsDatabase().getOutgoingMessage(mmsMessageId) return message.linkPreviews.firstOrNull()?.attachmentId?.rowId } @@ -131,23 +130,20 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) ), threadId) } - override fun isMmsOutgoing(mmsMessageId: Long): Boolean { - val mmsDb = DatabaseComponent.get(context).mmsDatabase() - return mmsDb.getMessage(mmsMessageId).use { cursor -> - mmsDb.readerFor(cursor).next - }?.isOutgoing ?: false - } - - override fun isOutgoingMessage(timestamp: Long): Boolean { - val smsDatabase = DatabaseComponent.get(context).smsDatabase() - val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() - return smsDatabase.isOutgoingMessage(timestamp) || mmsDatabase.isOutgoingMessage(timestamp) + override fun isOutgoingMessage(id: MessageId): Boolean { + return if (id.mms) { + DatabaseComponent.get(context).mmsDatabase().isOutgoingMessage(id.id) + } else { + DatabaseComponent.get(context).smsDatabase().isOutgoingMessage(id.id) + } } - override fun isDeletedMessage(timestamp: Long): Boolean { - val smsDatabase = DatabaseComponent.get(context).smsDatabase() - val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() - return smsDatabase.isDeletedMessage(timestamp) || mmsDatabase.isDeletedMessage(timestamp) + override fun isDeletedMessage(id: MessageId): Boolean { + return if (id.mms) { + DatabaseComponent.get(context).mmsDatabase().isDeletedMessage(id.id) + } else { + DatabaseComponent.get(context).smsDatabase().isDeletedMessage(id.id) + } } override fun handleSuccessfulAttachmentUpload(attachmentId: Long, attachmentStream: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult) { @@ -160,7 +156,7 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) attachmentStream.preview, attachmentStream.width, attachmentStream.height, Optional.fromNullable(uploadResult.digest), - attachmentStream.fileName, + attachmentStream.filename, attachmentStream.voiceNote, attachmentStream.caption, uploadResult.url); @@ -174,12 +170,7 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) database.handleFailedAttachmentUpload(databaseAttachment.attachmentId) } - override fun getMessageID(serverID: Long): Long? { - val openGroupMessagingDatabase = DatabaseComponent.get(context).lokiMessageDatabase() - return openGroupMessagingDatabase.getMessageID(serverID) - } - - override fun getMessageID(serverId: Long, threadId: Long): Pair? { + override fun getMessageID(serverId: Long, threadId: Long): MessageId? { val messageDB = DatabaseComponent.get(context).lokiMessageDatabase() return messageDB.getMessageID(serverId, threadId) } @@ -194,69 +185,56 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) val messages = component.mmsSmsDatabase().getUserMessages(threadId, userPubKey) val messageDatabase = component.lokiMessageDatabase() return messages.mapNotNull { - messageDatabase.getMessageServerHash( - messageID = it.id, - mms = it.isMms - ) + messageDatabase.getMessageServerHash(it.messageId) } } - 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, mms = !isSms) + override fun deleteMessage(messageId: MessageId) { + if (messageId.mms) { + DatabaseComponent.get(context).mmsDatabase().deleteMessage(messageId.id) + } else { + DatabaseComponent.get(context).smsDatabase().deleteMessage(messageId.id) + } - threadId ?: return - timestamp ?: return - MessagingModuleConfiguration.shared.lastSentTimestampCache.delete(threadId, timestamp) + DatabaseComponent.get(context).lokiMessageDatabase().deleteMessage(messageId) + DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageId) } override fun deleteMessages(messageIDs: List, threadId: Long, isSms: Boolean) { val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() else DatabaseComponent.get(context).mmsDatabase() - val messages = messageIDs.mapNotNull { runCatching { messagingDatabase.getMessageRecord(it) }.getOrNull() } - - // Perform local delete - messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId) - - // Perform online delete - DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs) + messagingDatabase.deleteMessages(messageIDs) + DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs, isSms = isSms) 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 markMessageAsDeleted(timestamp: Long, author: String, displayedMessage: String) { + override fun markMessageAsDeleted(messageId: MessageId, displayedMessage: String) { val database = DatabaseComponent.get(context).mmsSmsDatabase() - val address = Address.fromSerialized(author) - val message = database.getMessageFor(timestamp, address) ?: return Log.w("", "Failed to find message to mark as deleted") + val message = database.getMessageById(messageId) ?: return Log.w("", "Failed to find message to mark as deleted") markMessagesAsDeleted( messages = listOf(MarkAsDeletedMessage( - messageId = message.id, + messageId = message.messageId, isOutgoing = message.isOutgoing )), - isSms = !message.isMms, displayedMessage = displayedMessage ) } override fun markMessagesAsDeleted( messages: List, - isSms: Boolean, displayedMessage: String ) { - val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() - else DatabaseComponent.get(context).mmsDatabase() + val smsDatabase = DatabaseComponent.get(context).smsDatabase() + val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() messages.forEach { message -> - messagingDatabase.markAsDeleted(message.messageId, message.isOutgoing, displayedMessage) + if (message.messageId.mms) { + mmsDatabase.markAsDeleted(message.messageId.id, message.isOutgoing, displayedMessage) + } else { + smsDatabase.markAsDeleted(message.messageId.id, message.isOutgoing, displayedMessage) + } } } @@ -265,21 +243,11 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) serverHashes: List, displayedMessage: String ) { - val sendersForHashes = DatabaseComponent.get(context).lokiMessageDatabase() + val markAsDeleteMessages = DatabaseComponent.get(context).lokiMessageDatabase() .getSendersForHashes(threadId, serverHashes.toSet()) + .map { MarkAsDeletedMessage(messageId = it.messageId, isOutgoing = it.isOutgoing) } - val smsMessages = sendersForHashes.asSequence() - .filter { it.isSms } - .map { msg -> MarkAsDeletedMessage(messageId = msg.messageId, isOutgoing = msg.isOutgoing) } - .toList() - - val mmsMessages = sendersForHashes.asSequence() - .filter { !it.isSms } - .map { msg -> MarkAsDeletedMessage(messageId = msg.messageId, isOutgoing = msg.isOutgoing) } - .toList() - - markMessagesAsDeleted(smsMessages, isSms = true, displayedMessage) - markMessagesAsDeleted(mmsMessages, isSms = false, displayedMessage) + markMessagesAsDeleted(markAsDeleteMessages, displayedMessage) } override fun markUserMessagesAsDeleted( @@ -288,25 +256,19 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) sender: String, displayedMessage: String ) { - val mmsMessages = mutableListOf() - val smsMessages = mutableListOf() - - DatabaseComponent.get(context).mmsSmsDatabase().getUserMessages(threadId, sender) + val toDelete = DatabaseComponent.get(context).mmsSmsDatabase().getUserMessages(threadId, sender) + .asSequence() .filter { it.timestamp <= until } - .forEach { record -> - if (record.isMms) { - mmsMessages.add(MarkAsDeletedMessage(record.id, record.isOutgoing)) - } else { - smsMessages.add(MarkAsDeletedMessage(record.id, record.isOutgoing)) - } + .map { record -> + MarkAsDeletedMessage(messageId = record.messageId, isOutgoing = record.isOutgoing) } + .toList() - markMessagesAsDeleted(smsMessages, isSms = true, displayedMessage) - markMessagesAsDeleted(mmsMessages, isSms = false, displayedMessage) + markMessagesAsDeleted(toDelete, displayedMessage) } - override fun getServerHashForMessage(messageID: Long, mms: Boolean): String? = - DatabaseComponent.get(context).lokiMessageDatabase().getMessageServerHash(messageID, mms) + override fun getServerHashForMessage(messageID: MessageId): String? = + DatabaseComponent.get(context).lokiMessageDatabase().getMessageServerHash(messageID) override fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? = DatabaseComponent.get(context).attachmentDatabase() @@ -340,12 +302,11 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) .withStream(`is`) .withContentType(attachment.contentType) .withLength(attachment.size) - .withFileName(attachment.fileName) + .withFileName(attachment.filename) .withVoiceNote(attachment.isVoiceNote) .withWidth(attachment.width) .withHeight(attachment.height) .withCaption(attachment.caption) - .withListener { total: Long, progress: Long -> EventBus.getDefault().postSticky(PartProgressEvent(attachment, total, progress)) } .build() } catch (ioe: IOException) { Log.w("Loki", "Couldn't open attachment", ioe) @@ -356,18 +317,13 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) } fun DatabaseAttachment.toAttachmentPointer(): SessionServiceAttachmentPointer { - return SessionServiceAttachmentPointer(attachmentId.rowId, contentType, key?.toByteArray(), Optional.fromNullable(size.toInt()), Optional.absent(), width, height, Optional.fromNullable(digest), Optional.fromNullable(fileName), isVoiceNote, Optional.fromNullable(caption), url) -} - -fun SessionServiceAttachmentPointer.toSignalPointer(): SignalServiceAttachmentPointer { - return SignalServiceAttachmentPointer(id,contentType,key?.toByteArray() ?: byteArrayOf(), size, preview, width, height, digest, fileName, voiceNote, caption, url) + return SessionServiceAttachmentPointer(attachmentId.rowId, contentType, key?.toByteArray(), Optional.fromNullable(size.toInt()), Optional.absent(), width, height, Optional.fromNullable(digest), filename, isVoiceNote, Optional.fromNullable(caption), url) } fun DatabaseAttachment.toAttachmentStream(context: Context): SessionServiceAttachmentStream { val stream = PartAuthority.getAttachmentStream(context, this.dataUri!!) - val listener = SignalServiceAttachment.ProgressListener { total: Long, progress: Long -> EventBus.getDefault().postSticky(PartProgressEvent(this, total, progress))} - var attachmentStream = SessionServiceAttachmentStream(stream, this.contentType, this.size, Optional.fromNullable(this.fileName), this.isVoiceNote, Optional.absent(), this.width, this.height, Optional.fromNullable(this.caption), listener) + var attachmentStream = SessionServiceAttachmentStream(stream, this.contentType, this.size, this.filename, this.isVoiceNote, Optional.absent(), this.width, this.height, Optional.fromNullable(this.caption)) attachmentStream.attachmentId = this.attachmentId.rowId attachmentStream.isAudio = MediaUtil.isAudio(this) attachmentStream.isGif = MediaUtil.isGif(this) @@ -386,7 +342,7 @@ fun DatabaseAttachment.toSignalAttachmentPointer(): SignalServiceAttachmentPoint if (TextUtils.isEmpty(location)) { return null } // `key` can be empty in an open group context (no encryption means no encryption key) return try { - val id = location!!.toLong() + val id = location?.toLongOrNull() ?: 0L val key = Base64.decode(key!!) SignalServiceAttachmentPointer( id, @@ -397,7 +353,7 @@ fun DatabaseAttachment.toSignalAttachmentPointer(): SignalServiceAttachmentPoint width, height, Optional.fromNullable(digest), - Optional.fromNullable(fileName), + filename, isVoiceNote, Optional.fromNullable(caption), url @@ -409,11 +365,7 @@ fun DatabaseAttachment.toSignalAttachmentPointer(): SignalServiceAttachmentPoint fun DatabaseAttachment.toSignalAttachmentStream(context: Context): SignalServiceAttachmentStream { val stream = PartAuthority.getAttachmentStream(context, this.dataUri!!) - val listener = SignalServiceAttachment.ProgressListener { total: Long, progress: Long -> EventBus.getDefault().postSticky(PartProgressEvent(this, total, progress))} - return SignalServiceAttachmentStream(stream, this.contentType, this.size, Optional.fromNullable(this.fileName), this.isVoiceNote, Optional.absent(), this.width, this.height, Optional.fromNullable(this.caption), listener) + return SignalServiceAttachmentStream(stream, this.contentType, this.size, this.filename, this.isVoiceNote, Optional.absent(), this.width, this.height, Optional.fromNullable(this.caption)) } -fun DatabaseAttachment.shouldHaveImageSize(): Boolean { - return (MediaUtil.isVideo(this) || MediaUtil.isImage(this) || MediaUtil.isGif(this)); -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/MmsNotificationAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/MmsNotificationAttachment.java deleted file mode 100644 index 46dd4367f1..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/MmsNotificationAttachment.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.thoughtcrime.securesms.attachments; - - -import android.net.Uri; -import androidx.annotation.Nullable; - -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress; -import org.thoughtcrime.securesms.database.AttachmentDatabase; -import org.thoughtcrime.securesms.database.MmsDatabase; -import org.session.libsession.messaging.sending_receiving.attachments.Attachment; - -public class MmsNotificationAttachment extends Attachment { - - public MmsNotificationAttachment(int status, long size) { - super("application/mms", getTransferStateFromStatus(status), size, null, null, null, null, null, null, false, 0, 0, false, null, ""); - } - - @Nullable - @Override - public Uri getDataUri() { - return null; - } - - @Nullable - @Override - public Uri getThumbnailUri() { - return null; - } - - private static int getTransferStateFromStatus(int status) { - if (status == MmsDatabase.Status.DOWNLOAD_INITIALIZED || - status == MmsDatabase.Status.DOWNLOAD_NO_CONNECTIVITY) - { - return AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING; - } else if (status == MmsDatabase.Status.DOWNLOAD_CONNECTING) { - return AttachmentTransferProgress.TRANSFER_PROGRESS_STARTED; - } else { - return AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java deleted file mode 100644 index 64c7ac3df2..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java +++ /dev/null @@ -1,193 +0,0 @@ -package org.thoughtcrime.securesms.audio; - -import android.media.AudioFormat; -import android.media.AudioRecord; -import android.media.MediaCodec; -import android.media.MediaCodecInfo; -import android.media.MediaFormat; -import android.media.MediaRecorder; - -import org.session.libsession.utilities.Util; -import org.session.libsignal.utilities.Log; - -import java.io.IOException; -import java.io.OutputStream; -import java.nio.ByteBuffer; - -public class AudioCodec { - - private static final String TAG = AudioCodec.class.getSimpleName(); - - private static final int SAMPLE_RATE = 44100; - private static final int SAMPLE_RATE_INDEX = 4; - private static final int CHANNELS = 1; - private static final int BIT_RATE = 32000; - - private final int bufferSize; - private final MediaCodec mediaCodec; - private final AudioRecord audioRecord; - - private boolean running = true; - private boolean finished = false; - - public AudioCodec() throws IOException { - this.bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); - this.mediaCodec = createMediaCodec(this.bufferSize); - try { - this.audioRecord = createAudioRecord(this.bufferSize); - this.mediaCodec.start(); - audioRecord.startRecording(); - } catch (Exception e) { - Log.w(TAG, e); - mediaCodec.release(); - throw new IOException(e); - } - } - - public synchronized void stop() { - running = false; - while (!finished) Util.wait(this, 0); - } - - public void start(final OutputStream outputStream) { - new Thread(new Runnable() { - @Override - public void run() { - MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); - byte[] audioRecordData = new byte[bufferSize]; - ByteBuffer[] codecInputBuffers = mediaCodec.getInputBuffers(); - ByteBuffer[] codecOutputBuffers = mediaCodec.getOutputBuffers(); - - try { - while (true) { - boolean running = isRunning(); - - handleCodecInput(audioRecord, audioRecordData, mediaCodec, codecInputBuffers, running); - handleCodecOutput(mediaCodec, codecOutputBuffers, bufferInfo, outputStream); - - if (!running) break; - } - } catch (IOException e) { - Log.w(TAG, e); - } finally { - mediaCodec.stop(); - audioRecord.stop(); - - mediaCodec.release(); - audioRecord.release(); - - Util.close(outputStream); - setFinished(); - } - } - }, AudioCodec.class.getSimpleName()).start(); - } - - private synchronized boolean isRunning() { - return running; - } - - private synchronized void setFinished() { - finished = true; - notifyAll(); - } - - private void handleCodecInput(AudioRecord audioRecord, byte[] audioRecordData, - MediaCodec mediaCodec, ByteBuffer[] codecInputBuffers, - boolean running) - { - int length = audioRecord.read(audioRecordData, 0, audioRecordData.length); - int codecInputBufferIndex = mediaCodec.dequeueInputBuffer(10 * 1000); - - if (codecInputBufferIndex >= 0) { - ByteBuffer codecBuffer = codecInputBuffers[codecInputBufferIndex]; - codecBuffer.clear(); - codecBuffer.put(audioRecordData); - mediaCodec.queueInputBuffer(codecInputBufferIndex, 0, length, 0, running ? 0 : MediaCodec.BUFFER_FLAG_END_OF_STREAM); - } - } - - private void handleCodecOutput(MediaCodec mediaCodec, - ByteBuffer[] codecOutputBuffers, - MediaCodec.BufferInfo bufferInfo, - OutputStream outputStream) - throws IOException - { - int codecOutputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 0); - - while (codecOutputBufferIndex != MediaCodec.INFO_TRY_AGAIN_LATER) { - if (codecOutputBufferIndex >= 0) { - ByteBuffer encoderOutputBuffer = codecOutputBuffers[codecOutputBufferIndex]; - - encoderOutputBuffer.position(bufferInfo.offset); - encoderOutputBuffer.limit(bufferInfo.offset + bufferInfo.size); - - if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != MediaCodec.BUFFER_FLAG_CODEC_CONFIG) { - byte[] header = createAdtsHeader(bufferInfo.size - bufferInfo.offset); - - - outputStream.write(header); - - byte[] data = new byte[encoderOutputBuffer.remaining()]; - encoderOutputBuffer.get(data); - outputStream.write(data); - } - - encoderOutputBuffer.clear(); - - mediaCodec.releaseOutputBuffer(codecOutputBufferIndex, false); - } else if (codecOutputBufferIndex== MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { - codecOutputBuffers = mediaCodec.getOutputBuffers(); - } - - codecOutputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 0); - } - - } - - private byte[] createAdtsHeader(int length) { - int frameLength = length + 7; - byte[] adtsHeader = new byte[7]; - - adtsHeader[0] = (byte) 0xFF; // Sync Word - adtsHeader[1] = (byte) 0xF1; // MPEG-4, Layer (0), No CRC - adtsHeader[2] = (byte) ((MediaCodecInfo.CodecProfileLevel.AACObjectLC - 1) << 6); - adtsHeader[2] |= (((byte) SAMPLE_RATE_INDEX) << 2); - adtsHeader[2] |= (((byte) CHANNELS) >> 2); - adtsHeader[3] = (byte) (((CHANNELS & 3) << 6) | ((frameLength >> 11) & 0x03)); - adtsHeader[4] = (byte) ((frameLength >> 3) & 0xFF); - adtsHeader[5] = (byte) (((frameLength & 0x07) << 5) | 0x1f); - adtsHeader[6] = (byte) 0xFC; - - return adtsHeader; - } - - private AudioRecord createAudioRecord(int bufferSize) throws SecurityException { - return new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE, - AudioFormat.CHANNEL_IN_MONO, - AudioFormat.ENCODING_PCM_16BIT, bufferSize * 10); - } - - private MediaCodec createMediaCodec(int bufferSize) throws IOException { - MediaCodec mediaCodec = MediaCodec.createEncoderByType("audio/mp4a-latm"); - MediaFormat mediaFormat = new MediaFormat(); - - mediaFormat.setString(MediaFormat.KEY_MIME, "audio/mp4a-latm"); - mediaFormat.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE); - mediaFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS); - mediaFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, bufferSize); - mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE); - mediaFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC); - - try { - mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); - } catch (Exception e) { - Log.w(TAG, e); - mediaCodec.release(); - throw new IOException(e); - } - - return mediaCodec; - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java deleted file mode 100644 index 7b4bde9c4c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java +++ /dev/null @@ -1,103 +0,0 @@ -package org.thoughtcrime.securesms.audio; - - -import android.content.Context; -import android.net.Uri; -import android.os.ParcelFileDescriptor; -import android.util.Pair; -import androidx.annotation.NonNull; -import java.io.IOException; -import java.util.concurrent.ExecutorService; -import org.session.libsession.utilities.MediaTypes; -import org.session.libsession.utilities.Util; -import org.session.libsignal.utilities.ListenableFuture; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.SettableFuture; -import org.session.libsignal.utilities.ThreadUtils; -import org.thoughtcrime.securesms.providers.BlobProvider; -import org.thoughtcrime.securesms.util.MediaUtil; - -public class AudioRecorder { - - private static final String TAG = AudioRecorder.class.getSimpleName(); - - private static final ExecutorService executor = ThreadUtils.newDynamicSingleThreadedExecutor(); - - private final Context context; - - private AudioCodec audioCodec; - private Uri captureUri; - - // Simple interface that allows us to provide a callback method to our `startRecording` method - public interface AudioMessageRecordingFinishedCallback { - void onAudioMessageRecordingFinished(); - } - - public AudioRecorder(@NonNull Context context) { - this.context = context; - } - - public void startRecording(AudioMessageRecordingFinishedCallback callback) { - Log.i(TAG, "startRecording()"); - - executor.execute(() -> { - Log.i(TAG, "Running startRecording() + " + Thread.currentThread().getId()); - try { - if (audioCodec != null) { - Log.e(TAG, "Trying to start recording while another recording is in progress, exiting..."); - return; - } - - ParcelFileDescriptor fds[] = ParcelFileDescriptor.createPipe(); - - captureUri = BlobProvider.getInstance() - .forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0) - .withMimeType(MediaTypes.AUDIO_AAC) - .createForSingleSessionOnDisk(context, e -> Log.w(TAG, "Error during recording", e)); - - audioCodec = new AudioCodec(); - audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1])); - - callback.onAudioMessageRecordingFinished(); - } catch (IOException e) { - Log.w(TAG, e); - } - }); - } - - public @NonNull ListenableFuture> stopRecording() { - Log.i(TAG, "stopRecording()"); - - final SettableFuture> future = new SettableFuture<>(); - - executor.execute(() -> { - if (audioCodec == null) { - sendToFuture(future, new IOException("MediaRecorder was never initialized successfully!")); - return; - } - - audioCodec.stop(); - - try { - long size = MediaUtil.getMediaSize(context, captureUri); - sendToFuture(future, new Pair<>(captureUri, size)); - } catch (IOException ioe) { - Log.w(TAG, ioe); - sendToFuture(future, ioe); - } - - audioCodec = null; - captureUri = null; - }); - - return future; - } - - private void sendToFuture(final SettableFuture future, final Exception exception) { - Util.runOnMain(() -> future.setException(exception)); - } - - private void sendToFuture(final SettableFuture future, final T result) { - Util.runOnMain(() -> future.set(result)); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.kt b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.kt new file mode 100644 index 0000000000..91c469d027 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.kt @@ -0,0 +1,199 @@ +package org.thoughtcrime.securesms.audio + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import android.os.SystemClock +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.session.libsignal.utilities.Log +import java.io.File +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +private const val TAG = "AudioRecorder" + +data class AudioRecordResult( + val file: File, + val length: Long, + val duration: Duration +) + + +class AudioRecorderHandle( + private val onStopCommand: suspend () -> Unit, + private val deferred: Deferred>, + private val startedResult: SharedFlow>, +) { + + private val listenerScope = CoroutineScope(Dispatchers.Main) + + /** + * Add a listener that will be called on main thread, when the recording has started. + * + * Note that after stop/cancel is called, this listener will not be called again. + */ + fun addOnStartedListener(onStartedResult: (Result) -> Unit) { + listenerScope.launch { + startedResult.collectLatest { result -> + onStartedResult(result) + } + } + } + + /** + * Stop the recording process and return the result. Note that if there's error + * during the recording, this method will throw an exception. + */ + suspend fun stop(): AudioRecordResult { + listenerScope.cancel() + onStopCommand() + return deferred.await().getOrThrow() + } + + /** + * Cancel the recording process and discard any result. + * + * The cancellation is best effort only. When the method returns, there's no + * guarantee that the recording has been stopped. But it's guaranteed that if you + * spin up a new recording immediately after calling this method, the new recording session + * won't start until the old one is properly cleaned up. + */ + @OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class) + fun cancel() { + listenerScope.cancel() + deferred.cancel() + + if (deferred.isCompleted && deferred.getCompleted().isSuccess) { + // Clean up the temporary file if the recording was completed while we were cancelling. + GlobalScope.launch { + deferred.getCompleted().getOrThrow().file.delete() + } + } + } +} + +private sealed interface RecorderCommand { + data object Stop : RecorderCommand + data class ErrorReceived(val error: Throwable) : RecorderCommand +} + +// There can only be on instance of MediaRecorder running at a time, we use a coroutine Mutex to ensure only +// one coroutine can access the MediaRecorder at a time. +private val mediaRecorderMutex = Mutex() + +/** + * Start recording audio. THe recording will be bound to the lifecycle of the coroutine scope. + * + * To stop recording and grab the result, call [AudioRecorderHandle.stop] + */ +fun recordAudio( + scope: CoroutineScope, + context: Context, +): AudioRecorderHandle { + // Channel to send commands to the recorder coroutine. + val commandChannel = Channel(capacity = 1) + + // Channel to notify if the recording has started successfully. + val startResultChannel = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) + + // Start the recording in a coroutine + val deferred = scope.async(Dispatchers.IO) { + runCatching { + mediaRecorderMutex.withLock { + val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + MediaRecorder() + } + + var started = false + + try { + val file by lazy { + File.createTempFile("audio_recording_", ".m4a", context.cacheDir) + } + + recorder.setAudioSource(MediaRecorder.AudioSource.MIC) + recorder.setAudioChannels(1) + recorder.setAudioSamplingRate(44100) + recorder.setAudioEncodingBitRate(32000) + recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + recorder.setOutputFile(file) + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + recorder.setOnErrorListener { _, what, extra -> + commandChannel.trySend( + RecorderCommand.ErrorReceived( + RuntimeException("MediaRecorder error: what=$what, extra=$extra") + ) + ) + } + + recorder.prepare() + recorder.start() + val recordingStarted = SystemClock.elapsedRealtime() + started = true + startResultChannel.emit(Result.success(Unit)) + + // Wait for either stop signal or error + when (val c = commandChannel.receive()) { + is RecorderCommand.Stop -> { + Log.d(TAG, "Received stop command, stopping recording.") + val duration = + (SystemClock.elapsedRealtime() - recordingStarted).milliseconds + recorder.stop() + + val length = file.length() + + return@runCatching AudioRecordResult( + file = file, + length = length, + duration = duration + ) + } + + is RecorderCommand.ErrorReceived -> { + Log.e(TAG, "Error received during recording: ${c.error.message}") + file.delete() + throw c.error + } + } + } catch (e: Exception) { + if (e is CancellationException) { + Log.d(TAG, "Recording cancelled by coroutine cancellation") + } else { + Log.e(TAG, "Error during audio recording", e) + } + + if (!started) { + startResultChannel.emit(Result.failure(e)) + } + throw e + } finally { + Log.d(TAG, "Releasing MediaRecorder resources") + recorder.release() + } + } + } + } + + return AudioRecorderHandle( + onStopCommand = { commandChannel.send(RecorderCommand.Stop) }, + deferred = deferred, + startedResult = startResultChannel + ) +} 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 ac70a6024a..0238fd0a9b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java @@ -29,7 +29,6 @@ import org.session.libsession.utilities.Util; import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.guava.Optional; -import org.thoughtcrime.securesms.attachments.AttachmentServer; import org.thoughtcrime.securesms.mms.AudioSlide; public class AudioSlidePlayer implements SensorEventListener { @@ -48,7 +47,6 @@ public class AudioSlidePlayer implements SensorEventListener { private @NonNull WeakReference listener; private @Nullable ExoPlayer mediaPlayer; - private @Nullable AttachmentServer audioAttachmentServer; private long startTime; public synchronized static AudioSlidePlayer createFor(@NonNull Context context, @@ -92,12 +90,9 @@ private void play(final double progress, boolean earpiece) throws IOException { if (this.mediaPlayer != null) { stop(); } this.mediaPlayer = new ExoPlayer.Builder(context).build(); - this.audioAttachmentServer = new AttachmentServer(context, slide.asAttachment()); this.startTime = System.currentTimeMillis(); - audioAttachmentServer.start(); - - MediaItem mediaItem = MediaItem.fromUri(audioAttachmentServer.getUri()); + MediaItem mediaItem = MediaItem.fromUri(slide.asAttachment().getDataUri()); mediaPlayer.setMediaItem(mediaItem); mediaPlayer.setAudioAttributes(new AudioAttributes.Builder() @@ -149,11 +144,6 @@ public void onPlaybackStateChanged(int playbackState) { mediaPlayer.release(); mediaPlayer = null; - if (audioAttachmentServer != null) { - audioAttachmentServer.stop(); - audioAttachmentServer = null; - } - sensorManager.unregisterListener(AudioSlidePlayer.this); if (wakeLock != null && wakeLock.isHeld()) { @@ -174,11 +164,6 @@ public void onPlayerError(PlaybackException error) { synchronized (AudioSlidePlayer.this) { mediaPlayer = null; - if (audioAttachmentServer != null) { - audioAttachmentServer.stop(); - audioAttachmentServer = null; - } - sensorManager.unregisterListener(AudioSlidePlayer.this); if (wakeLock != null && wakeLock.isHeld()) { @@ -205,12 +190,9 @@ public synchronized void stop() { this.mediaPlayer.release(); } - if (this.audioAttachmentServer != null) { this.audioAttachmentServer.stop(); } - sensorManager.unregisterListener(AudioSlidePlayer.this); this.mediaPlayer = null; - this.audioAttachmentServer = null; } public synchronized static void stopAll() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt deleted file mode 100644 index bf19c3cc34..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt +++ /dev/null @@ -1,129 +0,0 @@ -package org.thoughtcrime.securesms.avatar - -import android.Manifest -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.provider.MediaStore -import androidx.activity.result.ActivityResultLauncher -import androidx.core.content.ContextCompat -import com.canhub.cropper.CropImageContractOptions -import com.canhub.cropper.CropImageOptions -import com.canhub.cropper.CropImageView -import network.loki.messenger.R -import org.session.libsession.utilities.getColorFromAttr -import org.session.libsignal.utilities.ExternalStorageUtil.getImageDir -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.NoExternalStorageException -import org.thoughtcrime.securesms.util.FileProviderUtil -import java.io.File -import java.io.IOException -import java.util.LinkedList - -class AvatarSelection( - private val activity: Activity, - private val onAvatarCropped: ActivityResultLauncher, - private val onPickImage: ActivityResultLauncher -) { - private val TAG: String = AvatarSelection::class.java.simpleName - - private val bgColor by lazy { activity.getColorFromAttr(android.R.attr.colorPrimary) } - private val txtColor by lazy { activity.getColorFromAttr(android.R.attr.textColorPrimary) } - private val imageScrim by lazy { ContextCompat.getColor(activity, R.color.avatar_background) } - private val activityTitle by lazy { activity.getString(R.string.image) } - - /** - * Returns result on [.REQUEST_CODE_CROP_IMAGE] - */ - fun circularCropImage( - inputFile: Uri?, - outputFile: Uri? - ) { - onAvatarCropped.launch( - CropImageContractOptions( - uri = inputFile, - cropImageOptions = CropImageOptions( - guidelines = CropImageView.Guidelines.ON, - aspectRatioX = 1, - aspectRatioY = 1, - fixAspectRatio = true, - cropShape = CropImageView.CropShape.OVAL, - customOutputUri = outputFile, - allowRotation = true, - allowFlipping = true, - backgroundColor = imageScrim, - toolbarColor = bgColor, - activityBackgroundColor = bgColor, - toolbarTintColor = txtColor, - toolbarBackButtonColor = txtColor, - toolbarTitleColor = txtColor, - activityMenuIconColor = txtColor, - activityMenuTextColor = txtColor, - activityTitle = activityTitle - ) - ) - ) - } - - /** - * Returns result on [.REQUEST_CODE_AVATAR] - * - * @return Temporary capture file if created. - */ - fun startAvatarSelection( - includeClear: Boolean, - attemptToIncludeCamera: Boolean, - createTempFile: ()->File? - ) { - var captureFile: File? = null - val hasCameraPermission = ContextCompat - .checkSelfPermission( - activity, - Manifest.permission.CAMERA - ) == PackageManager.PERMISSION_GRANTED - if (attemptToIncludeCamera && hasCameraPermission) { - captureFile = createTempFile() - } - - val chooserIntent = createAvatarSelectionIntent(activity, captureFile, includeClear) - onPickImage.launch(chooserIntent) - } - - private fun createAvatarSelectionIntent( - context: Context, - tempCaptureFile: File?, - includeClear: Boolean - ): Intent { - val extraIntents: MutableList = LinkedList() - val galleryIntent = Intent(Intent.ACTION_OPEN_DOCUMENT) - galleryIntent.setType("image/*") - - if (tempCaptureFile != null) { - val uri = FileProviderUtil.getUriFor(context, tempCaptureFile) - val cameraIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) - cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, uri) - cameraIntent.setFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - extraIntents.add(cameraIntent) - } - - if (includeClear) { - extraIntents.add(Intent("network.loki.securesms.action.CLEAR_PROFILE_PHOTO")) - } - - val chooserIntent = Intent.createChooser( - galleryIntent, - context.getString(R.string.image) - ) - - if (!extraIntents.isEmpty()) { - chooserIntent.putExtra( - Intent.EXTRA_INITIAL_INTENTS, - extraIntents.toTypedArray() - ) - } - - return chooserIntent - } -} \ 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 deleted file mode 100644 index bd288d11a8..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt +++ /dev/null @@ -1,449 +0,0 @@ -package org.thoughtcrime.securesms.calls - -import android.Manifest -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.graphics.Outline -import android.media.AudioManager -import android.os.Build -import android.os.Bundle -import android.view.MenuItem -import android.view.View -import android.view.ViewOutlineProvider -import android.view.WindowManager -import android.widget.TextView -import androidx.activity.viewModels -import androidx.core.content.ContextCompat -import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import com.squareup.phrase.Phrase -import dagger.hilt.android.AndroidEntryPoint -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.ActivityWebrtcBinding -import org.apache.commons.lang3.time.DurationFormatUtils -import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.truncateIdForDisplay -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.permissions.Permissions -import org.thoughtcrime.securesms.service.WebRtcCallService -import org.thoughtcrime.securesms.webrtc.AudioManagerCommand -import org.thoughtcrime.securesms.webrtc.CallViewModel -import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_CONNECTED -import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_INCOMING -import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_OUTGOING -import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_PRE_INIT -import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_RECONNECTING -import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_RINGING -import org.thoughtcrime.securesms.webrtc.Orientation -import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager.AudioDevice.EARPIECE -import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager.AudioDevice.SPEAKER_PHONE - -@AndroidEntryPoint -class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { - - companion object { - const val ACTION_PRE_OFFER = "pre-offer" - const val ACTION_FULL_SCREEN_INTENT = "fullscreen-intent" - const val ACTION_ANSWER = "answer" - const val ACTION_END = "end-call" - - const val BUSY_SIGNAL_DELAY_FINISH = 5500L - - private const val CALL_DURATION_FORMAT = "HH:mm:ss" - } - - private val viewModel by viewModels() - private lateinit var binding: ActivityWebrtcBinding - private var uiJob: Job? = null - private var wantsToAnswer = false - set(value) { - field = value - WebRtcCallService.broadcastWantsToAnswer(this, value) - } - private var hangupReceiver: BroadcastReceiver? = null - - /** - * We need to track the device's orientation so we can calculate whether or not to rotate the video streams - * This works a lot better than using `OrientationEventListener > onOrientationChanged' - * which gives us a rotation angle that doesn't take into account pitch vs roll, so tipping the device from front to back would - * trigger the video rotation logic, while we really only want it when the device is in portrait or landscape. - */ - private var orientationManager = OrientationManager(this) - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - finish() - return true - } - return super.onOptionsItemSelected(item) - } - - override fun onNewIntent(intent: Intent) { - 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) - } - } - - override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { - super.onCreate(savedInstanceState, ready) - - binding = ActivityWebrtcBinding.inflate(layoutInflater) - setContentView(binding.root) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { - setShowWhenLocked(true) - setTurnScreenOn(true) - } - - window.addFlags( - WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED - or WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD - or WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON - or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON - or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON - ) - volumeControlStream = AudioManager.STREAM_VOICE_CALL - - if (intent.action == ACTION_ANSWER) { - answerCall() - } - if (intent.action == ACTION_PRE_OFFER) { - wantsToAnswer = true - answerCall() // this will do nothing, except update notification state - } - if (intent.action == ACTION_FULL_SCREEN_INTENT) { - supportActionBar?.setDisplayHomeAsUpEnabled(false) - } - - binding.floatingRendererContainer.setOnClickListener { - viewModel.swapVideos() - } - - binding.microphoneButton.setOnClickListener { - val audioEnabledIntent = - WebRtcCallService.microphoneIntent(this, !viewModel.microphoneEnabled) - startService(audioEnabledIntent) - } - - binding.speakerPhoneButton.setOnClickListener { - val command = - AudioManagerCommand.SetUserDevice(if (viewModel.isSpeaker) EARPIECE else SPEAKER_PHONE) - WebRtcCallService.sendAudioManagerCommand(this, command) - } - - binding.acceptCallButton.setOnClickListener { - if (viewModel.currentCallState == CALL_PRE_INIT) { - wantsToAnswer = true - updateControls() - } - answerCall() - } - - binding.declineCallButton.setOnClickListener { - val declineIntent = WebRtcCallService.denyCallIntent(this) - startService(declineIntent) - } - - hangupReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - finish() - } - } - - LocalBroadcastManager.getInstance(this) - .registerReceiver(hangupReceiver!!, IntentFilter(ACTION_END)) - - binding.enableCameraButton.setOnClickListener { - Permissions.with(this) - .request(Manifest.permission.CAMERA) - .onAllGranted { - val intent = WebRtcCallService.cameraEnabled(this, !viewModel.videoState.value.userVideoEnabled) - startService(intent) - } - .execute() - } - - binding.switchCameraButton.setOnClickListener { - startService(WebRtcCallService.flipCamera(this)) - } - - binding.endCallButton.setOnClickListener { - startService(WebRtcCallService.hangupIntent(this)) - } - binding.backArrow.setOnClickListener { - onBackPressed() - } - - lifecycleScope.launch { - orientationManager.orientation.collect { orientation -> - viewModel.deviceOrientation = orientation - updateControlsRotation() - } - } - - clipFloatingInsets() - - // set up the user avatar - TextSecurePreferences.getLocalNumber(this)?.let{ - val username = TextSecurePreferences.getProfileName(this) ?: truncateIdForDisplay(it) - binding.userAvatar.apply { - publicKey = it - displayName = username - update() - } - } - - // Substitute "Session" into the "{app_name} Call" text - val sessionCallTV = findViewById(R.id.sessionCallText) - sessionCallTV?.text = Phrase.from(this, R.string.callsSessionCall).put(APP_NAME_KEY, getString(R.string.app_name)).format() - } - - /** - * Makes sure the floating video inset has clipped rounded corners, included with the video stream itself - */ - private fun clipFloatingInsets() { - // clip the video inset with rounded corners - val videoInsetProvider = object : ViewOutlineProvider() { - override fun getOutline(view: View, outline: Outline) { - // all corners - outline.setRoundRect( - 0, 0, view.width, view.height, - resources.getDimensionPixelSize(R.dimen.video_inset_radius).toFloat() - ) - } - } - - binding.floatingRendererContainer.outlineProvider = videoInsetProvider - binding.floatingRendererContainer.clipToOutline = true - } - - override fun onResume() { - super.onResume() - orientationManager.startOrientationListener() - - } - - override fun onPause() { - super.onPause() - orientationManager.stopOrientationListener() - } - - override fun onDestroy() { - super.onDestroy() - hangupReceiver?.let { receiver -> - LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver) - } - - orientationManager.destroy() - } - - private fun answerCall() { - val answerIntent = WebRtcCallService.acceptCallIntent(this) - ContextCompat.startForegroundService(this, answerIntent) - } - - private fun updateControlsRotation() { - with (binding) { - val rotation = when(viewModel.deviceOrientation){ - Orientation.LANDSCAPE -> -90f - Orientation.REVERSED_LANDSCAPE -> 90f - else -> 0f - } - - userAvatar.animate().cancel() - userAvatar.animate().rotation(rotation).start() - contactAvatar.animate().cancel() - contactAvatar.animate().rotation(rotation).start() - - speakerPhoneButton.animate().cancel() - speakerPhoneButton.animate().rotation(rotation).start() - - microphoneButton.animate().cancel() - microphoneButton.animate().rotation(rotation).start() - - enableCameraButton.animate().cancel() - enableCameraButton.animate().rotation(rotation).start() - - switchCameraButton.animate().cancel() - switchCameraButton.animate().rotation(rotation).start() - - endCallButton.animate().cancel() - endCallButton.animate().rotation(rotation).start() - } - } - - private fun updateControls(state: CallViewModel.State? = null) { - with(binding) { - if (state == null) { - if (wantsToAnswer) { - controlGroup.isVisible = true - remoteLoadingView.isVisible = true - incomingControlGroup.isVisible = false - } - } else { - controlGroup.isVisible = state in listOf( - CALL_CONNECTED, - CALL_OUTGOING, - CALL_INCOMING - ) || (state == CALL_PRE_INIT && wantsToAnswer) - remoteLoadingView.isVisible = - state !in listOf(CALL_CONNECTED, CALL_RINGING, CALL_PRE_INIT) || wantsToAnswer - incomingControlGroup.isVisible = - state in listOf(CALL_RINGING, CALL_PRE_INIT) && !wantsToAnswer - reconnectingText.isVisible = state == CALL_RECONNECTING - endCallButton.isVisible = endCallButton.isVisible || state == CALL_RECONNECTING - } - } - } - - override fun onStart() { - super.onStart() - - uiJob = lifecycleScope.launch { - - launch { - viewModel.audioDeviceState.collect { state -> - val speakerEnabled = state.selectedDevice == SPEAKER_PHONE - // change drawable background to enabled or not - binding.speakerPhoneButton.isSelected = speakerEnabled - } - } - - launch { - viewModel.callState.collect { state -> - Log.d("Loki", "Consuming view model state $state") - when (state) { - CALL_RINGING -> if (wantsToAnswer) { - answerCall() - wantsToAnswer = false - } - CALL_CONNECTED -> wantsToAnswer = false - else -> {} - } - updateControls(state) - } - } - - launch { - viewModel.recipient.collect { latestRecipient -> - binding.contactAvatar.recycle() - - if (latestRecipient.recipient != null) { - val contactPublicKey = latestRecipient.recipient.address.serialize() - val contactDisplayName = viewModel.getUserName(contactPublicKey) - supportActionBar?.title = contactDisplayName - binding.remoteRecipientName.text = contactDisplayName - - // sort out the contact's avatar - binding.contactAvatar.apply { - publicKey = contactPublicKey - displayName = contactDisplayName - update() - } - } - } - } - - launch { - while (isActive) { - val startTime = viewModel.callStartTime - if (startTime == -1L) { - binding.callTime.isVisible = false - } else { - binding.callTime.isVisible = true - binding.callTime.text = DurationFormatUtils.formatDuration( - System.currentTimeMillis() - startTime, - CALL_DURATION_FORMAT - ) - } - - delay(1_000) - } - } - - launch { - viewModel.localAudioEnabledState.collect { isEnabled -> - // change drawable background to enabled or not - binding.microphoneButton.isSelected = !isEnabled - } - } - - // handle video state - launch { - viewModel.videoState.collect { state -> - binding.floatingRenderer.removeAllViews() - binding.fullscreenRenderer.removeAllViews() - - // handle fullscreen video window - if(state.showFullscreenVideo()){ - viewModel.fullscreenRenderer?.let { surfaceView -> - binding.fullscreenRenderer.addView(surfaceView) - binding.fullscreenRenderer.isVisible = true - hideAvatar() - } - } else { - binding.fullscreenRenderer.isVisible = false - showAvatar(state.swapped) - } - - // handle floating video window - if(state.showFloatingVideo()){ - viewModel.floatingRenderer?.let { surfaceView -> - binding.floatingRenderer.addView(surfaceView) - binding.floatingRenderer.isVisible = true - binding.swapViewIcon.bringToFront() - } - } else { - binding.floatingRenderer.isVisible = false - } - - // the floating video inset (empty or not) should be shown - // the moment we have either of the video streams - val showFloatingContainer = state.userVideoEnabled || state.remoteVideoEnabled - binding.floatingRendererContainer.isVisible = showFloatingContainer - binding.swapViewIcon.isVisible = showFloatingContainer - - // make sure to default to the contact's avatar if the floating container is not visible - if (!showFloatingContainer) showAvatar(false) - - // handle buttons - binding.enableCameraButton.isSelected = state.userVideoEnabled - } - } - } - } - - /** - * Shows the avatar image. - * If @showUserAvatar is true, the user's avatar is shown, otherwise the contact's avatar is shown. - */ - private fun showAvatar(showUserAvatar: Boolean) { - binding.userAvatar.isVisible = showUserAvatar - binding.contactAvatar.isVisible = !showUserAvatar - } - - private fun hideAvatar() { - binding.userAvatar.isVisible = false - binding.contactAvatar.isVisible = false - } - - override fun onStop() { - super.onStop() - uiJob?.cancel() - binding.fullscreenRenderer.removeAllViews() - binding.floatingRenderer.removeAllViews() - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java index 2365bc843b..a0646bc532 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java @@ -15,6 +15,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatEditText; import androidx.core.os.BuildCompat; import androidx.core.view.inputmethod.EditorInfoCompat; import androidx.core.view.inputmethod.InputConnectionCompat; @@ -22,9 +23,8 @@ import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.components.emoji.EmojiEditText; -public class ComposeText extends EmojiEditText { +public class ComposeText extends AppCompatEditText { private CharSequence hint; private SpannableString subHint; @@ -101,31 +101,6 @@ public void setHint(@NonNull String hint, @Nullable CharSequence subHint) { } } - public void setCursorPositionChangedListener(@Nullable CursorPositionChangedListener listener) { - this.cursorPositionChangedListener = listener; - } - - public void setTransport() { - final boolean useSystemEmoji = TextSecurePreferences.isSystemEmojiPreferred(getContext()); - final boolean isIncognito = TextSecurePreferences.isIncognitoKeyboardEnabled(getContext()); - - int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND; - int inputType = getInputType(); - - setImeActionLabel(null, 0); - - if (useSystemEmoji) { - inputType = (inputType & ~InputType.TYPE_MASK_VARIATION) | InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE; - } - - setInputType(inputType); - if (isIncognito) { - setImeOptions(imeOptions | 16777216); - } else { - setImeOptions(imeOptions); - } - } - @Override public InputConnection onCreateInputConnection(EditorInfo editorInfo) { InputConnection inputConnection = super.onCreateInputConnection(editorInfo); @@ -141,10 +116,6 @@ public InputConnection onCreateInputConnection(EditorInfo editorInfo) { return InputConnectionCompat.createWrapper(inputConnection, editorInfo, new CommitContentListener(mediaListener)); } - public void setMediaListener(@Nullable InputPanel.MediaListener mediaListener) { - this.mediaListener = mediaListener; - } - private void initialize() { if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) { setImeOptions(getImeOptions() | 16777216); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ControllableTabLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/ControllableTabLayout.java deleted file mode 100644 index 969945621f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ControllableTabLayout.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import com.google.android.material.tabs.TabLayout; -import android.util.AttributeSet; -import android.view.View; - -import java.util.List; - -/** - * An implementation of {@link TabLayout} that disables taps when the view is disabled. - */ -public class ControllableTabLayout extends TabLayout { - - private List touchables; - - public ControllableTabLayout(Context context) { - super(context); - } - - public ControllableTabLayout(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public ControllableTabLayout(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - @Override - public void setEnabled(boolean enabled) { - if (isEnabled() && !enabled) { - touchables = getTouchables(); - } - - for (View touchable : touchables) { - touchable.setClickable(enabled); - } - - super.setEnabled(enabled); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemAlertView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemAlertView.java deleted file mode 100644 index 2a8de38d33..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemAlertView.java +++ /dev/null @@ -1,68 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.content.res.TypedArray; -import android.util.AttributeSet; -import android.view.View; -import android.widget.ImageView; -import android.widget.LinearLayout; - -import network.loki.messenger.R; - -public class ConversationItemAlertView extends LinearLayout { - - private static final String TAG = ConversationItemAlertView.class.getSimpleName(); - - private ImageView approvalIndicator; - private ImageView failedIndicator; - - public ConversationItemAlertView(Context context) { - this(context, null); - } - - public ConversationItemAlertView(Context context, AttributeSet attrs) { - super(context, attrs); - initialize(attrs); - } - - public ConversationItemAlertView(final Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - initialize(attrs); - } - - private void initialize(AttributeSet attrs) { - inflate(getContext(), R.layout.alert_view, this); - - approvalIndicator = findViewById(R.id.pending_approval_indicator); - failedIndicator = findViewById(R.id.sms_failed_indicator); - - if (attrs != null) { - TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.AlertView, 0, 0); - boolean useSmallIcon = typedArray.getBoolean(R.styleable.AlertView_useSmallIcon, false); - typedArray.recycle(); - - if (useSmallIcon) { - int size = getResources().getDimensionPixelOffset(R.dimen.alertview_small_icon_size); - failedIndicator.getLayoutParams().width = size; - failedIndicator.getLayoutParams().height = size; - requestLayout(); - } - } - } - - public void setNone() { - this.setVisibility(View.GONE); - } - - public void setPendingApproval() { - this.setVisibility(View.VISIBLE); - approvalIndicator.setVisibility(View.VISIBLE); - failedIndicator.setVisibility(View.GONE); - } - - public void setFailed() { - this.setVisibility(View.VISIBLE); - approvalIndicator.setVisibility(View.GONE); - failedIndicator.setVisibility(View.VISIBLE); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java b/app/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java deleted file mode 100644 index 861281c999..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/DeliveryStatusView.java +++ /dev/null @@ -1,104 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.content.res.TypedArray; -import android.util.AttributeSet; -import android.view.View; -import android.view.animation.Animation; -import android.view.animation.LinearInterpolator; -import android.view.animation.RotateAnimation; -import android.widget.FrameLayout; -import android.widget.ImageView; - -import network.loki.messenger.R; - -public class DeliveryStatusView extends FrameLayout { - - private static final String TAG = DeliveryStatusView.class.getSimpleName(); - - private static final RotateAnimation ROTATION_ANIMATION = new RotateAnimation(0, 360f, - Animation.RELATIVE_TO_SELF, 0.5f, - Animation.RELATIVE_TO_SELF, 0.5f); - static { - ROTATION_ANIMATION.setInterpolator(new LinearInterpolator()); - ROTATION_ANIMATION.setDuration(1500); - ROTATION_ANIMATION.setRepeatCount(Animation.INFINITE); - } - - private final ImageView pendingIndicator; - private final ImageView sentIndicator; - private final ImageView deliveredIndicator; - private final ImageView readIndicator; - - public DeliveryStatusView(Context context) { - this(context, null); - } - - public DeliveryStatusView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public DeliveryStatusView(final Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - - inflate(context, R.layout.delivery_status_view, this); - - this.deliveredIndicator = findViewById(R.id.delivered_indicator); - this.sentIndicator = findViewById(R.id.sent_indicator); - this.pendingIndicator = findViewById(R.id.pending_indicator); - this.readIndicator = findViewById(R.id.read_indicator); - - if (attrs != null) { - TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.DeliveryStatusView, 0, 0); - setTint(typedArray.getColor(R.styleable.DeliveryStatusView_iconColor, getResources().getColor(R.color.core_white))); - typedArray.recycle(); - } - } - - public void setNone() { - this.setVisibility(View.GONE); - } - - public void setPending() { - this.setVisibility(View.GONE); - pendingIndicator.setVisibility(View.VISIBLE); - pendingIndicator.startAnimation(ROTATION_ANIMATION); - sentIndicator.setVisibility(View.GONE); - deliveredIndicator.setVisibility(View.GONE); - readIndicator.setVisibility(View.GONE); - } - - public void setSent() { - this.setVisibility(View.GONE); - pendingIndicator.setVisibility(View.GONE); - pendingIndicator.clearAnimation(); - sentIndicator.setVisibility(View.VISIBLE); - deliveredIndicator.setVisibility(View.GONE); - readIndicator.setVisibility(View.GONE); - } - - public void setDelivered() { - this.setVisibility(View.GONE); - pendingIndicator.setVisibility(View.GONE); - pendingIndicator.clearAnimation(); - sentIndicator.setVisibility(View.GONE); - deliveredIndicator.setVisibility(View.VISIBLE); - readIndicator.setVisibility(View.GONE); - } - - public void setRead() { - this.setVisibility(View.GONE); - pendingIndicator.setVisibility(View.GONE); - pendingIndicator.clearAnimation(); - sentIndicator.setVisibility(View.GONE); - deliveredIndicator.setVisibility(View.GONE); - readIndicator.setVisibility(View.VISIBLE); - } - - public void setTint(int color) { - pendingIndicator.setColorFilter(color); - deliveredIndicator.setColorFilter(color); - sentIndicator.setColorFilter(color); - readIndicator.setColorFilter(color); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java b/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java deleted file mode 100644 index f51b4a7d93..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/DocumentView.java +++ /dev/null @@ -1,185 +0,0 @@ -package org.thoughtcrime.securesms.components; - - -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Color; -import android.graphics.PorterDuff; -import androidx.annotation.AttrRes; -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.ImageView; -import android.widget.TextView; - -import com.pnikosis.materialishprogress.ProgressWheel; - -import org.greenrobot.eventbus.Subscribe; -import org.greenrobot.eventbus.ThreadMode; -import network.loki.messenger.R; - -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress; -import org.thoughtcrime.securesms.events.PartProgressEvent; -import org.thoughtcrime.securesms.mms.DocumentSlide; -import org.thoughtcrime.securesms.mms.SlideClickListener; -import org.session.libsession.utilities.Util; -import org.session.libsignal.utilities.guava.Optional; - -public class DocumentView extends FrameLayout { - - private static final String TAG = DocumentView.class.getSimpleName(); - - private final @NonNull AnimatingToggle controlToggle; - private final @NonNull ImageView downloadButton; - private final @NonNull ProgressWheel downloadProgress; - private final @NonNull View container; - private final @NonNull ViewGroup iconContainer; - private final @NonNull TextView fileName; - private final @NonNull TextView fileSize; - private final @NonNull TextView document; - - private @Nullable SlideClickListener downloadListener; - private @Nullable SlideClickListener viewListener; - private @Nullable DocumentSlide documentSlide; - - public DocumentView(@NonNull Context context) { - this(context, null); - } - - public DocumentView(@NonNull Context context, @Nullable AttributeSet attrs) { - this(context, attrs, 0); - } - - public DocumentView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) { - super(context, attrs, defStyleAttr); - inflate(context, R.layout.document_view, this); - - this.container = findViewById(R.id.document_container); - this.iconContainer = findViewById(R.id.icon_container); - this.controlToggle = findViewById(R.id.control_toggle); - this.downloadButton = findViewById(R.id.download); - this.downloadProgress = findViewById(R.id.download_progress); - this.fileName = findViewById(R.id.file_name); - this.fileSize = findViewById(R.id.file_size); - this.document = findViewById(R.id.document); - - if (attrs != null) { - TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.DocumentView, 0, 0); - int titleColor = typedArray.getInt(R.styleable.DocumentView_doc_titleColor, Color.BLACK); - int captionColor = typedArray.getInt(R.styleable.DocumentView_doc_captionColor, Color.BLACK); - int downloadTint = typedArray.getInt(R.styleable.DocumentView_doc_downloadButtonTint, Color.WHITE); - typedArray.recycle(); - - fileName.setTextColor(titleColor); - fileSize.setTextColor(captionColor); - downloadButton.setColorFilter(downloadTint, PorterDuff.Mode.MULTIPLY); - downloadProgress.setBarColor(downloadTint); - } - } - - public void setDownloadClickListener(@Nullable SlideClickListener listener) { - this.downloadListener = listener; - } - - public void setDocumentClickListener(@Nullable SlideClickListener listener) { - this.viewListener = listener; - } - - public void setDocument(final @NonNull DocumentSlide documentSlide, - final boolean showControls) - { - if (showControls && documentSlide.isPendingDownload()) { - controlToggle.displayQuick(downloadButton); - downloadButton.setOnClickListener(new DownloadClickedListener(documentSlide)); - if (downloadProgress.isSpinning()) downloadProgress.stopSpinning(); - } else if (showControls && documentSlide.getTransferState() == AttachmentTransferProgress.TRANSFER_PROGRESS_STARTED) { - controlToggle.displayQuick(downloadProgress); - downloadProgress.spin(); - } else { - controlToggle.displayQuick(iconContainer); - if (downloadProgress.isSpinning()) downloadProgress.stopSpinning(); - } - - this.documentSlide = documentSlide; - - this.fileName.setText(documentSlide.getFileName().or(getContext().getString(R.string.attachmentsErrorNotSupported))); - this.fileSize.setText(Util.getPrettyFileSize(documentSlide.getFileSize())); - this.document.setText(getFileType(documentSlide.getFileName())); - this.setOnClickListener(new OpenClickedListener(documentSlide)); - } - - @Override - public void setFocusable(boolean focusable) { - super.setFocusable(focusable); - this.downloadButton.setFocusable(focusable); - } - - @Override - public void setClickable(boolean clickable) { - super.setClickable(clickable); - this.downloadButton.setClickable(clickable); - } - - @Override - public void setEnabled(boolean enabled) { - super.setEnabled(enabled); - this.downloadButton.setEnabled(enabled); - } - - private @NonNull String getFileType(Optional fileName) { - if (!fileName.isPresent()) return ""; - - String[] parts = fileName.get().split("\\."); - - if (parts.length < 2) { - return ""; - } - - String suffix = parts[parts.length - 1]; - - if (suffix.length() <= 3) { - return suffix; - } - - return ""; - } - - @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) - public void onEventAsync(final PartProgressEvent event) { - if (documentSlide != null && event.attachment.equals(documentSlide.asAttachment())) { - downloadProgress.setInstantProgress(((float) event.progress) / event.total); - } - } - - private class DownloadClickedListener implements View.OnClickListener { - private final @NonNull DocumentSlide slide; - - private DownloadClickedListener(@NonNull DocumentSlide slide) { - this.slide = slide; - } - - @Override - public void onClick(View v) { - if (downloadListener != null) downloadListener.onClick(v, slide); - } - } - - private class OpenClickedListener implements View.OnClickListener { - private final @NonNull DocumentSlide slide; - - private OpenClickedListener(@NonNull DocumentSlide slide) { - this.slide = slide; - } - - @Override - public void onClick(View v) { - if (!slide.isPendingDownload() && !slide.isInProgress() && viewListener != null) { - viewListener.onClick(v, slide); - } - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java index 8a8d15a8e5..6726ebf61d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/FromTextView.java @@ -14,8 +14,9 @@ import android.util.AttributeSet; import network.loki.messenger.R; -import org.thoughtcrime.securesms.components.emoji.EmojiTextView; + import org.session.libsession.utilities.recipients.Recipient; +import org.thoughtcrime.securesms.components.emoji.EmojiTextView; import org.thoughtcrime.securesms.util.ResUtil; import org.session.libsession.utilities.CenterAlignedRelativeSizeSpan; @@ -36,7 +37,7 @@ public void setText(Recipient recipient) { } public void setText(Recipient recipient, boolean read) { - String fromString = recipient.toShortString(); + String fromString = recipient.getName(); int typeface; @@ -74,8 +75,8 @@ public void setText(Recipient recipient, boolean read) { setText(builder); - if (recipient.isBlocked()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_block_grey600_18dp, 0, 0, 0); - else if (recipient.isMuted()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_volume_off_grey600_18dp, 0, 0, 0); + if (recipient.isBlocked()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_user_round_x, 0, 0, 0); + else if (recipient.isMuted()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_volume_off, 0, 0, 0); else setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/HidingLinearLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/HidingLinearLayout.java deleted file mode 100644 index bdb7c2fdf0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/HidingLinearLayout.java +++ /dev/null @@ -1,76 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.util.AttributeSet; -import android.view.animation.AlphaAnimation; -import android.view.animation.Animation; -import android.view.animation.AnimationSet; -import android.view.animation.ScaleAnimation; -import android.widget.LinearLayout; - -import androidx.interpolator.view.animation.FastOutSlowInInterpolator; - -public class HidingLinearLayout extends LinearLayout { - - public HidingLinearLayout(Context context) { - super(context); - } - - public HidingLinearLayout(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public HidingLinearLayout(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public void hide() { - if (!isEnabled() || getVisibility() == GONE) return; - - AnimationSet animation = new AnimationSet(true); - animation.addAnimation(new ScaleAnimation(1, 0.5f, 1, 1, Animation.RELATIVE_TO_SELF, 1f, Animation.RELATIVE_TO_SELF, 0.5f)); - animation.addAnimation(new AlphaAnimation(1, 0)); - animation.setDuration(100); - - animation.setAnimationListener(new Animation.AnimationListener() { - @Override - public void onAnimationStart(Animation animation) { - } - - @Override - public void onAnimationRepeat(Animation animation) { - } - - @Override - public void onAnimationEnd(Animation animation) { - setVisibility(GONE); - } - }); - - animateWith(animation); - } - - public void show() { - if (!isEnabled() || getVisibility() == VISIBLE) return; - - setVisibility(VISIBLE); - - AnimationSet animation = new AnimationSet(true); - animation.addAnimation(new ScaleAnimation(0.5f, 1, 1, 1, Animation.RELATIVE_TO_SELF, 1f, Animation.RELATIVE_TO_SELF, 0.5f)); - animation.addAnimation(new AlphaAnimation(0, 1)); - animation.setDuration(100); - - animateWith(animation); - } - - private void animateWith(Animation animation) { - animation.setDuration(150); - animation.setInterpolator(new FastOutSlowInInterpolator()); - startAnimation(animation); - } - - public void disable() { - setVisibility(GONE); - setEnabled(false); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareLayout.java deleted file mode 100644 index cd76ebd03a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareLayout.java +++ /dev/null @@ -1,93 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.util.AttributeSet; -import android.widget.EditText; - -import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardShownListener; -import org.session.libsession.utilities.ServiceUtil; - -public class InputAwareLayout extends KeyboardAwareLinearLayout implements OnKeyboardShownListener { - private InputView current; - - public InputAwareLayout(Context context) { - this(context, null); - } - - public InputAwareLayout(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public InputAwareLayout(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - addOnKeyboardShownListener(this); - } - - @Override public void onKeyboardShown() { - hideAttachedInput(true); - } - - public void show(@NonNull final EditText imeTarget, @NonNull final InputView input) { - if (isKeyboardOpen()) { - hideSoftkey(imeTarget, new Runnable() { - @Override public void run() { - hideAttachedInput(true); - input.show(getKeyboardHeight(), true); - current = input; - } - }); - } else { - if (current != null) current.hide(true); - input.show(getKeyboardHeight(), current != null); - current = input; - } - } - - public InputView getCurrentInput() { - return current; - } - - public void hideCurrentInput(EditText imeTarget) { - if (isKeyboardOpen()) hideSoftkey(imeTarget, null); - else hideAttachedInput(false); - } - - public void hideAttachedInput(boolean instant) { - if (current != null) current.hide(instant); - current = null; - } - - public boolean isInputOpen() { - return (isKeyboardOpen() || (current != null && current.isShowing())); - } - - public void showSoftkey(final EditText inputTarget) { - postOnKeyboardOpen(new Runnable() { - @Override public void run() { - hideAttachedInput(true); - } - }); - inputTarget.post(new Runnable() { - @Override public void run() { - inputTarget.requestFocus(); - ServiceUtil.getInputMethodManager(inputTarget.getContext()).showSoftInput(inputTarget, 0); - } - }); - } - - public void hideSoftkey(final EditText inputTarget, @Nullable Runnable runAfterClose) { - if (runAfterClose != null) postOnKeyboardClose(runAfterClose); - - ServiceUtil.getInputMethodManager(inputTarget.getContext()) - .hideSoftInputFromWindow(inputTarget.getWindowToken(), 0); - } - - public interface InputView { - void show(int height, boolean immediate); - void hide(boolean immediate); - boolean isShowing(); - } -} - diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java deleted file mode 100644 index bebc12a7e5..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java +++ /dev/null @@ -1,269 +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 . - */ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.graphics.Rect; -import android.preference.PreferenceManager; -import android.util.AttributeSet; -import android.view.Surface; -import android.view.View; - -import androidx.appcompat.widget.LinearLayoutCompat; - -import org.session.libsession.utilities.ServiceUtil; -import org.session.libsession.utilities.Util; -import org.session.libsignal.utilities.Log; - -import java.lang.reflect.Field; -import java.util.HashSet; -import java.util.Set; - -import network.loki.messenger.R; - -/** - * LinearLayout that, when a view container, will report back when it thinks a soft keyboard - * has been opened and what its height would be. - */ -public class KeyboardAwareLinearLayout extends LinearLayoutCompat { - private static final String TAG = KeyboardAwareLinearLayout.class.getSimpleName(); - - private final Rect rect = new Rect(); - private final Set hiddenListeners = new HashSet<>(); - private final Set shownListeners = new HashSet<>(); - private final int minKeyboardSize; - private final int minCustomKeyboardSize; - private final int defaultCustomKeyboardSize; - private final int minCustomKeyboardTopMarginPortrait; - private final int minCustomKeyboardTopMarginLandscape; - private final int statusBarHeight; - - private int viewInset; - - private boolean keyboardOpen = false; - private int rotation = -1; - private boolean isFullscreen = false; - - public KeyboardAwareLinearLayout(Context context) { - this(context, null); - } - - public KeyboardAwareLinearLayout(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public KeyboardAwareLinearLayout(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - final int statusBarRes = getResources().getIdentifier("status_bar_height", "dimen", "android"); - minKeyboardSize = getResources().getDimensionPixelSize(R.dimen.min_keyboard_size); - minCustomKeyboardSize = getResources().getDimensionPixelSize(R.dimen.min_custom_keyboard_size); - defaultCustomKeyboardSize = getResources().getDimensionPixelSize(R.dimen.default_custom_keyboard_size); - minCustomKeyboardTopMarginPortrait = getResources().getDimensionPixelSize(R.dimen.min_custom_keyboard_top_margin_portrait); - minCustomKeyboardTopMarginLandscape = getResources().getDimensionPixelSize(R.dimen.min_custom_keyboard_top_margin_portrait); - statusBarHeight = statusBarRes > 0 ? getResources().getDimensionPixelSize(statusBarRes) : 0; - viewInset = getViewInset(); - } - - @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - updateRotation(); - updateKeyboardState(); - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - } - - private void updateRotation() { - int oldRotation = rotation; - rotation = getDeviceRotation(); - if (oldRotation != rotation) { - Log.i(TAG, "rotation changed"); - onKeyboardClose(); - } - } - - private void updateKeyboardState() { - if (viewInset == 0) viewInset = getViewInset(); - - getWindowVisibleDisplayFrame(rect); - - final int availableHeight = getAvailableHeight(); - final int keyboardHeight = availableHeight - (rect.bottom - rect.top); - - if (keyboardHeight > minKeyboardSize) { - if (getKeyboardHeight() != keyboardHeight) { - if (isLandscape()) { - setKeyboardLandscapeHeight(keyboardHeight); - } else { - setKeyboardPortraitHeight(keyboardHeight); - } - } - if (!keyboardOpen) { - onKeyboardOpen(keyboardHeight); - } - } else if (keyboardOpen) { - onKeyboardClose(); - } - } - - private int getViewInset() { - try { - Field attachInfoField = View.class.getDeclaredField("mAttachInfo"); - attachInfoField.setAccessible(true); - Object attachInfo = attachInfoField.get(this); - if (attachInfo != null) { - Field stableInsetsField = attachInfo.getClass().getDeclaredField("mStableInsets"); - stableInsetsField.setAccessible(true); - Rect insets = (Rect)stableInsetsField.get(attachInfo); - return insets.bottom; - } - } catch (NoSuchFieldException nsfe) { - Log.w(TAG, "field reflection error when measuring view inset, NoSuchFieldException"); - } catch (IllegalAccessException iae) { - Log.w(TAG, "access reflection error when measuring view inset", iae); - } - return 0; - } - - private int getAvailableHeight() { - final int availableHeight = this.getRootView().getHeight() - viewInset - (!isFullscreen ? statusBarHeight : 0); - final int availableWidth = this.getRootView().getWidth() - (!isFullscreen ? statusBarHeight : 0); - - if (isLandscape() && availableHeight > availableWidth) { - //noinspection SuspiciousNameCombination - return availableWidth; - } - - return availableHeight; - } - - protected void onKeyboardOpen(int keyboardHeight) { - Log.i(TAG, "onKeyboardOpen(" + keyboardHeight + ")"); - keyboardOpen = true; - - notifyShownListeners(); - } - - protected void onKeyboardClose() { - Log.i(TAG, "onKeyboardClose()"); - keyboardOpen = false; - notifyHiddenListeners(); - } - - public boolean isKeyboardOpen() { - return keyboardOpen; - } - - public int getKeyboardHeight() { - return isLandscape() ? getKeyboardLandscapeHeight() : getKeyboardPortraitHeight(); - } - - public boolean isLandscape() { - int rotation = getDeviceRotation(); - return rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270; - } - private int getDeviceRotation() { - return ServiceUtil.getWindowManager(getContext()).getDefaultDisplay().getRotation(); - } - - private int getKeyboardLandscapeHeight() { - int keyboardHeight = PreferenceManager.getDefaultSharedPreferences(getContext()) - .getInt("keyboard_height_landscape", defaultCustomKeyboardSize); - return Util.clamp(keyboardHeight, minCustomKeyboardSize, getRootView().getHeight() - minCustomKeyboardTopMarginLandscape); - } - - private int getKeyboardPortraitHeight() { - int keyboardHeight = PreferenceManager.getDefaultSharedPreferences(getContext()) - .getInt("keyboard_height_portrait", defaultCustomKeyboardSize); - return Util.clamp(keyboardHeight, minCustomKeyboardSize, getRootView().getHeight() - minCustomKeyboardTopMarginPortrait); - } - - private void setKeyboardPortraitHeight(int height) { - PreferenceManager.getDefaultSharedPreferences(getContext()) - .edit().putInt("keyboard_height_portrait", height).apply(); - } - - private void setKeyboardLandscapeHeight(int height) { - PreferenceManager.getDefaultSharedPreferences(getContext()) - .edit().putInt("keyboard_height_landscape", height).apply(); - } - - public void postOnKeyboardClose(final Runnable runnable) { - if (keyboardOpen) { - addOnKeyboardHiddenListener(new OnKeyboardHiddenListener() { - @Override public void onKeyboardHidden() { - removeOnKeyboardHiddenListener(this); - runnable.run(); - } - }); - } else { - runnable.run(); - } - } - - public void postOnKeyboardOpen(final Runnable runnable) { - if (!keyboardOpen) { - addOnKeyboardShownListener(new OnKeyboardShownListener() { - @Override public void onKeyboardShown() { - removeOnKeyboardShownListener(this); - runnable.run(); - } - }); - } else { - runnable.run(); - } - } - - public void addOnKeyboardHiddenListener(OnKeyboardHiddenListener listener) { - hiddenListeners.add(listener); - } - - public void removeOnKeyboardHiddenListener(OnKeyboardHiddenListener listener) { - hiddenListeners.remove(listener); - } - - public void addOnKeyboardShownListener(OnKeyboardShownListener listener) { - shownListeners.add(listener); - } - - public void removeOnKeyboardShownListener(OnKeyboardShownListener listener) { - shownListeners.remove(listener); - } - - public void setFullscreen(boolean isFullscreen) { - this.isFullscreen = isFullscreen; - } - - private void notifyHiddenListeners() { - final Set listeners = new HashSet<>(hiddenListeners); - for (OnKeyboardHiddenListener listener : listeners) { - listener.onKeyboardHidden(); - } - } - - private void notifyShownListeners() { - final Set listeners = new HashSet<>(shownListeners); - for (OnKeyboardShownListener listener : listeners) { - listener.onKeyboardShown(); - } - } - - public interface OnKeyboardHiddenListener { - void onKeyboardHidden(); - } - - public interface OnKeyboardShownListener { - void onKeyboardShown(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/LabeledEditText.java b/app/src/main/java/org/thoughtcrime/securesms/components/LabeledEditText.java deleted file mode 100644 index 11c9fcaf1c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/LabeledEditText.java +++ /dev/null @@ -1,87 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Color; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.text.Editable; -import android.text.TextUtils; -import android.util.AttributeSet; -import android.view.View; -import android.view.ViewGroup; -import android.widget.EditText; -import android.widget.FrameLayout; -import android.widget.TextView; - -import network.loki.messenger.R; - -public class LabeledEditText extends FrameLayout implements View.OnFocusChangeListener { - - private TextView label; - private EditText input; - private View border; - private ViewGroup textContainer; - - public LabeledEditText(@NonNull Context context) { - super(context); - init(null); - } - - public LabeledEditText(@NonNull Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - init(attrs); - } - - private void init(@Nullable AttributeSet attrs) { - inflate(getContext(), R.layout.labeled_edit_text, this); - - String labelText = ""; - int backgroundColor = Color.BLACK; - int textLayout = R.layout.labeled_edit_text_default; - - if (attrs != null) { - TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.LabeledEditText, 0, 0); - - labelText = typedArray.getString(R.styleable.LabeledEditText_labeledEditText_label); - backgroundColor = typedArray.getColor(R.styleable.LabeledEditText_labeledEditText_background, Color.BLACK); - textLayout = typedArray.getResourceId(R.styleable.LabeledEditText_labeledEditText_textLayout, R.layout.labeled_edit_text_default); - - typedArray.recycle(); - } - - label = findViewById(R.id.label); - border = findViewById(R.id.border); - textContainer = findViewById(R.id.text_container); - - inflate(getContext(), textLayout, textContainer); - input = findViewById(R.id.input); - - label.setText(labelText); - label.setBackgroundColor(backgroundColor); - - if (TextUtils.isEmpty(labelText)) { - label.setVisibility(INVISIBLE); - } - - input.setOnFocusChangeListener(this); - } - - public EditText getInput() { - return input; - } - - public void setText(String text) { - input.setText(text); - } - - public Editable getText() { - return input.getText(); - } - - @Override - public void onFocusChange(View v, boolean hasFocus) { - border.setBackgroundResource(hasFocus ? R.drawable.labeled_edit_text_background_active - : R.drawable.labeled_edit_text_background_inactive); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/MaxHeightScrollView.java b/app/src/main/java/org/thoughtcrime/securesms/components/MaxHeightScrollView.java deleted file mode 100644 index 744e1a35e5..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/MaxHeightScrollView.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.content.res.TypedArray; -import androidx.annotation.Nullable; -import android.util.AttributeSet; -import android.widget.ScrollView; - -import network.loki.messenger.R; - -public class MaxHeightScrollView extends ScrollView { - - private int maxHeight = -1; - - public MaxHeightScrollView(Context context) { - super(context); - initialize(null); - } - - public MaxHeightScrollView(Context context, AttributeSet attrs) { - super(context, attrs); - initialize(attrs); - } - - private void initialize(@Nullable AttributeSet attrs) { - if (attrs != null) { - TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.MaxHeightScrollView, 0, 0); - - maxHeight = typedArray.getDimensionPixelOffset(R.styleable.MaxHeightScrollView_scrollView_maxHeight, -1); - - typedArray.recycle(); - } - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - if (maxHeight >= 0) { - heightMeasureSpec = MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST); - } - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/MediaView.java b/app/src/main/java/org/thoughtcrime/securesms/components/MediaView.java index cdce4b7260..8a0d5a56e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/MediaView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/MediaView.java @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.components; - import android.content.Context; import android.net.Uri; import android.util.AttributeSet; @@ -10,11 +9,14 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.media3.common.util.UnstableApi; import com.bumptech.glide.RequestManager; import org.session.libsession.utilities.Stub; import org.thoughtcrime.securesms.mms.VideoSlide; +import org.thoughtcrime.securesms.util.FilenameUtils; import org.thoughtcrime.securesms.video.VideoPlayer; import java.io.IOException; @@ -26,6 +28,14 @@ public class MediaView extends FrameLayout { private ZoomingImageView imageView; private Stub videoView; + public interface FullscreenToggleListener { + void toggleFullscreen(); + void setFullscreen(boolean displayFullscreen); + } + + @Nullable + private FullscreenToggleListener fullscreenToggleListener = null; + public MediaView(@NonNull Context context) { super(context); initialize(); @@ -50,43 +60,70 @@ private void initialize() { public void set(@NonNull RequestManager glideRequests, @NonNull Window window, - @NonNull Uri source, + @NonNull Uri sourceUri, @NonNull String mediaType, long size, boolean autoplay) - throws IOException + throws IOException { if (mediaType.startsWith("image/")) { imageView.setVisibility(View.VISIBLE); if (videoView.resolved()) videoView.get().setVisibility(View.GONE); - imageView.setImageUri(glideRequests, source, mediaType); + imageView.setImageUri(glideRequests, sourceUri, mediaType); + + // handle fullscreen toggle based on image tap + imageView.setInteractor(new ZoomingImageView.ZoomImageInteractions() { + @Override + public void onImageTapped() { + if (fullscreenToggleListener != null) fullscreenToggleListener.toggleFullscreen(); + } + }); } else if (mediaType.startsWith("video/")) { imageView.setVisibility(View.GONE); videoView.get().setVisibility(View.VISIBLE); videoView.get().setWindow(window); - videoView.get().setVideoSource(new VideoSlide(getContext(), source, size), autoplay); + + // react to callbacks from video players and pass it on to the fullscreen handling + videoView.get().setInteractor(new VideoPlayer.VideoPlayerInteractions() { + @Override + public void onControllerVisibilityChanged(boolean visible) { + // go fullscreen once the controls are hidden + if(fullscreenToggleListener != null) fullscreenToggleListener.setFullscreen(!visible); + } + }); + + + Context context = getContext(); + String filename = FilenameUtils.getFilenameFromUri(context, sourceUri); + + videoView.get().setVideoSource(new VideoSlide(context, sourceUri, filename, size), autoplay); } else { throw new IOException("Unsupported media type: " + mediaType); } } - public void pause() { + public void setControlsYPosition(int yPosition){ if (this.videoView.resolved()){ - this.videoView.get().pause(); + this.videoView.get().setControlsYPosition(yPosition); } } - public void hideControls() { + public Long pause() { if (this.videoView.resolved()){ - this.videoView.get().hideControls(); + return this.videoView.get().pause(); } + + return 0L; } - public @Nullable View getPlaybackControls() { + public void seek(Long position){ if (this.videoView.resolved()){ - return this.videoView.get().getControlView(); + this.videoView.get().seek(position); } - return null; + } + + public void setFullscreenToggleListener(FullscreenToggleListener listener) { + this.fullscreenToggleListener = listener; } public void cleanup() { @@ -95,4 +132,4 @@ public void cleanup() { this.videoView.get().cleanup(); } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/NestedScrollableHost.kt b/app/src/main/java/org/thoughtcrime/securesms/components/NestedScrollableHost.kt deleted file mode 100644 index ef27c307c7..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/NestedScrollableHost.kt +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.thoughtcrime.securesms.components - -import android.content.Context -import android.util.AttributeSet -import android.view.MotionEvent -import android.view.View -import android.view.ViewConfiguration -import android.widget.FrameLayout -import androidx.viewpager2.widget.ViewPager2 -import androidx.viewpager2.widget.ViewPager2.ORIENTATION_HORIZONTAL -import kotlin.math.absoluteValue -import kotlin.math.sign - -/** - * Layout to wrap a scrollable component inside a ViewPager2. Provided as a solution to the problem - * where pages of ViewPager2 have nested scrollable elements that scroll in the same direction as - * ViewPager2. The scrollable element needs to be the immediate and only child of this host layout. - * - * This solution has limitations when using multiple levels of nested scrollable elements - * (e.g. a horizontal RecyclerView in a vertical RecyclerView in a horizontal ViewPager2). - */ -class NestedScrollableHost : FrameLayout { - constructor(context: Context) : super(context) - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - - private var touchSlop = 0 - private var initialX = 0f - private var initialY = 0f - private val parentViewPager: ViewPager2? - get() { - var v: View? = parent as? View - while (v != null && v !is ViewPager2) { - v = v.parent as? View - } - return v as? ViewPager2 - } - - private val child: View? get() = if (childCount > 0) getChildAt(0) else null - - init { - touchSlop = ViewConfiguration.get(context).scaledTouchSlop - } - - private fun canChildScroll(orientation: Int, delta: Float): Boolean { - val direction = -delta.sign.toInt() - return when (orientation) { - 0 -> child?.canScrollHorizontally(direction) ?: false - 1 -> child?.canScrollVertically(direction) ?: false - else -> throw IllegalArgumentException() - } - } - - override fun onInterceptTouchEvent(e: MotionEvent): Boolean { - handleInterceptTouchEvent(e) - return super.onInterceptTouchEvent(e) - } - - private fun handleInterceptTouchEvent(e: MotionEvent) { - val orientation = parentViewPager?.orientation ?: return - - // Early return if child can't scroll in same direction as parent - if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) { - return - } - - if (e.action == MotionEvent.ACTION_DOWN) { - initialX = e.x - initialY = e.y - parent.requestDisallowInterceptTouchEvent(true) - } else if (e.action == MotionEvent.ACTION_MOVE) { - val dx = e.x - initialX - val dy = e.y - initialY - val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL - - // assuming ViewPager2 touch-slop is 2x touch-slop of child - val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f - val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f - - if (scaledDx > touchSlop || scaledDy > touchSlop) { - if (isVpHorizontal == (scaledDy > scaledDx)) { - // Gesture is perpendicular, allow all parents to intercept - parent.requestDisallowInterceptTouchEvent(false) - } else { - // Gesture is parallel, query child if movement in that direction is possible - if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) { - // Child can scroll, disallow all parents to intercept - parent.requestDisallowInterceptTouchEvent(true) - } else { - // Child cannot scroll, allow all parents to intercept - parent.requestDisallowInterceptTouchEvent(false) - } - } - } - } - } -} \ No newline at end of file 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 f6c47937d2..1d18db392e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -10,6 +10,7 @@ import android.widget.RelativeLayout import com.bumptech.glide.Glide import com.bumptech.glide.RequestManager import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.bitmap.CenterCrop import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewProfilePictureBinding @@ -21,14 +22,20 @@ import org.session.libsession.database.StorageProtocol import org.session.libsession.utilities.Address import org.session.libsession.utilities.AppTextSecurePreferences import org.session.libsession.utilities.GroupUtil +import org.session.libsession.utilities.UsernameUtils import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.truncateIdForDisplay import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.util.AvatarUtils +import org.thoughtcrime.securesms.util.avatarOptions import javax.inject.Inject @AndroidEntryPoint class ProfilePictureView @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null + context: Context, + attrs: AttributeSet? = null ) : RelativeLayout(context, attrs) { private val TAG = "ProfilePictureView" @@ -48,6 +55,15 @@ class ProfilePictureView @JvmOverloads constructor( @Inject lateinit var storage: StorageProtocol + @Inject + lateinit var usernameUtils: UsernameUtils + + @Inject + lateinit var avatarUtils: AvatarUtils + + @Inject + lateinit var proStatusManager: ProStatusManager + private val profilePicturesCache = mutableMapOf() private val resourcePadding by lazy { context.resources.getDimensionPixelSize(R.dimen.normal_padding).toFloat() @@ -59,9 +75,11 @@ class ProfilePictureView @JvmOverloads constructor( update(sender) } - private fun createUnknownRecipientDrawable(): Drawable { - return ResourceContactPhoto(R.drawable.ic_profile_default) - .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false, resourcePadding) + private fun createUnknownRecipientDrawable(publicKey: String? = null): Drawable { + val color = if(publicKey.isNullOrEmpty()) ContactColors.UNKNOWN_COLOR.toConversationColor(context) + else avatarUtils.getColorFromKey(publicKey) + return ResourceContactPhoto(R.drawable.ic_user_filled_custom) + .asDrawable(context, color, false, resourcePadding) } fun update(recipient: Recipient) { @@ -69,51 +87,74 @@ class ProfilePictureView @JvmOverloads constructor( recipient.run { update( address = address, - isLegacyGroupRecipient = isLegacyGroupRecipient, - isCommunityInboxRecipient = isCommunityInboxRecipient, - isGroupsV2Recipient = isGroupV2Recipient + profileViewDataType = when { + isGroupV2Recipient -> ProfileViewDataType.GroupvV2( + customGroupImage = profileAvatar + ) + isLegacyGroupRecipient -> ProfileViewDataType.LegacyGroup + isCommunityRecipient -> ProfileViewDataType.Community + isCommunityInboxRecipient -> ProfileViewDataType.CommunityInbox + else -> ProfileViewDataType.OneOnOne + } ) } } fun update( address: Address, - isLegacyGroupRecipient: Boolean = false, - isCommunityInboxRecipient: Boolean = false, - isGroupsV2Recipient: Boolean = false, + profileViewDataType: ProfileViewDataType = ProfileViewDataType.OneOnOne ) { fun getUserDisplayName(publicKey: String): String = prefs.takeIf { userPublicKey == publicKey }?.getProfileName() - ?: storage.getContactNameWithAccountID(publicKey) + ?: usernameUtils.getContactNameWithAccountID(publicKey) - if (isLegacyGroupRecipient || isGroupsV2Recipient) { - val members = if (isLegacyGroupRecipient) { - groupDatabase - .getGroupMemberAddresses(address.toGroupString(), true) - } else { - storage.getMembers(address.serialize()) - .map { Address.fromSerialized(it.accountIdString()) } - }.sorted().take(2) + // group avatar + if (profileViewDataType is ProfileViewDataType.GroupvV2 || profileViewDataType is ProfileViewDataType.LegacyGroup) { + // if the group has a custom image, use that + // other wise make up a double avatar from the first two members + // if there is only one member then use that member + an unknown icon coloured based on the group id - if (members.size <= 1) { - publicKey = "" + // first check if we have a custom image + if((profileViewDataType as? ProfileViewDataType.GroupvV2)?.customGroupImage != null){ + publicKey = address.toString() 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) + additionalPublicKey = null // we don't want a second image when there is a custom image set + } else { // otherwise apply the logic based on members + + val members = if (profileViewDataType is ProfileViewDataType.LegacyGroup) { + groupDatabase.getGroupMemberAddresses(address.toGroupString(), true) + } else { + storage.getMembers(address.toString()) + .map { Address.fromSerialized(it.accountId()) } + }.sorted().take(2) + + if (members.isEmpty()) { + publicKey = "" + displayName = "" + additionalPublicKey = "" + additionalDisplayName = "" + } else if (members.size == 1) { + val pk = members.getOrNull(0)?.toString() ?: "" + publicKey = pk + displayName = getUserDisplayName(pk) + additionalPublicKey = + address.toString() // use the group address to later generate a colour based on the group id + additionalDisplayName = "" + } else { + val pk = members.getOrNull(0)?.toString() ?: "" + publicKey = pk + displayName = getUserDisplayName(pk) + val apk = members.getOrNull(1)?.toString() ?: "" + additionalPublicKey = apk + additionalDisplayName = getUserDisplayName(apk) + } } - } else if(isCommunityInboxRecipient) { - val publicKey = GroupUtil.getDecodedOpenGroupInboxAccountId(address.serialize()) + } else if(profileViewDataType is ProfileViewDataType.CommunityInbox) { + val publicKey = GroupUtil.getDecodedOpenGroupInboxAccountId(address.toString()) this.publicKey = publicKey displayName = getUserDisplayName(publicKey) additionalPublicKey = null } else { - val publicKey = address.serialize() + val publicKey = address.toString() this.publicKey = publicKey displayName = getUserDisplayName(publicKey) additionalPublicKey = null @@ -142,13 +183,12 @@ class ProfilePictureView @JvmOverloads constructor( glide.clear(binding.doubleModeImageView2) binding.doubleModeImageViewContainer.visibility = View.INVISIBLE } - } private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?) { if (publicKey.isNotEmpty()) { // if we already have a recipient that matches the current key, reuse it - val recipient = if(this.recipient != null && this.recipient?.address?.serialize() == publicKey){ + val recipient = if(this.recipient != null && this.recipient?.address?.toString() == publicKey){ this.recipient!! } else { @@ -164,26 +204,34 @@ class ProfilePictureView @JvmOverloads constructor( glide.clear(imageView) - val placeholder = PlaceholderAvatarPhoto(publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}") + val placeholder = PlaceholderAvatarPhoto( + publicKey, + displayName ?: truncateIdForDisplay(publicKey), + avatarUtils.generateTextBitmap(128, publicKey, displayName) + ) if (signalProfilePicture != null && avatar != "0" && avatar != "") { + val maxSizePx = context.resources.getDimensionPixelSize(R.dimen.medium_profile_picture_size) glide.load(signalProfilePicture) + .avatarOptions( + sizePx = maxSizePx, + freezeFrame = proStatusManager.freezeFrameForUser(recipient.address) + ) .placeholder(createUnknownRecipientDrawable()) - .centerCrop() .error(glide.load(placeholder)) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .circleCrop() + .into(imageView) + } else if(recipient.isGroupRecipient) { // for groups, if we have an unknown it needs to display the unknown icon but with a bg colour based on the group's id + glide.load(createUnknownRecipientDrawable(publicKey)) + .optionalTransform(CenterCrop()) .into(imageView) } else if (recipient.isCommunityRecipient && recipient.groupAvatarId == null) { glide.load(unknownOpenGroupDrawable) - .centerCrop() - .circleCrop() + .optionalTransform(CenterCrop()) .into(imageView) } else { glide.load(placeholder) .placeholder(createUnknownRecipientDrawable()) - .centerCrop() - .circleCrop() + .optionalTransform(CenterCrop()) .diskCacheStrategy(DiskCacheStrategy.NONE) .into(imageView) } @@ -198,4 +246,14 @@ class ProfilePictureView @JvmOverloads constructor( profilePicturesCache.clear() } // endregion + + sealed interface ProfileViewDataType{ + data object OneOnOne: ProfileViewDataType + data object LegacyGroup: ProfileViewDataType + data object Community: ProfileViewDataType + data object CommunityInbox: ProfileViewDataType + data class GroupvV2( + val customGroupImage: String? = null + ): ProfileViewDataType + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java b/app/src/main/java/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java deleted file mode 100644 index 98bce61010..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java +++ /dev/null @@ -1,164 +0,0 @@ -package org.thoughtcrime.securesms.components; - - -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.os.Bundle; -import android.provider.MediaStore; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.FrameLayout; -import android.widget.ImageView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.loader.app.LoaderManager; -import androidx.loader.content.Loader; -import androidx.recyclerview.widget.DefaultItemAnimator; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.bumptech.glide.Glide; -import com.bumptech.glide.load.Key; -import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.bumptech.glide.signature.MediaStoreSignature; - -import org.session.libsession.utilities.ViewUtil; -import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; -import org.thoughtcrime.securesms.database.loaders.RecentPhotosLoader; - -import network.loki.messenger.R; - -public class RecentPhotoViewRail extends FrameLayout implements LoaderManager.LoaderCallbacks { - - @NonNull private final RecyclerView recyclerView; - @Nullable private OnItemClickedListener listener; - - public RecentPhotoViewRail(Context context) { - this(context, null); - } - - public RecentPhotoViewRail(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public RecentPhotoViewRail(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - - inflate(context, R.layout.recent_photo_view, this); - - this.recyclerView = ViewUtil.findById(this, R.id.photo_list); - this.recyclerView.setLayoutManager(new LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)); - this.recyclerView.setItemAnimator(new DefaultItemAnimator()); - } - - public void setListener(@Nullable OnItemClickedListener listener) { - this.listener = listener; - - if (this.recyclerView.getAdapter() != null) { - ((RecentPhotoAdapter)this.recyclerView.getAdapter()).setListener(listener); - } - } - - @Override - public @NonNull Loader onCreateLoader(int id, Bundle args) { - return new RecentPhotosLoader(getContext()); - } - - @Override - public void onLoadFinished(@NonNull Loader loader, Cursor data) { - this.recyclerView.setAdapter(new RecentPhotoAdapter(getContext(), data, RecentPhotosLoader.BASE_URL, listener)); - } - - @Override - public void onLoaderReset(@NonNull Loader loader) { - ((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(null); - } - - private static class RecentPhotoAdapter extends CursorRecyclerViewAdapter { - - @SuppressWarnings("unused") - private static final String TAG = RecentPhotoAdapter.class.getSimpleName(); - - @NonNull private final Uri baseUri; - @Nullable private OnItemClickedListener clickedListener; - - private RecentPhotoAdapter(@NonNull Context context, @NonNull Cursor cursor, @NonNull Uri baseUri, @Nullable OnItemClickedListener listener) { - super(context, cursor); - this.baseUri = baseUri; - this.clickedListener = listener; - } - - @Override - public RecentPhotoViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) { - View itemView = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.recent_photo_view_item, parent, false); - - return new RecentPhotoViewHolder(itemView); - } - - @Override - public void onBindItemViewHolder(RecentPhotoViewHolder viewHolder, @NonNull Cursor cursor) { - viewHolder.imageView.setImageDrawable(null); - - long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns._ID)); - long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_TAKEN)); - long dateModified = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_MODIFIED)); - String mimeType = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.MIME_TYPE)); - String bucketId = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.BUCKET_ID)); - int orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.ORIENTATION)); - long size = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.SIZE)); - int width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation))); - int height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation))); - - final Uri uri = Uri.withAppendedPath(baseUri, Long.toString(id)); - - Key signature = new MediaStoreSignature(mimeType, dateModified, orientation); - - Glide.with(getContext().getApplicationContext()) - .load(uri) - .signature(signature) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .into(viewHolder.imageView); - - viewHolder.imageView.setOnClickListener(v -> { - if (clickedListener != null) clickedListener.onItemClicked(uri, mimeType, bucketId, dateTaken, width, height, size); - }); - - } - - @SuppressWarnings("SuspiciousNameCombination") - private String getWidthColumn(int orientation) { - if (orientation == 0 || orientation == 180) return MediaStore.Images.ImageColumns.WIDTH; - else return MediaStore.Images.ImageColumns.HEIGHT; - } - - @SuppressWarnings("SuspiciousNameCombination") - private String getHeightColumn(int orientation) { - if (orientation == 0 || orientation == 180) return MediaStore.Images.ImageColumns.HEIGHT; - else return MediaStore.Images.ImageColumns.WIDTH; - } - - public void setListener(@Nullable OnItemClickedListener listener) { - this.clickedListener = listener; - } - - static class RecentPhotoViewHolder extends RecyclerView.ViewHolder { - - ImageView imageView; - - RecentPhotoViewHolder(View itemView) { - super(itemView); - - this.imageView = ViewUtil.findById(itemView, R.id.thumbnail); - } - } - } - - public interface OnItemClickedListener { - void onItemClicked(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height, long size); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/RemovableEditableMediaView.java b/app/src/main/java/org/thoughtcrime/securesms/components/RemovableEditableMediaView.java deleted file mode 100644 index aac02118a8..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/RemovableEditableMediaView.java +++ /dev/null @@ -1,79 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.FrameLayout; -import android.widget.ImageView; - -import network.loki.messenger.R; - -public class RemovableEditableMediaView extends FrameLayout { - - private final @NonNull ImageView remove; - private final @NonNull ImageView edit; - - private final int removeSize; - private final int editSize; - - private @Nullable View current; - - public RemovableEditableMediaView(Context context) { - this(context, null); - } - - public RemovableEditableMediaView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public RemovableEditableMediaView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - - this.remove = (ImageView)LayoutInflater.from(context).inflate(R.layout.media_view_remove_button, this, false); - this.edit = (ImageView)LayoutInflater.from(context).inflate(R.layout.media_view_edit_button, this, false); - - this.removeSize = getResources().getDimensionPixelSize(R.dimen.media_bubble_remove_button_size); - this.editSize = getResources().getDimensionPixelSize(R.dimen.media_bubble_edit_button_size); - - this.remove.setVisibility(View.GONE); - this.edit.setVisibility(View.GONE); - } - - @Override - public void onFinishInflate() { - super.onFinishInflate(); - this.addView(remove); - this.addView(edit); - } - - public void display(@Nullable View view, boolean editable) { - edit.setVisibility(editable ? View.VISIBLE : View.GONE); - - if (view == current) return; - if (current != null) current.setVisibility(View.GONE); - - if (view != null) { - view.setPadding(view.getPaddingLeft(), removeSize / 2, removeSize / 2, (int)(8 * getResources().getDisplayMetrics().density)); - edit.setPadding(0, 0, removeSize / 2, 0); - - view.setVisibility(View.VISIBLE); - remove.setVisibility(View.VISIBLE); - } else { - remove.setVisibility(View.GONE); - edit.setVisibility(View.GONE); - } - - current = view; - } - - public void setRemoveClickListener(View.OnClickListener listener) { - this.remove.setOnClickListener(listener); - } - - public void setEditClickListener(View.OnClickListener listener) { - this.edit.setOnClickListener(listener); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/RepeatableImageKey.java b/app/src/main/java/org/thoughtcrime/securesms/components/RepeatableImageKey.java deleted file mode 100644 index 39ddf092cb..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/RepeatableImageKey.java +++ /dev/null @@ -1,84 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import androidx.appcompat.widget.AppCompatImageButton; -import android.util.AttributeSet; -import android.view.HapticFeedbackConstants; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewConfiguration; - -public class RepeatableImageKey extends AppCompatImageButton { - - private KeyEventListener listener; - - public RepeatableImageKey(Context context) { - super(context); - init(); - } - - public RepeatableImageKey(Context context, AttributeSet attrs) { - super(context, attrs); - init(); - } - - public RepeatableImageKey(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - init(); - } - - private void init() { - setOnClickListener(new RepeaterClickListener()); - setOnTouchListener(new RepeaterTouchListener()); - } - - public void setOnKeyEventListener(KeyEventListener listener) { - this.listener = listener; - } - - private void notifyListener() { - if (this.listener != null) this.listener.onKeyEvent(); - } - - private class RepeaterClickListener implements OnClickListener { - @Override public void onClick(View v) { - notifyListener(); - } - } - - private class Repeater implements Runnable { - @Override - public void run() { - notifyListener(); - postDelayed(this, ViewConfiguration.getKeyRepeatDelay()); - } - } - - private class RepeaterTouchListener implements OnTouchListener { - private final Repeater repeater; - - RepeaterTouchListener() { - this.repeater = new Repeater(); - } - - @Override - public boolean onTouch(View view, MotionEvent motionEvent) { - switch (motionEvent.getAction()) { - case MotionEvent.ACTION_DOWN: - view.postDelayed(repeater, ViewConfiguration.getKeyRepeatTimeout()); - performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); - return false; - case MotionEvent.ACTION_CANCEL: - case MotionEvent.ACTION_UP: - view.removeCallbacks(repeater); - return false; - default: - return false; - } - } - } - - public interface KeyEventListener { - void onKeyEvent(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SafeViewPager.kt b/app/src/main/java/org/thoughtcrime/securesms/components/SafeViewPager.kt deleted file mode 100644 index 6748478736..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/SafeViewPager.kt +++ /dev/null @@ -1,30 +0,0 @@ -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 30e609a047..58b6d3a459 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java @@ -39,7 +39,7 @@ public SearchToolbar(Context context, @Nullable AttributeSet attrs, int defStyle } private void initialize() { - setNavigationIcon(getContext().getResources().getDrawable(R.drawable.ic_baseline_clear_24)); + setNavigationIcon(getContext().getResources().getDrawable(R.drawable.ic_x)); inflateMenu(R.menu.conversation_list_search); this.searchItem = getMenu().findItem(R.id.action_filter_search); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ShapeScrim.java b/app/src/main/java/org/thoughtcrime/securesms/components/ShapeScrim.java deleted file mode 100644 index b4239ecdd9..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ShapeScrim.java +++ /dev/null @@ -1,107 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.PorterDuff; -import android.graphics.PorterDuffXfermode; -import android.graphics.RectF; -import android.util.AttributeSet; -import android.view.View; - -import network.loki.messenger.R; - -public class ShapeScrim extends View { - - private enum ShapeType { - CIRCLE, SQUARE - } - - private final Paint eraser; - private final ShapeType shape; - private final float radius; - - private Bitmap scrim; - private Canvas scrimCanvas; - - public ShapeScrim(Context context) { - this(context, null); - } - - public ShapeScrim(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public ShapeScrim(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - - if (attrs != null) { - TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ShapeScrim, 0, 0); - String shapeName = typedArray.getString(R.styleable.ShapeScrim_shape); - - if ("square".equalsIgnoreCase(shapeName)) this.shape = ShapeType.SQUARE; - else if ("circle".equalsIgnoreCase(shapeName)) this.shape = ShapeType.CIRCLE; - else this.shape = ShapeType.SQUARE; - - this.radius = typedArray.getFloat(R.styleable.ShapeScrim_radius, 0.4f); - - typedArray.recycle(); - } else { - this.shape = ShapeType.SQUARE; - this.radius = 0.4f; - } - - this.eraser = new Paint(); - this.eraser.setColor(0xFFFFFFFF); - this.eraser.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); - } - - @Override - public void onDraw(Canvas canvas) { - super.onDraw(canvas); - - int shortDimension = getWidth() < getHeight() ? getWidth() : getHeight(); - float drawRadius = shortDimension * radius; - - if (scrimCanvas == null) { - scrim = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888); - scrimCanvas = new Canvas(scrim); - } - - scrim.eraseColor(Color.TRANSPARENT); - scrimCanvas.drawColor(Color.parseColor("#55BDBDBD")); - - if (shape == ShapeType.CIRCLE) drawCircle(scrimCanvas, drawRadius, eraser); - else drawSquare(scrimCanvas, drawRadius, eraser); - - canvas.drawBitmap(scrim, 0, 0, null); - } - - @Override - public void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { - super.onSizeChanged(width, height, oldHeight, oldHeight); - - if (width != oldWidth || height != oldHeight) { - scrim = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); - scrimCanvas = new Canvas(scrim); - } - } - - private void drawCircle(Canvas canvas, float radius, Paint eraser) { - canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius, eraser); - } - - private void drawSquare(Canvas canvas, float radius, Paint eraser) { - float left = (getWidth() / 2 ) - radius; - float top = (getHeight() / 2) - radius; - float right = left + (radius * 2); - float bottom = top + (radius * 2); - - RectF square = new RectF(left, top, right, bottom); - - canvas.drawRoundRect(square, 25, 25, eraser); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.kt b/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.kt index 9161dd828d..a39972b23d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SwitchPreferenceCompat.kt @@ -2,33 +2,29 @@ package org.thoughtcrime.securesms.components import android.content.Context import android.util.AttributeSet -import androidx.preference.CheckBoxPreference -import com.squareup.phrase.Phrase +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.preference.PreferenceViewHolder +import androidx.preference.TwoStatePreference +import kotlinx.coroutines.flow.MutableStateFlow import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.thoughtcrime.securesms.ui.components.SessionSwitch import org.thoughtcrime.securesms.ui.getSubbedCharSequence -import org.thoughtcrime.securesms.ui.getSubbedString +import org.thoughtcrime.securesms.ui.setThemedContent -class SwitchPreferenceCompat : CheckBoxPreference { +class SwitchPreferenceCompat : TwoStatePreference { private var listener: OnPreferenceClickListener? = null - constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context!!, attrs, defStyleAttr) { - setLayoutRes() - } - - constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context!!, attrs, defStyleAttr, defStyleRes) { - setLayoutRes() - } - - constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) { - setLayoutRes() - } + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs, androidx.preference.R.attr.switchPreferenceCompatStyle) + constructor(context: Context) : this(context, null, androidx.preference.R.attr.switchPreferenceCompatStyle) - constructor(context: Context?) : super(context!!) { - setLayoutRes() - } + private val checkState = MutableStateFlow(isChecked) + private val enableState = MutableStateFlow(isEnabled) - private fun setLayoutRes() { + init { widgetLayoutResource = R.layout.switch_compat_preference if (this.hasKey()) { @@ -43,6 +39,31 @@ class SwitchPreferenceCompat : CheckBoxPreference { } } + override fun setChecked(checked: Boolean) { + super.setChecked(checked) + + checkState.value = checked + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + + enableState.value = enabled + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + + val composeView = holder.findViewById(R.id.compose_preference) as ComposeView + composeView.setThemedContent { + SessionSwitch( + checked = checkState.collectAsState().value, + onCheckedChange = null, + enabled = isEnabled + ) + } + } + override fun setOnPreferenceClickListener(listener: OnPreferenceClickListener?) { this.listener = listener } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.kt deleted file mode 100644 index 03604079a5..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.kt +++ /dev/null @@ -1,182 +0,0 @@ -package org.thoughtcrime.securesms.components - -import android.animation.LayoutTransition -import android.content.Context -import android.util.AttributeSet -import android.view.View -import android.widget.FrameLayout -import android.widget.TextView -import androidx.core.content.ContextCompat -import com.annimon.stream.Stream -import com.pnikosis.materialishprogress.ProgressWheel -import kotlin.math.max -import network.loki.messenger.R -import org.greenrobot.eventbus.EventBus -import org.greenrobot.eventbus.Subscribe -import org.greenrobot.eventbus.ThreadMode -import org.session.libsession.messaging.sending_receiving.attachments.Attachment -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress -import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY -import org.session.libsession.utilities.ViewUtil -import org.thoughtcrime.securesms.events.PartProgressEvent -import org.thoughtcrime.securesms.mms.Slide -import org.thoughtcrime.securesms.ui.getSubbedString - -class TransferControlView @JvmOverloads constructor(context: Context?, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : FrameLayout(context!!, attrs, defStyleAttr) { - private var slides: List? = null - private var current: View? = null - - private val progressWheel: ProgressWheel - private val downloadDetails: View - private val downloadDetailsText: TextView - private val downloadProgress: MutableMap - - init { - inflate(context, R.layout.transfer_controls_view, this) - - isLongClickable = false - ViewUtil.setBackground(this, ContextCompat.getDrawable(context!!, R.drawable.transfer_controls_background)) - visibility = GONE - layoutTransition = LayoutTransition() - - this.downloadProgress = HashMap() - this.progressWheel = ViewUtil.findById(this, R.id.progress_wheel) - this.downloadDetails = ViewUtil.findById(this, R.id.download_details) - this.downloadDetailsText = ViewUtil.findById(this, R.id.download_details_text) - } - - override fun setFocusable(focusable: Boolean) { - super.setFocusable(focusable) - downloadDetails.isFocusable = focusable - } - - override fun setClickable(clickable: Boolean) { - super.setClickable(clickable) - downloadDetails.isClickable = clickable - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this) - } - - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - EventBus.getDefault().unregister(this) - } - - private fun setSlides(slides: List) { - require(slides.isNotEmpty()) { "Must provide at least one slide." } - - this.slides = slides - - if (!isUpdateToExistingSet(slides)) { - downloadProgress.clear() - Stream.of(slides).forEach { s: Slide -> downloadProgress[s.asAttachment()] = 0f } - } - - for (slide in slides) { - if (slide.asAttachment().transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE) { - downloadProgress[slide.asAttachment()] = 1f - } - } - - when (getTransferState(slides)) { - AttachmentTransferProgress.TRANSFER_PROGRESS_STARTED -> showProgressSpinner(calculateProgress(downloadProgress)) - AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING, AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED -> { - downloadDetailsText.text = getDownloadText(this.slides!!) - display(downloadDetails) - } - - else -> display(null) - } - } - - @JvmOverloads - fun showProgressSpinner(progress: Float = calculateProgress(downloadProgress)) { - if (progress == 0f) { - progressWheel.spin() - } else { - progressWheel.setInstantProgress(progress) - } - display(progressWheel) - } - - fun clear() { - clearAnimation() - visibility = GONE - if (current != null) { - current!!.clearAnimation() - current!!.visibility = GONE - } - current = null - slides = null - } - - private fun isUpdateToExistingSet(slides: List): Boolean { - if (slides.size != downloadProgress.size) { - return false - } - - for (slide in slides) { - if (!downloadProgress.containsKey(slide.asAttachment())) { - return false - } - } - - return true - } - - private fun getTransferState(slides: List): Int { - var transferState = AttachmentTransferProgress.TRANSFER_PROGRESS_DONE - for (slide in slides) { - transferState = if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING && transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE) { - slide.transferState - } else { - max(transferState.toDouble(), slide.transferState.toDouble()).toInt() - } - } - return transferState - } - - private fun getDownloadText(slides: List): String { - if (slides.size == 1) { - return slides[0].contentDescription - } else { - val downloadCount = Stream.of(slides).reduce(0) { count: Int, slide: Slide -> - if (slide.transferState != AttachmentTransferProgress.TRANSFER_PROGRESS_DONE) count + 1 else count - } - return context.getSubbedString(R.string.andMore, COUNT_KEY to downloadCount.toString()) - } - } - - private fun display(view: View?) { - if (current != null) { - current!!.visibility = GONE - } - - if (view != null) { - view.visibility = VISIBLE - } else { - visibility = GONE - } - - current = view - } - - private fun calculateProgress(downloadProgress: Map): Float { - var totalProgress = 0f - for (progress in downloadProgress.values) { - totalProgress += progress / downloadProgress.size - } - return totalProgress - } - - @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) - fun onEventAsync(event: PartProgressEvent) { - if (downloadProgress.containsKey(event.attachment)) { - downloadProgress[event.attachment] = event.progress.toFloat() / event.total - progressWheel.setInstantProgress(calculateProgress(downloadProgress)) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.java deleted file mode 100644 index b246bca4d3..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.java +++ /dev/null @@ -1,139 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.net.Uri; -import android.os.AsyncTask; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.util.AttributeSet; - - -import org.session.libsignal.utilities.Log; -import android.util.Pair; -import android.view.View; -import android.widget.FrameLayout; - -import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.bumptech.glide.request.target.Target; -import com.davemorrissey.labs.subscaleview.ImageSource; -import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView; -import com.davemorrissey.labs.subscaleview.decoder.DecoderFactory; -import com.github.chrisbanes.photoview.PhotoView; - -import network.loki.messenger.R; -import org.thoughtcrime.securesms.components.subsampling.AttachmentBitmapDecoder; -import org.thoughtcrime.securesms.components.subsampling.AttachmentRegionDecoder; -import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; -import com.bumptech.glide.RequestManager; -import org.thoughtcrime.securesms.mms.PartAuthority; -import org.thoughtcrime.securesms.util.BitmapDecodingException; -import org.thoughtcrime.securesms.util.BitmapUtil; -import org.thoughtcrime.securesms.util.MediaUtil; - -import java.io.IOException; -import java.io.InputStream; - - -public class ZoomingImageView extends FrameLayout { - - private static final String TAG = ZoomingImageView.class.getSimpleName(); - - private final PhotoView photoView; - private final SubsamplingScaleImageView subsamplingImageView; - - public ZoomingImageView(Context context) { - this(context, null); - } - - public ZoomingImageView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public ZoomingImageView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - - inflate(context, R.layout.zooming_image_view, this); - - this.photoView = findViewById(R.id.image_view); - this.subsamplingImageView = findViewById(R.id.subsampling_image_view); - - this.subsamplingImageView.setOrientation(SubsamplingScaleImageView.ORIENTATION_USE_EXIF); - } - - @SuppressLint("StaticFieldLeak") - public void setImageUri(@NonNull RequestManager glideRequests, @NonNull Uri uri, @NonNull String contentType) - { - final Context context = getContext(); - final int maxTextureSize = BitmapUtil.getMaxTextureSize(); - - Log.i(TAG, "Max texture size: " + maxTextureSize); - - new AsyncTask>() { - @Override - protected @Nullable Pair doInBackground(Void... params) { - if (MediaUtil.isGif(contentType)) return null; - - try { - InputStream inputStream = PartAuthority.getAttachmentStream(context, uri); - return BitmapUtil.getDimensions(inputStream); - } catch (IOException | BitmapDecodingException e) { - Log.w(TAG, e); - return null; - } - } - - protected void onPostExecute(@Nullable Pair dimensions) { - Log.i(TAG, "Dimensions: " + (dimensions == null ? "(null)" : dimensions.first + ", " + dimensions.second)); - - if (dimensions == null || (dimensions.first <= maxTextureSize && dimensions.second <= maxTextureSize)) { - Log.i(TAG, "Loading in standard image view..."); - setImageViewUri(glideRequests, uri); - } else { - Log.i(TAG, "Loading in subsampling image view..."); - setSubsamplingImageViewUri(uri); - } - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - private void setImageViewUri(@NonNull RequestManager glideRequests, @NonNull Uri uri) { - photoView.setVisibility(View.VISIBLE); - subsamplingImageView.setVisibility(View.GONE); - - glideRequests.load(new DecryptableUri(uri)) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .dontTransform() - .override(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) - .into(photoView); - } - - private void setSubsamplingImageViewUri(@NonNull Uri uri) { - subsamplingImageView.setBitmapDecoderFactory(new AttachmentBitmapDecoderFactory()); - subsamplingImageView.setRegionDecoderFactory(new AttachmentRegionDecoderFactory()); - - subsamplingImageView.setVisibility(View.VISIBLE); - photoView.setVisibility(View.GONE); - - subsamplingImageView.setImage(ImageSource.uri(uri)); - } - - public void cleanup() { - photoView.setImageDrawable(null); - subsamplingImageView.recycle(); - } - - private static class AttachmentBitmapDecoderFactory implements DecoderFactory { - @Override - public AttachmentBitmapDecoder make() throws IllegalAccessException, InstantiationException { - return new AttachmentBitmapDecoder(); - } - } - - private static class AttachmentRegionDecoderFactory implements DecoderFactory { - @Override - public AttachmentRegionDecoder make() throws IllegalAccessException, InstantiationException { - return new AttachmentRegionDecoder(); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.kt new file mode 100644 index 0000000000..7c8ec46a5d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ZoomingImageView.kt @@ -0,0 +1,164 @@ +package org.thoughtcrime.securesms.components + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.PointF +import android.net.Uri +import android.os.AsyncTask +import android.util.AttributeSet +import android.util.Pair +import android.view.GestureDetector +import android.view.MotionEvent +import android.widget.FrameLayout +import com.bumptech.glide.RequestManager +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.request.target.Target +import com.davemorrissey.labs.subscaleview.ImageSource +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import com.davemorrissey.labs.subscaleview.decoder.DecoderFactory +import com.github.chrisbanes.photoview.PhotoView +import network.loki.messenger.R +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.components.subsampling.AttachmentBitmapDecoder +import org.thoughtcrime.securesms.components.subsampling.AttachmentRegionDecoder +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri +import org.thoughtcrime.securesms.mms.PartAuthority +import org.thoughtcrime.securesms.util.BitmapDecodingException +import org.thoughtcrime.securesms.util.BitmapUtil +import org.thoughtcrime.securesms.util.MediaUtil +import java.io.IOException + +class ZoomingImageView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : + FrameLayout(context, attrs, defStyleAttr) { + private val photoView: PhotoView + private val subsamplingImageView: SubsamplingScaleImageView + + interface ZoomImageInteractions { + fun onImageTapped() + } + + private var interactor: ZoomImageInteractions? = null + + init { + inflate(context, R.layout.zooming_image_view, this) + + this.photoView = findViewById(R.id.image_view) + + this.subsamplingImageView = findViewById(R.id.subsampling_image_view) + + subsamplingImageView.orientation = SubsamplingScaleImageView.ORIENTATION_USE_EXIF + } + + fun setInteractor(interactor: ZoomImageInteractions?) { + this.interactor = interactor + } + + @SuppressLint("StaticFieldLeak") + fun setImageUri(glideRequests: RequestManager, uri: Uri, contentType: String) { + val context = context + val maxTextureSize = BitmapUtil.getMaxTextureSize() + + Log.i( + TAG, + "Max texture size: $maxTextureSize" + ) + + object : AsyncTask?>() { + override fun doInBackground(vararg params: Void?): Pair? { + if (MediaUtil.isGif(contentType)) return null + + try { + val inputStream = PartAuthority.getAttachmentStream(context, uri) + return BitmapUtil.getDimensions(inputStream) + } catch (e: IOException) { + Log.w(TAG, e) + return null + } catch (e: BitmapDecodingException) { + Log.w(TAG, e) + return null + } + } + + override fun onPostExecute(dimensions: Pair?) { + Log.i( + TAG, + "Dimensions: " + (if (dimensions == null) "(null)" else dimensions.first.toString() + ", " + dimensions.second) + ) + + if (dimensions == null || (dimensions.first <= maxTextureSize && dimensions.second <= maxTextureSize)) { + Log.i(TAG, "Loading in standard image view...") + setImageViewUri(glideRequests, uri) + } else { + Log.i(TAG, "Loading in subsampling image view...") + setSubsamplingImageViewUri(uri) + } + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } + + private fun setImageViewUri(glideRequests: RequestManager, uri: Uri) { + photoView.visibility = VISIBLE + subsamplingImageView.visibility = GONE + + photoView.setOnViewTapListener { _, _, _ -> + if (interactor != null) interactor!!.onImageTapped() + } + + glideRequests.load(DecryptableUri(uri)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .dontTransform() + .override(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) + .into(photoView) + } + + @SuppressLint("ClickableViewAccessibility") + private fun setSubsamplingImageViewUri(uri: Uri) { + subsamplingImageView.setBitmapDecoderFactory(AttachmentBitmapDecoderFactory()) + subsamplingImageView.setRegionDecoderFactory(AttachmentRegionDecoderFactory()) + + subsamplingImageView.visibility = VISIBLE + photoView.visibility = GONE + + val gestureDetector = GestureDetector( + context, + object : GestureDetector.SimpleOnGestureListener() { + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + interactor?.onImageTapped() + return true + } + } + ) + + subsamplingImageView.setImage(ImageSource.uri(uri)) + subsamplingImageView.setOnTouchListener { v, event -> + gestureDetector.onTouchEvent(event) + } + } + + fun cleanup() { + photoView.setImageDrawable(null) + subsamplingImageView.recycle() + } + + private class AttachmentBitmapDecoderFactory : DecoderFactory { + @Throws(IllegalAccessException::class, InstantiationException::class) + override fun make(): AttachmentBitmapDecoder { + return AttachmentBitmapDecoder() + } + } + + private class AttachmentRegionDecoderFactory : DecoderFactory { + @Throws(IllegalAccessException::class, InstantiationException::class) + override fun make(): AttachmentRegionDecoder { + return AttachmentRegionDecoder() + } + } + + companion object { + private val TAG: String = ZoomingImageView::class.java.simpleName + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraSurfaceView.java b/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraSurfaceView.java deleted file mode 100644 index 7a991eae5c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraSurfaceView.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.thoughtcrime.securesms.components.camera; - -import android.content.Context; -import android.view.SurfaceHolder; -import android.view.SurfaceView; - -public class CameraSurfaceView extends SurfaceView implements SurfaceHolder.Callback { - private boolean ready; - - @SuppressWarnings("deprecation") - public CameraSurfaceView(Context context) { - super(context); - getHolder().setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS); - getHolder().addCallback(this); - } - - public boolean isReady() { - return ready; - } - - @Override - public void surfaceCreated(SurfaceHolder holder) { - ready = true; - } - - @Override - public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {} - - @Override - public void surfaceDestroyed(SurfaceHolder holder) { - ready = false; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraUtils.java b/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraUtils.java deleted file mode 100644 index 1aea994a98..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraUtils.java +++ /dev/null @@ -1,106 +0,0 @@ -package org.thoughtcrime.securesms.components.camera; - -import android.app.Activity; -import android.hardware.Camera; -import android.hardware.Camera.CameraInfo; -import android.hardware.Camera.Parameters; -import android.hardware.Camera.Size; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.util.DisplayMetrics; -import org.session.libsignal.utilities.Log; -import android.view.Surface; - -import java.util.Collections; -import java.util.Comparator; -import java.util.LinkedList; -import java.util.List; - -@SuppressWarnings("deprecation") -public class CameraUtils { - private static final String TAG = CameraUtils.class.getSimpleName(); - /* - * modified from: https://github.com/commonsguy/cwac-camera/blob/master/camera/src/com/commonsware/cwac/camera/CameraUtils.java - */ - public static @Nullable Size getPreferredPreviewSize(int displayOrientation, - int width, - int height, - @NonNull Parameters parameters) { - final int targetWidth = displayOrientation % 180 == 90 ? height : width; - final int targetHeight = displayOrientation % 180 == 90 ? width : height; - final double targetRatio = (double) targetWidth / targetHeight; - - Log.d(TAG, String.format("getPreferredPreviewSize(%d, %d, %d) -> target %dx%d, AR %.02f", - displayOrientation, width, height, - targetWidth, targetHeight, targetRatio)); - - List sizes = parameters.getSupportedPreviewSizes(); - List ideals = new LinkedList<>(); - List bigEnough = new LinkedList<>(); - - for (Size size : sizes) { - Log.d(TAG, String.format(" %dx%d (%.02f)", size.width, size.height, (float)size.width / size.height)); - - if (size.height == size.width * targetRatio && size.height >= targetHeight && size.width >= targetWidth) { - ideals.add(size); - Log.d(TAG, " (ideal ratio)"); - } else if (size.width >= targetWidth && size.height >= targetHeight) { - bigEnough.add(size); - Log.d(TAG, " (good size, suboptimal ratio)"); - } - } - - if (!ideals.isEmpty()) return Collections.min(ideals, new AreaComparator()); - else if (!bigEnough.isEmpty()) return Collections.min(bigEnough, new AspectRatioComparator(targetRatio)); - else return Collections.max(sizes, new AreaComparator()); - } - - // based on - // http://developer.android.com/reference/android/hardware/Camera.html#setDisplayOrientation(int) - // and http://stackoverflow.com/a/10383164/115145 - public static int getCameraDisplayOrientation(@NonNull Activity activity, - @NonNull CameraInfo info) - { - int rotation = activity.getWindowManager().getDefaultDisplay().getRotation(); - int degrees = 0; - DisplayMetrics dm = new DisplayMetrics(); - - activity.getWindowManager().getDefaultDisplay().getMetrics(dm); - - switch (rotation) { - case Surface.ROTATION_0: degrees = 0; break; - case Surface.ROTATION_90: degrees = 90; break; - case Surface.ROTATION_180: degrees = 180; break; - case Surface.ROTATION_270: degrees = 270; break; - } - - if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { - return (360 - ((info.orientation + degrees) % 360)) % 360; - } else { - return (info.orientation - degrees + 360) % 360; - } - } - - private static class AreaComparator implements Comparator { - @Override - public int compare(Size lhs, Size rhs) { - return Long.signum(lhs.width * lhs.height - rhs.width * rhs.height); - } - } - - private static class AspectRatioComparator extends AreaComparator { - private final double target; - public AspectRatioComparator(double target) { - this.target = target; - } - - @Override - public int compare(Size lhs, Size rhs) { - final double lhsDiff = Math.abs(target - (double) lhs.width / lhs.height); - final double rhsDiff = Math.abs(target - (double) rhs.width / rhs.height); - if (lhsDiff < rhsDiff) return -1; - else if (lhsDiff > rhsDiff) return 1; - else return super.compare(lhs, rhs); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraView.java b/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraView.java deleted file mode 100644 index 5b04e39289..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/camera/CameraView.java +++ /dev/null @@ -1,587 +0,0 @@ -/*** - Copyright (c) 2013-2014 CommonsWare, LLC - Portions Copyright (C) 2007 The Android Open Source Project - - Licensed under the Apache License, Version 2.0 (the "License"); you may - not use this file except in compliance with the License. You may obtain - a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - */ - -package org.thoughtcrime.securesms.components.camera; - -import android.app.Activity; -import android.content.Context; -import android.content.pm.ActivityInfo; -import android.content.res.TypedArray; -import android.graphics.Color; -import android.graphics.Rect; -import android.hardware.Camera; -import android.hardware.Camera.CameraInfo; -import android.hardware.Camera.Parameters; -import android.hardware.Camera.Size; -import android.os.AsyncTask; -import android.os.Build; -import android.util.AttributeSet; -import android.view.OrientationEventListener; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.Util; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.guava.Optional; -import org.thoughtcrime.securesms.util.BitmapUtil; - -import java.io.IOException; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; - -import network.loki.messenger.R; - -@SuppressWarnings("deprecation") -public class CameraView extends ViewGroup { - private static final String TAG = CameraView.class.getSimpleName(); - - private final CameraSurfaceView surface; - private final OnOrientationChange onOrientationChange; - - private volatile Optional camera = Optional.absent(); - private volatile int cameraId = CameraInfo.CAMERA_FACING_BACK; - private volatile int displayOrientation = -1; - - private @NonNull State state = State.PAUSED; - private @Nullable Size previewSize; - private @NonNull List listeners = Collections.synchronizedList(new LinkedList()); - private int outputOrientation = -1; - - public CameraView(Context context) { - this(context, null); - } - - public CameraView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public CameraView(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - setBackgroundColor(Color.BLACK); - - if (attrs != null) { - TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CameraView); - int camera = typedArray.getInt(R.styleable.CameraView_camera, -1); - - if (camera != -1) cameraId = camera; - else if (isMultiCamera()) cameraId = TextSecurePreferences.getDirectCaptureCameraId(context); - - typedArray.recycle(); - } - - surface = new CameraSurfaceView(getContext()); - onOrientationChange = new OnOrientationChange(context.getApplicationContext()); - addView(surface); - } - - public void onResume() { - if (state != State.PAUSED) return; - state = State.RESUMED; - Log.i(TAG, "onResume() queued"); - enqueueTask(new SerialAsyncTask() { - @Override - protected - @Nullable - Void onRunBackground() { - try { - long openStartMillis = System.currentTimeMillis(); - camera = Optional.fromNullable(Camera.open(cameraId)); - Log.i(TAG, "camera.open() -> " + (System.currentTimeMillis() - openStartMillis) + "ms"); - synchronized (CameraView.this) { - CameraView.this.notifyAll(); - } - if (camera.isPresent()) onCameraReady(camera.get()); - } catch (Exception e) { - Log.w(TAG, e); - } - return null; - } - - @Override - protected void onPostMain(Void avoid) { - if (!camera.isPresent()) { - Log.w(TAG, "tried to open camera but got null"); - for (CameraViewListener listener : listeners) { - listener.onCameraFail(); - } - return; - } - - if (getActivity().getRequestedOrientation() != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) { - onOrientationChange.enable(); - } - Log.i(TAG, "onResume() completed"); - } - }); - } - - public void onPause() { - if (state == State.PAUSED) return; - state = State.PAUSED; - Log.i(TAG, "onPause() queued"); - - enqueueTask(new SerialAsyncTask() { - private Optional cameraToDestroy; - - @Override - protected void onPreMain() { - cameraToDestroy = camera; - camera = Optional.absent(); - } - - @Override - protected Void onRunBackground() { - if (cameraToDestroy.isPresent()) { - try { - stopPreview(); - cameraToDestroy.get().setPreviewCallback(null); - cameraToDestroy.get().release(); - Log.w(TAG, "released old camera instance"); - } catch (Exception e) { - Log.w(TAG, e); - } - } - return null; - } - - @Override protected void onPostMain(Void avoid) { - onOrientationChange.disable(); - displayOrientation = -1; - outputOrientation = -1; - removeView(surface); - addView(surface); - Log.i(TAG, "onPause() completed"); - } - }); - - for (CameraViewListener listener : listeners) { - listener.onCameraStop(); - } - } - - public boolean isStarted() { - return state != State.PAUSED; - } - - @SuppressWarnings("SuspiciousNameCombination") - @Override - protected void onLayout(boolean changed, int l, int t, int r, int b) { - final int width = r - l; - final int height = b - t; - final int previewWidth; - final int previewHeight; - - if (camera.isPresent() && previewSize != null) { - if (displayOrientation == 90 || displayOrientation == 270) { - previewWidth = previewSize.height; - previewHeight = previewSize.width; - } else { - previewWidth = previewSize.width; - previewHeight = previewSize.height; - } - } else { - previewWidth = width; - previewHeight = height; - } - - if (previewHeight == 0 || previewWidth == 0) { - Log.w(TAG, "skipping layout due to zero-width/height preview size"); - return; - } - - if (width * previewHeight > height * previewWidth) { - final int scaledChildHeight = previewHeight * width / previewWidth; - surface.layout(0, (height - scaledChildHeight) / 2, width, (height + scaledChildHeight) / 2); - } else { - final int scaledChildWidth = previewWidth * height / previewHeight; - surface.layout((width - scaledChildWidth) / 2, 0, (width + scaledChildWidth) / 2, height); - } - } - - @Override - protected void onSizeChanged(int w, int h, int oldw, int oldh) { - Log.i(TAG, "onSizeChanged(" + oldw + "x" + oldh + " -> " + w + "x" + h + ")"); - super.onSizeChanged(w, h, oldw, oldh); - if (camera.isPresent()) startPreview(camera.get().getParameters()); - } - - public void addListener(@NonNull CameraViewListener listener) { - listeners.add(listener); - } - - public void setPreviewCallback(final @NonNull PreviewCallback previewCallback) { - enqueueTask(new PostInitializationTask() { - @Override - protected void onPostMain(Void avoid) { - if (camera.isPresent()) { - camera.get().setPreviewCallback(new Camera.PreviewCallback() { - @Override - public void onPreviewFrame(byte[] data, Camera camera) { - if (!CameraView.this.camera.isPresent()) { - return; - } - - final int rotation = getCameraPictureOrientation(); - final Size previewSize = camera.getParameters().getPreviewSize(); - if (data != null) { - previewCallback.onPreviewFrame(new PreviewFrame(data, previewSize.width, previewSize.height, rotation)); - } - } - }); - } - } - }); - } - - public boolean isMultiCamera() { - return Camera.getNumberOfCameras() > 1; - } - - private void onCameraReady(final @NonNull Camera camera) { - final Parameters parameters = camera.getParameters(); - - parameters.setRecordingHint(true); - final List focusModes = parameters.getSupportedFocusModes(); - if (focusModes.contains(Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) { - parameters.setFocusMode(Parameters.FOCUS_MODE_CONTINUOUS_PICTURE); - } else if (focusModes.contains(Parameters.FOCUS_MODE_CONTINUOUS_VIDEO)) { - parameters.setFocusMode(Parameters.FOCUS_MODE_CONTINUOUS_VIDEO); - } - - displayOrientation = CameraUtils.getCameraDisplayOrientation(getActivity(), getCameraInfo()); - camera.setDisplayOrientation(displayOrientation); - camera.setParameters(parameters); - enqueueTask(new PostInitializationTask() { - @Override - protected Void onRunBackground() { - try { - camera.setPreviewDisplay(surface.getHolder()); - startPreview(parameters); - } catch (Exception e) { - Log.w(TAG, "couldn't set preview display", e); - } - return null; - } - }); - } - - private void startPreview(final @NonNull Parameters parameters) { - if (this.camera.isPresent()) { - try { - final Camera camera = this.camera.get(); - final Size preferredPreviewSize = getPreferredPreviewSize(parameters); - - if (preferredPreviewSize != null && !parameters.getPreviewSize().equals(preferredPreviewSize)) { - Log.i(TAG, "starting preview with size " + preferredPreviewSize.width + "x" + preferredPreviewSize.height); - if (state == State.ACTIVE) stopPreview(); - previewSize = preferredPreviewSize; - parameters.setPreviewSize(preferredPreviewSize.width, preferredPreviewSize.height); - camera.setParameters(parameters); - } else { - previewSize = parameters.getPreviewSize(); - } - long previewStartMillis = System.currentTimeMillis(); - camera.startPreview(); - Log.i(TAG, "camera.startPreview() -> " + (System.currentTimeMillis() - previewStartMillis) + "ms"); - state = State.ACTIVE; - Util.runOnMain(new Runnable() { - @Override - public void run() { - requestLayout(); - for (CameraViewListener listener : listeners) { - listener.onCameraStart(); - } - } - }); - } catch (Exception e) { - Log.w(TAG, e); - } - } - } - - private void stopPreview() { - if (camera.isPresent()) { - try { - camera.get().stopPreview(); - state = State.RESUMED; - } catch (Exception e) { - Log.w(TAG, e); - } - } - } - - - private Size getPreferredPreviewSize(@NonNull Parameters parameters) { - return CameraUtils.getPreferredPreviewSize(displayOrientation, - getMeasuredWidth(), - getMeasuredHeight(), - parameters); - } - - private int getCameraPictureOrientation() { - if (getActivity().getRequestedOrientation() != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) { - outputOrientation = getCameraPictureRotation(getActivity().getWindowManager() - .getDefaultDisplay() - .getOrientation()); - } else if (getCameraInfo().facing == CameraInfo.CAMERA_FACING_FRONT) { - outputOrientation = (360 - displayOrientation) % 360; - } else { - outputOrientation = displayOrientation; - } - - return outputOrientation; - } - - // https://github.com/signalapp/Signal-Android/issues/4715 - private boolean isTroublemaker() { - return getCameraInfo().facing == CameraInfo.CAMERA_FACING_FRONT && - "JWR66Y".equals(Build.DISPLAY) && - "yakju".equals(Build.PRODUCT); - } - - private @NonNull CameraInfo getCameraInfo() { - final CameraInfo info = new Camera.CameraInfo(); - Camera.getCameraInfo(cameraId, info); - return info; - } - - // XXX this sucks - private Activity getActivity() { - return (Activity)getContext(); - } - - public int getCameraPictureRotation(int orientation) { - final CameraInfo info = getCameraInfo(); - final int rotation; - - orientation = (orientation + 45) / 90 * 90; - - if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { - rotation = (info.orientation - orientation + 360) % 360; - } else { - rotation = (info.orientation + orientation) % 360; - } - - return rotation; - } - - private class OnOrientationChange extends OrientationEventListener { - public OnOrientationChange(Context context) { - super(context); - disable(); - } - - @Override - public void onOrientationChanged(int orientation) { - if (camera.isPresent() && orientation != ORIENTATION_UNKNOWN) { - int newOutputOrientation = getCameraPictureRotation(orientation); - - if (newOutputOrientation != outputOrientation) { - outputOrientation = newOutputOrientation; - - Camera.Parameters params = camera.get().getParameters(); - - params.setRotation(outputOrientation); - - try { - camera.get().setParameters(params); - } - catch (Exception e) { - Log.e(TAG, "Exception updating camera parameters in orientation change", e); - } - } - } - } - } - - public void takePicture(final Rect previewRect) { - if (!camera.isPresent() || camera.get().getParameters() == null) { - Log.w(TAG, "camera not in capture-ready state"); - return; - } - - camera.get().setOneShotPreviewCallback(new Camera.PreviewCallback() { - @Override - public void onPreviewFrame(byte[] data, final Camera camera) { - final int rotation = getCameraPictureOrientation(); - final Size previewSize = camera.getParameters().getPreviewSize(); - final Rect croppingRect = getCroppedRect(previewSize, previewRect, rotation); - - Log.i(TAG, "previewSize: " + previewSize.width + "x" + previewSize.height); - Log.i(TAG, "data bytes: " + data.length); - Log.i(TAG, "previewFormat: " + camera.getParameters().getPreviewFormat()); - Log.i(TAG, "croppingRect: " + croppingRect.toString()); - Log.i(TAG, "rotation: " + rotation); - new CaptureTask(previewSize, rotation, croppingRect).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, data); - } - }); - } - - private Rect getCroppedRect(Size cameraPreviewSize, Rect visibleRect, int rotation) { - final int previewWidth = cameraPreviewSize.width; - final int previewHeight = cameraPreviewSize.height; - - if (rotation % 180 > 0) rotateRect(visibleRect); - - float scale = (float) previewWidth / visibleRect.width(); - if (visibleRect.height() * scale > previewHeight) { - scale = (float) previewHeight / visibleRect.height(); - } - final float newWidth = visibleRect.width() * scale; - final float newHeight = visibleRect.height() * scale; - final float centerX = (isTroublemaker()) ? previewWidth - newWidth / 2 : previewWidth / 2; - final float centerY = previewHeight / 2; - - visibleRect.set((int) (centerX - newWidth / 2), - (int) (centerY - newHeight / 2), - (int) (centerX + newWidth / 2), - (int) (centerY + newHeight / 2)); - - if (rotation % 180 > 0) rotateRect(visibleRect); - return visibleRect; - } - - @SuppressWarnings("SuspiciousNameCombination") - private void rotateRect(Rect rect) { - rect.set(rect.top, rect.left, rect.bottom, rect.right); - } - - private void enqueueTask(SerialAsyncTask job) { - AsyncTask.SERIAL_EXECUTOR.execute(job); - } - - public static abstract class SerialAsyncTask implements Runnable { - - @Override - public final void run() { - if (!onWait()) { - Log.w(TAG, "skipping task, preconditions not met in onWait()"); - return; - } - - Util.runOnMainSync(this::onPreMain); - final Result result = onRunBackground(); - Util.runOnMainSync(() -> onPostMain(result)); - } - - protected boolean onWait() { return true; } - protected void onPreMain() {} - protected Result onRunBackground() { return null; } - protected void onPostMain(Result result) {} - } - - private abstract class PostInitializationTask extends SerialAsyncTask { - @Override protected boolean onWait() { - synchronized (CameraView.this) { - if (!camera.isPresent()) { - return false; - } - while (getMeasuredHeight() <= 0 || getMeasuredWidth() <= 0 || !surface.isReady()) { - Log.i(TAG, String.format("waiting. surface ready? %s", surface.isReady())); - Util.wait(CameraView.this, 0); - } - return true; - } - } - } - - private class CaptureTask extends AsyncTask { - private final Size previewSize; - private final int rotation; - private final Rect croppingRect; - - public CaptureTask(Size previewSize, int rotation, Rect croppingRect) { - this.previewSize = previewSize; - this.rotation = rotation; - this.croppingRect = croppingRect; - } - - @Override - protected byte[] doInBackground(byte[]... params) { - final byte[] data = params[0]; - try { - return BitmapUtil.createFromNV21(data, - previewSize.width, - previewSize.height, - rotation, - croppingRect, - cameraId == CameraInfo.CAMERA_FACING_FRONT); - } catch (IOException e) { - Log.w(TAG, e); - return null; - } - } - - @Override - protected void onPostExecute(byte[] imageBytes) { - if (imageBytes != null) { - for (CameraViewListener listener : listeners) { - listener.onImageCapture(imageBytes); - } - } - } - } - - private static class PreconditionsNotMetException extends Exception {} - - public interface CameraViewListener { - void onImageCapture(@NonNull final byte[] imageBytes); - void onCameraFail(); - void onCameraStart(); - void onCameraStop(); - } - - public interface PreviewCallback { - void onPreviewFrame(@NonNull PreviewFrame frame); - } - - public static class PreviewFrame { - private final @NonNull byte[] data; - private final int width; - private final int height; - private final int orientation; - - private PreviewFrame(@NonNull byte[] data, int width, int height, int orientation) { - this.data = data; - this.width = width; - this.height = height; - this.orientation = orientation; - } - - public @NonNull byte[] getData() { - return data; - } - - public int getWidth() { - return width; - } - - public int getHeight() { - return height; - } - - public int getOrientation() { - return orientation; - } - } - - private enum State { - PAUSED, RESUMED, ACTIVE - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaDialog.kt deleted file mode 100644 index 54e9197da4..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaDialog.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.thoughtcrime.securesms.components.dialogs - -import android.content.Context -import network.loki.messenger.R -import org.thoughtcrime.securesms.showSessionDialog - -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.deleteMessage, recordCount, recordCount)) - text(context.resources.getString(R.string.deleteMessageDescriptionEveryone)) - dangerButton(R.string.delete) { doDelete.run() } - cancelButton() - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaPreviewDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaPreviewDialog.kt index 542c8719fe..8a6d20e146 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaPreviewDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaPreviewDialog.kt @@ -9,7 +9,6 @@ class DeleteMediaPreviewDialog { @JvmStatic fun show(context: Context, doDelete: Runnable) { context.showSessionDialog { - iconAttribute(R.attr.dialog_alert_icon) title(context.resources.getString(R.string.delete)) text(R.string.deleteMessageDeviceOnly) dangerButton(R.string.delete) { doDelete.run() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiImageView.java index f77043e81e..5e8338b5c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiImageView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiImageView.java @@ -37,7 +37,7 @@ public void setImageEmoji(CharSequence emoji) { Drawable emojiDrawable = EmojiProvider.getEmojiDrawable(getContext(), emoji); if (emojiDrawable == null) { // fallback - setImageResource(R.drawable.ic_outline_disabled_by_default_24); + setImageResource(R.drawable.ic_square_x); } else { setImageDrawable(emojiDrawable); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiKeyboardProvider.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiKeyboardProvider.java deleted file mode 100644 index d34db1d810..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiKeyboardProvider.java +++ /dev/null @@ -1,162 +0,0 @@ -package org.thoughtcrime.securesms.components.emoji; - -import android.content.Context; -import android.graphics.drawable.Drawable; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.viewpager.widget.PagerAdapter; -import android.view.KeyEvent; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; - - -import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener; -import com.bumptech.glide.RequestManager; -import org.thoughtcrime.securesms.util.ResUtil; - -import org.session.libsession.utilities.ThemeUtil; - -import java.util.LinkedList; -import java.util.List; - -import network.loki.messenger.R; - -/** - * A provider to select emoji in the {@link org.thoughtcrime.securesms.components.emoji.MediaKeyboard}. - */ -public class EmojiKeyboardProvider implements MediaKeyboardProvider, - MediaKeyboardProvider.TabIconProvider, - MediaKeyboardProvider.BackspaceObserver, - VariationSelectorListener -{ - private static final KeyEvent DELETE_KEY_EVENT = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL); - - private final Context context; - private final List models; - private final RecentEmojiPageModel recentModel; - private final EmojiPagerAdapter emojiPagerAdapter; - private final EmojiEventListener emojiEventListener; - - private Controller controller; - - public EmojiKeyboardProvider(@NonNull Context context, @Nullable EmojiEventListener emojiEventListener) { - this.context = context; - this.emojiEventListener = emojiEventListener; - this.models = new LinkedList<>(); - this.recentModel = new RecentEmojiPageModel(context); - this.emojiPagerAdapter = new EmojiPagerAdapter(context, models, new EmojiEventListener() { - @Override - public void onEmojiSelected(String emoji) { - recentModel.onCodePointSelected(emoji); - - if (emojiEventListener != null) { - emojiEventListener.onEmojiSelected(emoji); - } - } - - @Override - public void onKeyEvent(KeyEvent keyEvent) { - if (emojiEventListener != null) { - emojiEventListener.onKeyEvent(keyEvent); - } - } - }, this); - - models.add(recentModel); - models.addAll(EmojiPages.DISPLAY_PAGES); - } - - @Override - public void requestPresentation(@NonNull Presenter presenter, boolean isSoloProvider) { - presenter.present(this, emojiPagerAdapter, this, this, null, null, recentModel.getEmoji().size() > 0 ? 0 : 1); - } - - @Override - public void setController(@Nullable Controller controller) { - this.controller = controller; - } - - @Override - public int getProviderIconView(boolean selected) { - if (selected) { - return ThemeUtil.isDarkTheme(context) ? R.layout.emoji_keyboard_icon_dark_selected : R.layout.emoji_keyboard_icon_light_selected; - } else { - return ThemeUtil.isDarkTheme(context) ? R.layout.emoji_keyboard_icon_dark : R.layout.emoji_keyboard_icon_light; - } - } - - @Override - public void loadCategoryTabIcon(@NonNull RequestManager glideRequests, @NonNull ImageView imageView, int index) { - Drawable drawable = ResUtil.getDrawable(context, models.get(index).getIconAttr()); - imageView.setImageDrawable(drawable); - } - - @Override - public void onBackspaceClicked() { - if (emojiEventListener != null) { - emojiEventListener.onKeyEvent(DELETE_KEY_EVENT); - } - } - - @Override - public void onVariationSelectorStateChanged(boolean open) { - if (controller != null) { - controller.setViewPagerEnabled(!open); - } - } - - @Override - public boolean equals(@Nullable Object obj) { - return obj instanceof EmojiKeyboardProvider; - } - - private static class EmojiPagerAdapter extends PagerAdapter { - private Context context; - private List pages; - private EmojiEventListener emojiSelectionListener; - private VariationSelectorListener variationSelectorListener; - - public EmojiPagerAdapter(@NonNull Context context, - @NonNull List pages, - @NonNull EmojiEventListener emojiSelectionListener, - @NonNull VariationSelectorListener variationSelectorListener) - { - super(); - this.context = context; - this.pages = pages; - this.emojiSelectionListener = emojiSelectionListener; - this.variationSelectorListener = variationSelectorListener; - } - - @Override - public int getCount() { - return pages.size(); - } - - @Override - public @NonNull Object instantiateItem(@NonNull ViewGroup container, int position) { - EmojiPageView page = new EmojiPageView(context, emojiSelectionListener, variationSelectorListener, false); - container.addView(page); - return page; - } - - @Override - public void destroyItem(ViewGroup container, int position, Object object) { - container.removeView((View)object); - } - - @Override - public void setPrimaryItem(ViewGroup container, int position, Object object) { - EmojiPageView current = (EmojiPageView) object; - current.onSelected(); - super.setPrimaryItem(container, position, object); - } - - @Override - public boolean isViewFromObject(View view, Object object) { - return view == object; - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPages.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPages.java deleted file mode 100644 index dddcb56a8e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiPages.java +++ /dev/null @@ -1,67 +0,0 @@ -package org.thoughtcrime.securesms.components.emoji; - -import android.net.Uri; -import network.loki.messenger.R; -import org.thoughtcrime.securesms.emoji.EmojiCategory; -import java.util.Arrays; -import java.util.List; - -class EmojiPages { - - private static final EmojiPageModel PAGE_PEOPLE_0 = new StaticEmojiPageModel(EmojiCategory.PEOPLE, Arrays.asList( - new Emoji("\ud83d\ude00"), new Emoji("\ud83d\ude01"), new Emoji("\ud83d\ude02"), new Emoji("\ud83e\udd23"), new Emoji("\ud83d\ude03"), new Emoji("\ud83d\ude04"), new Emoji("\ud83d\ude05"), new Emoji("\ud83d\ude06"), new Emoji("\ud83d\ude09"), new Emoji("\ud83d\ude0a"), new Emoji("\ud83d\ude0b"), new Emoji("\ud83d\ude0e"), new Emoji("\ud83d\ude0d"), new Emoji("\ud83d\ude18"), new Emoji("\ud83d\ude17"), new Emoji("\ud83d\ude19"), new Emoji("\ud83d\ude1a"), new Emoji("\u263a\ufe0f"), new Emoji("\ud83d\ude42"), new Emoji("\ud83e\udd17"), new Emoji("\ud83e\udd29"), new Emoji("\ud83e\udd14"), new Emoji("\ud83e\udd28"), new Emoji("\ud83d\ude10"), new Emoji("\ud83d\ude11"), new Emoji("\ud83d\ude36"), new Emoji("\ud83d\ude44"), new Emoji("\ud83d\ude0f"), new Emoji("\ud83d\ude23"), new Emoji("\ud83d\ude25"), new Emoji("\ud83d\ude2e"), new Emoji("\ud83e\udd10"), new Emoji("\ud83d\ude2f"), new Emoji("\ud83d\ude2a"), new Emoji("\ud83d\ude2b"), new Emoji("\ud83d\ude34"), new Emoji("\ud83d\ude0c"), new Emoji("\ud83d\ude1b"), new Emoji("\ud83d\ude1c"), new Emoji("\ud83d\ude1d"), new Emoji("\ud83e\udd24"), new Emoji("\ud83d\ude12"), new Emoji("\ud83d\ude13"), new Emoji("\ud83d\ude14"), new Emoji("\ud83d\ude15"), new Emoji("\ud83d\ude43"), new Emoji("\ud83e\udd11"), new Emoji("\ud83d\ude32"), new Emoji("\u2639\ufe0f"), new Emoji("\ud83d\ude41"), new Emoji("\ud83d\ude16"), new Emoji("\ud83d\ude1e"), new Emoji("\ud83d\ude1f"), new Emoji("\ud83d\ude24"), new Emoji("\ud83d\ude22"), new Emoji("\ud83d\ude2d"), new Emoji("\ud83d\ude26"), new Emoji("\ud83d\ude27"), new Emoji("\ud83d\ude28"), new Emoji("\ud83d\ude29"), new Emoji("\ud83e\udd2f"), new Emoji("\ud83d\ude2c"), new Emoji("\ud83d\ude30"), new Emoji("\ud83d\ude31"), new Emoji("\ud83d\ude33"), new Emoji("\ud83e\udd2a"), new Emoji("\ud83d\ude35"), new Emoji("\ud83d\ude21"), new Emoji("\ud83d\ude20"), new Emoji("\ud83e\udd2c"), new Emoji("\ud83d\ude37"), new Emoji("\ud83e\udd12"), new Emoji("\ud83e\udd15"), new Emoji("\ud83e\udd22"), new Emoji("\ud83e\udd2e"), new Emoji("\ud83e\udd27"), new Emoji("\ud83d\ude07"), new Emoji("\ud83e\udd20"), new Emoji("\ud83e\udd21"), new Emoji("\ud83e\udd25"), new Emoji("\ud83e\udd2b"), new Emoji("\ud83e\udd2d"), new Emoji("\ud83e\uddd0"), new Emoji("\ud83e\udd13"), new Emoji("\ud83d\ude08"), new Emoji("\ud83d\udc7f"), new Emoji("\ud83d\udc79"), new Emoji("\ud83d\udc7a"), new Emoji("\ud83d\udc80"), new Emoji("\u2620\ufe0f"), new Emoji("\ud83d\udc7b"), new Emoji("\ud83d\udc7d"), new Emoji("\ud83d\udc7e"), new Emoji("\ud83e\udd16"), new Emoji("\ud83d\udca9"), new Emoji("\ud83d\ude3a"), new Emoji("\ud83d\ude38"), new Emoji("\ud83d\ude39"), new Emoji("\ud83d\ude3b"), new Emoji("\ud83d\ude3c"), new Emoji("\ud83d\ude3d"), new Emoji("\ud83d\ude40"), new Emoji("\ud83d\ude3f"), new Emoji("\ud83d\ude3e"), new Emoji("\ud83d\ude48"), new Emoji("\ud83d\ude49"), new Emoji("\ud83d\ude4a"), new Emoji("\ud83d\udc76", "\ud83d\udc76\ud83c\udffb", "\ud83d\udc76\ud83c\udffc", "\ud83d\udc76\ud83c\udffd", "\ud83d\udc76\ud83c\udffe", "\ud83d\udc76\ud83c\udfff"), new Emoji("\ud83e\uddd2", "\ud83e\uddd2\ud83c\udffb", "\ud83e\uddd2\ud83c\udffc", "\ud83e\uddd2\ud83c\udffd", "\ud83e\uddd2\ud83c\udffe", "\ud83e\uddd2\ud83c\udfff"), new Emoji("\ud83d\udc66", "\ud83d\udc66\ud83c\udffb", "\ud83d\udc66\ud83c\udffc", "\ud83d\udc66\ud83c\udffd", "\ud83d\udc66\ud83c\udffe", "\ud83d\udc66\ud83c\udfff"), new Emoji("\ud83d\udc67", "\ud83d\udc67\ud83c\udffb", "\ud83d\udc67\ud83c\udffc", "\ud83d\udc67\ud83c\udffd", "\ud83d\udc67\ud83c\udffe", "\ud83d\udc67\ud83c\udfff"), new Emoji("\ud83e\uddd1", "\ud83e\uddd1\ud83c\udffb", "\ud83e\uddd1\ud83c\udffc", "\ud83e\uddd1\ud83c\udffd", "\ud83e\uddd1\ud83c\udffe", "\ud83e\uddd1\ud83c\udfff"), new Emoji("\ud83d\udc68", "\ud83d\udc68\ud83c\udffb", "\ud83d\udc68\ud83c\udffc", "\ud83d\udc68\ud83c\udffd", "\ud83d\udc68\ud83c\udffe", "\ud83d\udc68\ud83c\udfff"), new Emoji("\ud83d\udc69", "\ud83d\udc69\ud83c\udffb", "\ud83d\udc69\ud83c\udffc", "\ud83d\udc69\ud83c\udffd", "\ud83d\udc69\ud83c\udffe", "\ud83d\udc69\ud83c\udfff"), new Emoji("\ud83e\uddd3", "\ud83e\uddd3\ud83c\udffb", "\ud83e\uddd3\ud83c\udffc", "\ud83e\uddd3\ud83c\udffd", "\ud83e\uddd3\ud83c\udffe", "\ud83e\uddd3\ud83c\udfff"), new Emoji("\ud83d\udc74", "\ud83d\udc74\ud83c\udffb", "\ud83d\udc74\ud83c\udffc", "\ud83d\udc74\ud83c\udffd", "\ud83d\udc74\ud83c\udffe", "\ud83d\udc74\ud83c\udfff"), new Emoji("\ud83d\udc75", "\ud83d\udc75\ud83c\udffb", "\ud83d\udc75\ud83c\udffc", "\ud83d\udc75\ud83c\udffd", "\ud83d\udc75\ud83c\udffe", "\ud83d\udc75\ud83c\udfff"), new Emoji("\ud83d\udc68\u200d\u2695\ufe0f", "\ud83d\udc68\ud83c\udffb\u200d\u2695\ufe0f", "\ud83d\udc68\ud83c\udffc\u200d\u2695\ufe0f", "\ud83d\udc68\ud83c\udffd\u200d\u2695\ufe0f", "\ud83d\udc68\ud83c\udffe\u200d\u2695\ufe0f", "\ud83d\udc68\ud83c\udfff\u200d\u2695\ufe0f"), new Emoji("\ud83d\udc69\u200d\u2695\ufe0f", "\ud83d\udc69\ud83c\udffb\u200d\u2695\ufe0f", "\ud83d\udc69\ud83c\udffc\u200d\u2695\ufe0f", "\ud83d\udc69\ud83c\udffd\u200d\u2695\ufe0f", "\ud83d\udc69\ud83c\udffe\u200d\u2695\ufe0f", "\ud83d\udc69\ud83c\udfff\u200d\u2695\ufe0f"), new Emoji("\ud83d\udc68\u200d\ud83c\udf93", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udf93", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udf93", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udf93", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udf93", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udf93"), new Emoji("\ud83d\udc69\u200d\ud83c\udf93", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udf93", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udf93", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udf93", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udf93", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udf93"), new Emoji("\ud83d\udc68\u200d\ud83c\udfeb", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udfeb", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udfeb", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udfeb", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udfeb", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udfeb"), new Emoji("\ud83d\udc69\u200d\ud83c\udfeb", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udfeb", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udfeb", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udfeb", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udfeb", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udfeb"), new Emoji("\ud83d\udc68\u200d\u2696\ufe0f", "\ud83d\udc68\ud83c\udffb\u200d\u2696\ufe0f", "\ud83d\udc68\ud83c\udffc\u200d\u2696\ufe0f", "\ud83d\udc68\ud83c\udffd\u200d\u2696\ufe0f", "\ud83d\udc68\ud83c\udffe\u200d\u2696\ufe0f", "\ud83d\udc68\ud83c\udfff\u200d\u2696\ufe0f"), new Emoji("\ud83d\udc69\u200d\u2696\ufe0f", "\ud83d\udc69\ud83c\udffb\u200d\u2696\ufe0f", "\ud83d\udc69\ud83c\udffc\u200d\u2696\ufe0f", "\ud83d\udc69\ud83c\udffd\u200d\u2696\ufe0f", "\ud83d\udc69\ud83c\udffe\u200d\u2696\ufe0f", "\ud83d\udc69\ud83c\udfff\u200d\u2696\ufe0f"), new Emoji("\ud83d\udc68\u200d\ud83c\udf3e", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udf3e", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udf3e", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udf3e", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udf3e", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udf3e"), new Emoji("\ud83d\udc69\u200d\ud83c\udf3e", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udf3e", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udf3e", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udf3e", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udf3e", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udf3e"), new Emoji("\ud83d\udc68\u200d\ud83c\udf73", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udf73", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udf73", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udf73", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udf73", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udf73"), new Emoji("\ud83d\udc69\u200d\ud83c\udf73", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udf73", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udf73", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udf73", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udf73", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udf73"), new Emoji("\ud83d\udc68\u200d\ud83d\udd27", "\ud83d\udc68\ud83c\udffb\u200d\ud83d\udd27", "\ud83d\udc68\ud83c\udffc\u200d\ud83d\udd27", "\ud83d\udc68\ud83c\udffd\u200d\ud83d\udd27", "\ud83d\udc68\ud83c\udffe\u200d\ud83d\udd27", "\ud83d\udc68\ud83c\udfff\u200d\ud83d\udd27"), new Emoji("\ud83d\udc69\u200d\ud83d\udd27", "\ud83d\udc69\ud83c\udffb\u200d\ud83d\udd27", "\ud83d\udc69\ud83c\udffc\u200d\ud83d\udd27", "\ud83d\udc69\ud83c\udffd\u200d\ud83d\udd27", "\ud83d\udc69\ud83c\udffe\u200d\ud83d\udd27", "\ud83d\udc69\ud83c\udfff\u200d\ud83d\udd27"), new Emoji("\ud83d\udc68\u200d\ud83c\udfed", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udfed", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udfed", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udfed", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udfed", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udfed"), new Emoji("\ud83d\udc69\u200d\ud83c\udfed", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udfed", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udfed", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udfed", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udfed", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udfed"), new Emoji("\ud83d\udc68\u200d\ud83d\udcbc", "\ud83d\udc68\ud83c\udffb\u200d\ud83d\udcbc", "\ud83d\udc68\ud83c\udffc\u200d\ud83d\udcbc", "\ud83d\udc68\ud83c\udffd\u200d\ud83d\udcbc", "\ud83d\udc68\ud83c\udffe\u200d\ud83d\udcbc", "\ud83d\udc68\ud83c\udfff\u200d\ud83d\udcbc"), new Emoji("\ud83d\udc69\u200d\ud83d\udcbc", "\ud83d\udc69\ud83c\udffb\u200d\ud83d\udcbc", "\ud83d\udc69\ud83c\udffc\u200d\ud83d\udcbc", "\ud83d\udc69\ud83c\udffd\u200d\ud83d\udcbc", "\ud83d\udc69\ud83c\udffe\u200d\ud83d\udcbc", "\ud83d\udc69\ud83c\udfff\u200d\ud83d\udcbc"), new Emoji("\ud83d\udc68\u200d\ud83d\udd2c", "\ud83d\udc68\ud83c\udffb\u200d\ud83d\udd2c", "\ud83d\udc68\ud83c\udffc\u200d\ud83d\udd2c", "\ud83d\udc68\ud83c\udffd\u200d\ud83d\udd2c", "\ud83d\udc68\ud83c\udffe\u200d\ud83d\udd2c", "\ud83d\udc68\ud83c\udfff\u200d\ud83d\udd2c"), new Emoji("\ud83d\udc69\u200d\ud83d\udd2c", "\ud83d\udc69\ud83c\udffb\u200d\ud83d\udd2c", "\ud83d\udc69\ud83c\udffc\u200d\ud83d\udd2c", "\ud83d\udc69\ud83c\udffd\u200d\ud83d\udd2c", "\ud83d\udc69\ud83c\udffe\u200d\ud83d\udd2c", "\ud83d\udc69\ud83c\udfff\u200d\ud83d\udd2c"), new Emoji("\ud83d\udc68\u200d\ud83d\udcbb", "\ud83d\udc68\ud83c\udffb\u200d\ud83d\udcbb", "\ud83d\udc68\ud83c\udffc\u200d\ud83d\udcbb", "\ud83d\udc68\ud83c\udffd\u200d\ud83d\udcbb", "\ud83d\udc68\ud83c\udffe\u200d\ud83d\udcbb", "\ud83d\udc68\ud83c\udfff\u200d\ud83d\udcbb"), new Emoji("\ud83d\udc69\u200d\ud83d\udcbb", "\ud83d\udc69\ud83c\udffb\u200d\ud83d\udcbb", "\ud83d\udc69\ud83c\udffc\u200d\ud83d\udcbb", "\ud83d\udc69\ud83c\udffd\u200d\ud83d\udcbb", "\ud83d\udc69\ud83c\udffe\u200d\ud83d\udcbb", "\ud83d\udc69\ud83c\udfff\u200d\ud83d\udcbb"), new Emoji("\ud83d\udc68\u200d\ud83c\udfa4", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udfa4", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udfa4", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udfa4", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udfa4", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udfa4"), new Emoji("\ud83d\udc69\u200d\ud83c\udfa4", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udfa4", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udfa4", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udfa4", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udfa4", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udfa4"), new Emoji("\ud83d\udc68\u200d\ud83c\udfa8", "\ud83d\udc68\ud83c\udffb\u200d\ud83c\udfa8", "\ud83d\udc68\ud83c\udffc\u200d\ud83c\udfa8", "\ud83d\udc68\ud83c\udffd\u200d\ud83c\udfa8", "\ud83d\udc68\ud83c\udffe\u200d\ud83c\udfa8", "\ud83d\udc68\ud83c\udfff\u200d\ud83c\udfa8"), new Emoji("\ud83d\udc69\u200d\ud83c\udfa8", "\ud83d\udc69\ud83c\udffb\u200d\ud83c\udfa8", "\ud83d\udc69\ud83c\udffc\u200d\ud83c\udfa8", "\ud83d\udc69\ud83c\udffd\u200d\ud83c\udfa8", "\ud83d\udc69\ud83c\udffe\u200d\ud83c\udfa8", "\ud83d\udc69\ud83c\udfff\u200d\ud83c\udfa8"), new Emoji("\ud83d\udc68\u200d\u2708\ufe0f", "\ud83d\udc68\ud83c\udffb\u200d\u2708\ufe0f", "\ud83d\udc68\ud83c\udffc\u200d\u2708\ufe0f", "\ud83d\udc68\ud83c\udffd\u200d\u2708\ufe0f", "\ud83d\udc68\ud83c\udffe\u200d\u2708\ufe0f", "\ud83d\udc68\ud83c\udfff\u200d\u2708\ufe0f"), new Emoji("\ud83d\udc69\u200d\u2708\ufe0f", "\ud83d\udc69\ud83c\udffb\u200d\u2708\ufe0f", "\ud83d\udc69\ud83c\udffc\u200d\u2708\ufe0f", "\ud83d\udc69\ud83c\udffd\u200d\u2708\ufe0f", "\ud83d\udc69\ud83c\udffe\u200d\u2708\ufe0f", "\ud83d\udc69\ud83c\udfff\u200d\u2708\ufe0f"), new Emoji("\ud83d\udc68\u200d\ud83d\ude80", "\ud83d\udc68\ud83c\udffb\u200d\ud83d\ude80", "\ud83d\udc68\ud83c\udffc\u200d\ud83d\ude80", "\ud83d\udc68\ud83c\udffd\u200d\ud83d\ude80", "\ud83d\udc68\ud83c\udffe\u200d\ud83d\ude80", "\ud83d\udc68\ud83c\udfff\u200d\ud83d\ude80"), new Emoji("\ud83d\udc69\u200d\ud83d\ude80", "\ud83d\udc69\ud83c\udffb\u200d\ud83d\ude80", "\ud83d\udc69\ud83c\udffc\u200d\ud83d\ude80", "\ud83d\udc69\ud83c\udffd\u200d\ud83d\ude80", "\ud83d\udc69\ud83c\udffe\u200d\ud83d\ude80", "\ud83d\udc69\ud83c\udfff\u200d\ud83d\ude80"), new Emoji("\ud83d\udc68\u200d\ud83d\ude92", "\ud83d\udc68\ud83c\udffb\u200d\ud83d\ude92", "\ud83d\udc68\ud83c\udffc\u200d\ud83d\ude92", "\ud83d\udc68\ud83c\udffd\u200d\ud83d\ude92", "\ud83d\udc68\ud83c\udffe\u200d\ud83d\ude92", "\ud83d\udc68\ud83c\udfff\u200d\ud83d\ude92"), new Emoji("\ud83d\udc69\u200d\ud83d\ude92", "\ud83d\udc69\ud83c\udffb\u200d\ud83d\ude92", "\ud83d\udc69\ud83c\udffc\u200d\ud83d\ude92", "\ud83d\udc69\ud83c\udffd\u200d\ud83d\ude92", "\ud83d\udc69\ud83c\udffe\u200d\ud83d\ude92", "\ud83d\udc69\ud83c\udfff\u200d\ud83d\ude92"), new Emoji("\ud83d\udc6e\u200d\u2642\ufe0f", "\ud83d\udc6e\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc6e\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc6e\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc6e\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc6e\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc6e\u200d\u2640\ufe0f", "\ud83d\udc6e\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc6e\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc6e\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc6e\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc6e\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udd75\ufe0f\u200d\u2642\ufe0f", "\ud83d\udd75\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udd75\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udd75\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udd75\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udd75\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udd75\ufe0f\u200d\u2640\ufe0f", "\ud83d\udd75\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udd75\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udd75\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udd75\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udd75\ud83c\udfff\u200d\u2640\ufe0f") - ), Uri.parse("emoji/People_0.png")); - - private static final EmojiPageModel PAGE_PEOPLE_1 = new StaticEmojiPageModel(EmojiCategory.PEOPLE, Arrays.asList( - new Emoji("\ud83d\udc82\u200d\u2642\ufe0f", "\ud83d\udc82\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc82\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc82\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc82\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc82\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc82\u200d\u2640\ufe0f", "\ud83d\udc82\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc82\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc82\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc82\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc82\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc77\u200d\u2642\ufe0f", "\ud83d\udc77\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc77\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc77\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc77\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc77\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc77\u200d\u2640\ufe0f", "\ud83d\udc77\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc77\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc77\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc77\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc77\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd34", "\ud83e\udd34\ud83c\udffb", "\ud83e\udd34\ud83c\udffc", "\ud83e\udd34\ud83c\udffd", "\ud83e\udd34\ud83c\udffe", "\ud83e\udd34\ud83c\udfff"), new Emoji("\ud83d\udc78", "\ud83d\udc78\ud83c\udffb", "\ud83d\udc78\ud83c\udffc", "\ud83d\udc78\ud83c\udffd", "\ud83d\udc78\ud83c\udffe", "\ud83d\udc78\ud83c\udfff"), new Emoji("\ud83d\udc73\u200d\u2642\ufe0f", "\ud83d\udc73\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc73\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc73\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc73\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc73\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc73\u200d\u2640\ufe0f", "\ud83d\udc73\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc73\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc73\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc73\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc73\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc72", "\ud83d\udc72\ud83c\udffb", "\ud83d\udc72\ud83c\udffc", "\ud83d\udc72\ud83c\udffd", "\ud83d\udc72\ud83c\udffe", "\ud83d\udc72\ud83c\udfff"), new Emoji("\ud83e\uddd5", "\ud83e\uddd5\ud83c\udffb", "\ud83e\uddd5\ud83c\udffc", "\ud83e\uddd5\ud83c\udffd", "\ud83e\uddd5\ud83c\udffe", "\ud83e\uddd5\ud83c\udfff"), new Emoji("\ud83e\uddd4", "\ud83e\uddd4\ud83c\udffb", "\ud83e\uddd4\ud83c\udffc", "\ud83e\uddd4\ud83c\udffd", "\ud83e\uddd4\ud83c\udffe", "\ud83e\uddd4\ud83c\udfff"), new Emoji("\ud83d\udc71\u200d\u2642\ufe0f", "\ud83d\udc71\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc71\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc71\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc71\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc71\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc71\u200d\u2640\ufe0f", "\ud83d\udc71\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc71\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc71\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc71\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc71\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd35", "\ud83e\udd35\ud83c\udffb", "\ud83e\udd35\ud83c\udffc", "\ud83e\udd35\ud83c\udffd", "\ud83e\udd35\ud83c\udffe", "\ud83e\udd35\ud83c\udfff"), new Emoji("\ud83d\udc70", "\ud83d\udc70\ud83c\udffb", "\ud83d\udc70\ud83c\udffc", "\ud83d\udc70\ud83c\udffd", "\ud83d\udc70\ud83c\udffe", "\ud83d\udc70\ud83c\udfff"), new Emoji("\ud83e\udd30", "\ud83e\udd30\ud83c\udffb", "\ud83e\udd30\ud83c\udffc", "\ud83e\udd30\ud83c\udffd", "\ud83e\udd30\ud83c\udffe", "\ud83e\udd30\ud83c\udfff"), new Emoji("\ud83e\udd31", "\ud83e\udd31\ud83c\udffb", "\ud83e\udd31\ud83c\udffc", "\ud83e\udd31\ud83c\udffd", "\ud83e\udd31\ud83c\udffe", "\ud83e\udd31\ud83c\udfff"), new Emoji("\ud83d\udc7c", "\ud83d\udc7c\ud83c\udffb", "\ud83d\udc7c\ud83c\udffc", "\ud83d\udc7c\ud83c\udffd", "\ud83d\udc7c\ud83c\udffe", "\ud83d\udc7c\ud83c\udfff"), new Emoji("\ud83c\udf85", "\ud83c\udf85\ud83c\udffb", "\ud83c\udf85\ud83c\udffc", "\ud83c\udf85\ud83c\udffd", "\ud83c\udf85\ud83c\udffe", "\ud83c\udf85\ud83c\udfff"), new Emoji("\ud83e\udd36", "\ud83e\udd36\ud83c\udffb", "\ud83e\udd36\ud83c\udffc", "\ud83e\udd36\ud83c\udffd", "\ud83e\udd36\ud83c\udffe", "\ud83e\udd36\ud83c\udfff"), new Emoji("\ud83e\uddd9\u200d\u2640\ufe0f", "\ud83e\uddd9\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddd9\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddd9\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddd9\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddd9\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddd9\u200d\u2642\ufe0f", "\ud83e\uddd9\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddd9\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddd9\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddd9\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddd9\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddda\u200d\u2640\ufe0f", "\ud83e\uddda\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddda\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddda\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddda\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddda\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddda\u200d\u2642\ufe0f", "\ud83e\uddda\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddda\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddda\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddda\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddda\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udddb\u200d\u2640\ufe0f", "\ud83e\udddb\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udddb\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udddb\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udddb\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udddb\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udddb\u200d\u2642\ufe0f", "\ud83e\udddb\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udddb\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udddb\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udddb\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udddb\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udddc\u200d\u2640\ufe0f", "\ud83e\udddc\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udddc\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udddc\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udddc\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udddc\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udddc\u200d\u2642\ufe0f", "\ud83e\udddc\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udddc\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udddc\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udddc\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udddc\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udddd\u200d\u2640\ufe0f", "\ud83e\udddd\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udddd\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udddd\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udddd\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udddd\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udddd\u200d\u2642\ufe0f", "\ud83e\udddd\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udddd\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udddd\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udddd\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udddd\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddde\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddde\u200d\u2642\ufe0f"), new Emoji("\ud83e\udddf\u200d\u2640\ufe0f"), new Emoji("\ud83e\udddf\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude4d\u200d\u2642\ufe0f", "\ud83d\ude4d\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\ude4d\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\ude4d\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\ude4d\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\ude4d\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude4d\u200d\u2640\ufe0f", "\ud83d\ude4d\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\ude4d\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\ude4d\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\ude4d\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\ude4d\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\ude4e\u200d\u2642\ufe0f", "\ud83d\ude4e\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\ude4e\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\ude4e\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\ude4e\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\ude4e\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude4e\u200d\u2640\ufe0f", "\ud83d\ude4e\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\ude4e\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\ude4e\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\ude4e\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\ude4e\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\ude45\u200d\u2642\ufe0f", "\ud83d\ude45\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\ude45\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\ude45\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\ude45\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\ude45\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude45\u200d\u2640\ufe0f", "\ud83d\ude45\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\ude45\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\ude45\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\ude45\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\ude45\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\ude46\u200d\u2642\ufe0f", "\ud83d\ude46\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\ude46\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\ude46\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\ude46\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\ude46\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude46\u200d\u2640\ufe0f", "\ud83d\ude46\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\ude46\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\ude46\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\ude46\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\ude46\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc81\u200d\u2642\ufe0f", "\ud83d\udc81\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc81\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc81\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc81\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc81\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc81\u200d\u2640\ufe0f", "\ud83d\udc81\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc81\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc81\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc81\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc81\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\ude4b\u200d\u2642\ufe0f", "\ud83d\ude4b\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\ude4b\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\ude4b\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\ude4b\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\ude4b\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude4b\u200d\u2640\ufe0f", "\ud83d\ude4b\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\ude4b\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\ude4b\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\ude4b\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\ude4b\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\ude47\u200d\u2642\ufe0f", "\ud83d\ude47\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\ude47\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\ude47\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\ude47\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\ude47\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\ude47\u200d\u2640\ufe0f", "\ud83d\ude47\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\ude47\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\ude47\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\ude47\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\ude47\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd26", "\ud83e\udd26\ud83c\udffb", "\ud83e\udd26\ud83c\udffc", "\ud83e\udd26\ud83c\udffd", "\ud83e\udd26\ud83c\udffe", "\ud83e\udd26\ud83c\udfff"), new Emoji("\ud83e\udd26\u200d\u2642\ufe0f", "\ud83e\udd26\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd26\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd26\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd26\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd26\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd26\u200d\u2640\ufe0f", "\ud83e\udd26\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd26\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd26\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd26\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd26\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd37", "\ud83e\udd37\ud83c\udffb", "\ud83e\udd37\ud83c\udffc", "\ud83e\udd37\ud83c\udffd", "\ud83e\udd37\ud83c\udffe", "\ud83e\udd37\ud83c\udfff"), new Emoji("\ud83e\udd37\u200d\u2642\ufe0f", "\ud83e\udd37\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd37\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd37\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd37\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd37\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd37\u200d\u2640\ufe0f", "\ud83e\udd37\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd37\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd37\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd37\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd37\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc86\u200d\u2642\ufe0f", "\ud83d\udc86\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc86\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc86\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc86\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc86\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc86\u200d\u2640\ufe0f", "\ud83d\udc86\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc86\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc86\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc86\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc86\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc87\u200d\u2642\ufe0f", "\ud83d\udc87\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udc87\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udc87\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udc87\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udc87\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc87\u200d\u2640\ufe0f", "\ud83d\udc87\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udc87\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udc87\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udc87\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udc87\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udeb6\u200d\u2642\ufe0f", "\ud83d\udeb6\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udeb6\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udeb6\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udeb6\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udeb6\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udeb6\u200d\u2640\ufe0f", "\ud83d\udeb6\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udeb6\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udeb6\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udeb6\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udeb6\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83c\udfc3\u200d\u2642\ufe0f", "\ud83c\udfc3\ud83c\udffb\u200d\u2642\ufe0f", "\ud83c\udfc3\ud83c\udffc\u200d\u2642\ufe0f", "\ud83c\udfc3\ud83c\udffd\u200d\u2642\ufe0f", "\ud83c\udfc3\ud83c\udffe\u200d\u2642\ufe0f", "\ud83c\udfc3\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83c\udfc3\u200d\u2640\ufe0f", "\ud83c\udfc3\ud83c\udffb\u200d\u2640\ufe0f", "\ud83c\udfc3\ud83c\udffc\u200d\u2640\ufe0f", "\ud83c\udfc3\ud83c\udffd\u200d\u2640\ufe0f", "\ud83c\udfc3\ud83c\udffe\u200d\u2640\ufe0f", "\ud83c\udfc3\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc83", "\ud83d\udc83\ud83c\udffb", "\ud83d\udc83\ud83c\udffc", "\ud83d\udc83\ud83c\udffd", "\ud83d\udc83\ud83c\udffe", "\ud83d\udc83\ud83c\udfff"), new Emoji("\ud83d\udd7a", "\ud83d\udd7a\ud83c\udffb", "\ud83d\udd7a\ud83c\udffc", "\ud83d\udd7a\ud83c\udffd", "\ud83d\udd7a\ud83c\udffe", "\ud83d\udd7a\ud83c\udfff"), new Emoji("\ud83d\udc6f\u200d\u2642\ufe0f"), new Emoji("\ud83d\udc6f\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddd6\u200d\u2640\ufe0f", "\ud83e\uddd6\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddd6\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddd6\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddd6\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddd6\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddd6\u200d\u2642\ufe0f", "\ud83e\uddd6\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddd6\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddd6\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddd6\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddd6\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddd7\u200d\u2640\ufe0f", "\ud83e\uddd7\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddd7\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddd7\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddd7\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddd7\ud83c\udfff\u200d\u2640\ufe0f") - ), Uri.parse("emoji/People_1.png")); - - private static final EmojiPageModel PAGE_PEOPLE_2 = new StaticEmojiPageModel(EmojiCategory.PEOPLE, Arrays.asList( - new Emoji("\ud83e\uddd7\u200d\u2642\ufe0f", "\ud83e\uddd7\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddd7\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddd7\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddd7\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddd7\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\uddd8\u200d\u2640\ufe0f", "\ud83e\uddd8\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\uddd8\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\uddd8\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\uddd8\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\uddd8\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\uddd8\u200d\u2642\ufe0f", "\ud83e\uddd8\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\uddd8\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\uddd8\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\uddd8\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\uddd8\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udec0", "\ud83d\udec0\ud83c\udffb", "\ud83d\udec0\ud83c\udffc", "\ud83d\udec0\ud83c\udffd", "\ud83d\udec0\ud83c\udffe", "\ud83d\udec0\ud83c\udfff"), new Emoji("\ud83d\udecc", "\ud83d\udecc\ud83c\udffb", "\ud83d\udecc\ud83c\udffc", "\ud83d\udecc\ud83c\udffd", "\ud83d\udecc\ud83c\udffe", "\ud83d\udecc\ud83c\udfff"), new Emoji("\ud83d\udd74\ufe0f", "\ud83d\udd74\ud83c\udffb", "\ud83d\udd74\ud83c\udffc", "\ud83d\udd74\ud83c\udffd", "\ud83d\udd74\ud83c\udffe", "\ud83d\udd74\ud83c\udfff"), new Emoji("\ud83d\udde3\ufe0f"), new Emoji("\ud83d\udc64"), new Emoji("\ud83d\udc65"), new Emoji("\ud83e\udd3a"), new Emoji("\ud83c\udfc7", "\ud83c\udfc7\ud83c\udffb", "\ud83c\udfc7\ud83c\udffc", "\ud83c\udfc7\ud83c\udffd", "\ud83c\udfc7\ud83c\udffe", "\ud83c\udfc7\ud83c\udfff"), new Emoji("\u26f7\ufe0f"), new Emoji("\ud83c\udfc2", "\ud83c\udfc2\ud83c\udffb", "\ud83c\udfc2\ud83c\udffc", "\ud83c\udfc2\ud83c\udffd", "\ud83c\udfc2\ud83c\udffe", "\ud83c\udfc2\ud83c\udfff"), new Emoji("\ud83c\udfcc\ufe0f\u200d\u2642\ufe0f", "\ud83c\udfcc\ud83c\udffb\u200d\u2642\ufe0f", "\ud83c\udfcc\ud83c\udffc\u200d\u2642\ufe0f", "\ud83c\udfcc\ud83c\udffd\u200d\u2642\ufe0f", "\ud83c\udfcc\ud83c\udffe\u200d\u2642\ufe0f", "\ud83c\udfcc\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83c\udfcc\ufe0f\u200d\u2640\ufe0f", "\ud83c\udfcc\ud83c\udffb\u200d\u2640\ufe0f", "\ud83c\udfcc\ud83c\udffc\u200d\u2640\ufe0f", "\ud83c\udfcc\ud83c\udffd\u200d\u2640\ufe0f", "\ud83c\udfcc\ud83c\udffe\u200d\u2640\ufe0f", "\ud83c\udfcc\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83c\udfc4\u200d\u2642\ufe0f", "\ud83c\udfc4\ud83c\udffb\u200d\u2642\ufe0f", "\ud83c\udfc4\ud83c\udffc\u200d\u2642\ufe0f", "\ud83c\udfc4\ud83c\udffd\u200d\u2642\ufe0f", "\ud83c\udfc4\ud83c\udffe\u200d\u2642\ufe0f", "\ud83c\udfc4\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83c\udfc4\u200d\u2640\ufe0f", "\ud83c\udfc4\ud83c\udffb\u200d\u2640\ufe0f", "\ud83c\udfc4\ud83c\udffc\u200d\u2640\ufe0f", "\ud83c\udfc4\ud83c\udffd\u200d\u2640\ufe0f", "\ud83c\udfc4\ud83c\udffe\u200d\u2640\ufe0f", "\ud83c\udfc4\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udea3\u200d\u2642\ufe0f", "\ud83d\udea3\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udea3\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udea3\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udea3\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udea3\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udea3\u200d\u2640\ufe0f", "\ud83d\udea3\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udea3\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udea3\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udea3\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udea3\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83c\udfca\u200d\u2642\ufe0f", "\ud83c\udfca\ud83c\udffb\u200d\u2642\ufe0f", "\ud83c\udfca\ud83c\udffc\u200d\u2642\ufe0f", "\ud83c\udfca\ud83c\udffd\u200d\u2642\ufe0f", "\ud83c\udfca\ud83c\udffe\u200d\u2642\ufe0f", "\ud83c\udfca\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83c\udfca\u200d\u2640\ufe0f", "\ud83c\udfca\ud83c\udffb\u200d\u2640\ufe0f", "\ud83c\udfca\ud83c\udffc\u200d\u2640\ufe0f", "\ud83c\udfca\ud83c\udffd\u200d\u2640\ufe0f", "\ud83c\udfca\ud83c\udffe\u200d\u2640\ufe0f", "\ud83c\udfca\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\u26f9\ufe0f\u200d\u2642\ufe0f", "\u26f9\ud83c\udffb\u200d\u2642\ufe0f", "\u26f9\ud83c\udffc\u200d\u2642\ufe0f", "\u26f9\ud83c\udffd\u200d\u2642\ufe0f", "\u26f9\ud83c\udffe\u200d\u2642\ufe0f", "\u26f9\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\u26f9\ufe0f\u200d\u2640\ufe0f", "\u26f9\ud83c\udffb\u200d\u2640\ufe0f", "\u26f9\ud83c\udffc\u200d\u2640\ufe0f", "\u26f9\ud83c\udffd\u200d\u2640\ufe0f", "\u26f9\ud83c\udffe\u200d\u2640\ufe0f", "\u26f9\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83c\udfcb\ufe0f\u200d\u2642\ufe0f", "\ud83c\udfcb\ud83c\udffb\u200d\u2642\ufe0f", "\ud83c\udfcb\ud83c\udffc\u200d\u2642\ufe0f", "\ud83c\udfcb\ud83c\udffd\u200d\u2642\ufe0f", "\ud83c\udfcb\ud83c\udffe\u200d\u2642\ufe0f", "\ud83c\udfcb\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83c\udfcb\ufe0f\u200d\u2640\ufe0f", "\ud83c\udfcb\ud83c\udffb\u200d\u2640\ufe0f", "\ud83c\udfcb\ud83c\udffc\u200d\u2640\ufe0f", "\ud83c\udfcb\ud83c\udffd\u200d\u2640\ufe0f", "\ud83c\udfcb\ud83c\udffe\u200d\u2640\ufe0f", "\ud83c\udfcb\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udeb4\u200d\u2642\ufe0f", "\ud83d\udeb4\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udeb4\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udeb4\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udeb4\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udeb4\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udeb4\u200d\u2640\ufe0f", "\ud83d\udeb4\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udeb4\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udeb4\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udeb4\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udeb4\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udeb5\u200d\u2642\ufe0f", "\ud83d\udeb5\ud83c\udffb\u200d\u2642\ufe0f", "\ud83d\udeb5\ud83c\udffc\u200d\u2642\ufe0f", "\ud83d\udeb5\ud83c\udffd\u200d\u2642\ufe0f", "\ud83d\udeb5\ud83c\udffe\u200d\u2642\ufe0f", "\ud83d\udeb5\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83d\udeb5\u200d\u2640\ufe0f", "\ud83d\udeb5\ud83c\udffb\u200d\u2640\ufe0f", "\ud83d\udeb5\ud83c\udffc\u200d\u2640\ufe0f", "\ud83d\udeb5\ud83c\udffd\u200d\u2640\ufe0f", "\ud83d\udeb5\ud83c\udffe\u200d\u2640\ufe0f", "\ud83d\udeb5\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83c\udfce\ufe0f"), new Emoji("\ud83c\udfcd\ufe0f"), new Emoji("\ud83e\udd38", "\ud83e\udd38\ud83c\udffb", "\ud83e\udd38\ud83c\udffc", "\ud83e\udd38\ud83c\udffd", "\ud83e\udd38\ud83c\udffe", "\ud83e\udd38\ud83c\udfff"), new Emoji("\ud83e\udd38\u200d\u2642\ufe0f", "\ud83e\udd38\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd38\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd38\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd38\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd38\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd38\u200d\u2640\ufe0f", "\ud83e\udd38\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd38\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd38\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd38\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd38\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd3c"), new Emoji("\ud83e\udd3c\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd3c\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd3d", "\ud83e\udd3d\ud83c\udffb", "\ud83e\udd3d\ud83c\udffc", "\ud83e\udd3d\ud83c\udffd", "\ud83e\udd3d\ud83c\udffe", "\ud83e\udd3d\ud83c\udfff"), new Emoji("\ud83e\udd3d\u200d\u2642\ufe0f", "\ud83e\udd3d\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd3d\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd3d\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd3d\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd3d\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd3d\u200d\u2640\ufe0f", "\ud83e\udd3d\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd3d\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd3d\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd3d\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd3d\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd3e", "\ud83e\udd3e\ud83c\udffb", "\ud83e\udd3e\ud83c\udffc", "\ud83e\udd3e\ud83c\udffd", "\ud83e\udd3e\ud83c\udffe", "\ud83e\udd3e\ud83c\udfff"), new Emoji("\ud83e\udd3e\u200d\u2642\ufe0f", "\ud83e\udd3e\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd3e\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd3e\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd3e\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd3e\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd3e\u200d\u2640\ufe0f", "\ud83e\udd3e\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd3e\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd3e\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd3e\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd3e\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83e\udd39", "\ud83e\udd39\ud83c\udffb", "\ud83e\udd39\ud83c\udffc", "\ud83e\udd39\ud83c\udffd", "\ud83e\udd39\ud83c\udffe", "\ud83e\udd39\ud83c\udfff"), new Emoji("\ud83e\udd39\u200d\u2642\ufe0f", "\ud83e\udd39\ud83c\udffb\u200d\u2642\ufe0f", "\ud83e\udd39\ud83c\udffc\u200d\u2642\ufe0f", "\ud83e\udd39\ud83c\udffd\u200d\u2642\ufe0f", "\ud83e\udd39\ud83c\udffe\u200d\u2642\ufe0f", "\ud83e\udd39\ud83c\udfff\u200d\u2642\ufe0f"), new Emoji("\ud83e\udd39\u200d\u2640\ufe0f", "\ud83e\udd39\ud83c\udffb\u200d\u2640\ufe0f", "\ud83e\udd39\ud83c\udffc\u200d\u2640\ufe0f", "\ud83e\udd39\ud83c\udffd\u200d\u2640\ufe0f", "\ud83e\udd39\ud83c\udffe\u200d\u2640\ufe0f", "\ud83e\udd39\ud83c\udfff\u200d\u2640\ufe0f"), new Emoji("\ud83d\udc6b"), new Emoji("\ud83d\udc6c"), new Emoji("\ud83d\udc6d"), new Emoji("\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68"), new Emoji("\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68"), new Emoji("\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69"), new Emoji("\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc68"), new Emoji("\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68"), new Emoji("\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc69"), new Emoji("\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67"), new Emoji("\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc67"), new Emoji("\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67"), new Emoji("\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d\udc67"), new Emoji("\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66"), new Emoji("\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67"), new Emoji("\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc66"), new Emoji("\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66"), new Emoji("\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc67"), new Emoji("\ud83d\udc68\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc67"), new Emoji("\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d\udc66"), new Emoji("\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d\udc67"), new Emoji("\ud83d\udc69\u200d\ud83d\udc66"), new Emoji("\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66"), new Emoji("\ud83d\udc69\u200d\ud83d\udc67"), new Emoji("\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc66"), new Emoji("\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc67"), new Emoji("\ud83e\udd33", "\ud83e\udd33\ud83c\udffb", "\ud83e\udd33\ud83c\udffc", "\ud83e\udd33\ud83c\udffd", "\ud83e\udd33\ud83c\udffe", "\ud83e\udd33\ud83c\udfff"), new Emoji("\ud83d\udcaa", "\ud83d\udcaa\ud83c\udffb", "\ud83d\udcaa\ud83c\udffc", "\ud83d\udcaa\ud83c\udffd", "\ud83d\udcaa\ud83c\udffe", "\ud83d\udcaa\ud83c\udfff"), new Emoji("\ud83d\udc48", "\ud83d\udc48\ud83c\udffb", "\ud83d\udc48\ud83c\udffc", "\ud83d\udc48\ud83c\udffd", "\ud83d\udc48\ud83c\udffe", "\ud83d\udc48\ud83c\udfff"), new Emoji("\ud83d\udc49", "\ud83d\udc49\ud83c\udffb", "\ud83d\udc49\ud83c\udffc", "\ud83d\udc49\ud83c\udffd", "\ud83d\udc49\ud83c\udffe", "\ud83d\udc49\ud83c\udfff"), new Emoji("\u261d\ufe0f", "\u261d\ud83c\udffb", "\u261d\ud83c\udffc", "\u261d\ud83c\udffd", "\u261d\ud83c\udffe", "\u261d\ud83c\udfff"), new Emoji("\ud83d\udc46", "\ud83d\udc46\ud83c\udffb", "\ud83d\udc46\ud83c\udffc", "\ud83d\udc46\ud83c\udffd", "\ud83d\udc46\ud83c\udffe", "\ud83d\udc46\ud83c\udfff"), new Emoji("\ud83d\udd95", "\ud83d\udd95\ud83c\udffb", "\ud83d\udd95\ud83c\udffc", "\ud83d\udd95\ud83c\udffd", "\ud83d\udd95\ud83c\udffe", "\ud83d\udd95\ud83c\udfff"), new Emoji("\ud83d\udc47", "\ud83d\udc47\ud83c\udffb", "\ud83d\udc47\ud83c\udffc", "\ud83d\udc47\ud83c\udffd", "\ud83d\udc47\ud83c\udffe", "\ud83d\udc47\ud83c\udfff"), new Emoji("\u270c\ufe0f", "\u270c\ud83c\udffb", "\u270c\ud83c\udffc", "\u270c\ud83c\udffd", "\u270c\ud83c\udffe", "\u270c\ud83c\udfff"), new Emoji("\ud83e\udd1e", "\ud83e\udd1e\ud83c\udffb", "\ud83e\udd1e\ud83c\udffc", "\ud83e\udd1e\ud83c\udffd", "\ud83e\udd1e\ud83c\udffe", "\ud83e\udd1e\ud83c\udfff"), new Emoji("\ud83d\udd96", "\ud83d\udd96\ud83c\udffb", "\ud83d\udd96\ud83c\udffc", "\ud83d\udd96\ud83c\udffd", "\ud83d\udd96\ud83c\udffe", "\ud83d\udd96\ud83c\udfff"), new Emoji("\ud83e\udd18", "\ud83e\udd18\ud83c\udffb", "\ud83e\udd18\ud83c\udffc", "\ud83e\udd18\ud83c\udffd", "\ud83e\udd18\ud83c\udffe", "\ud83e\udd18\ud83c\udfff"), new Emoji("\ud83e\udd19", "\ud83e\udd19\ud83c\udffb", "\ud83e\udd19\ud83c\udffc", "\ud83e\udd19\ud83c\udffd", "\ud83e\udd19\ud83c\udffe", "\ud83e\udd19\ud83c\udfff"), new Emoji("\ud83d\udd90\ufe0f", "\ud83d\udd90\ud83c\udffb", "\ud83d\udd90\ud83c\udffc", "\ud83d\udd90\ud83c\udffd", "\ud83d\udd90\ud83c\udffe", "\ud83d\udd90\ud83c\udfff"), new Emoji("\u270b", "\u270b\ud83c\udffb", "\u270b\ud83c\udffc", "\u270b\ud83c\udffd", "\u270b\ud83c\udffe", "\u270b\ud83c\udfff"), new Emoji("\ud83d\udc4c", "\ud83d\udc4c\ud83c\udffb", "\ud83d\udc4c\ud83c\udffc", "\ud83d\udc4c\ud83c\udffd", "\ud83d\udc4c\ud83c\udffe", "\ud83d\udc4c\ud83c\udfff"), new Emoji("\ud83d\udc4d", "\ud83d\udc4d\ud83c\udffb", "\ud83d\udc4d\ud83c\udffc", "\ud83d\udc4d\ud83c\udffd", "\ud83d\udc4d\ud83c\udffe", "\ud83d\udc4d\ud83c\udfff"), new Emoji("\ud83d\udc4e", "\ud83d\udc4e\ud83c\udffb", "\ud83d\udc4e\ud83c\udffc", "\ud83d\udc4e\ud83c\udffd", "\ud83d\udc4e\ud83c\udffe", "\ud83d\udc4e\ud83c\udfff"), new Emoji("\u270a", "\u270a\ud83c\udffb", "\u270a\ud83c\udffc", "\u270a\ud83c\udffd", "\u270a\ud83c\udffe", "\u270a\ud83c\udfff"), new Emoji("\ud83d\udc4a", "\ud83d\udc4a\ud83c\udffb", "\ud83d\udc4a\ud83c\udffc", "\ud83d\udc4a\ud83c\udffd", "\ud83d\udc4a\ud83c\udffe", "\ud83d\udc4a\ud83c\udfff") - ), Uri.parse("emoji/People_2.png")); - - private static final EmojiPageModel PAGE_PEOPLE_3 = new StaticEmojiPageModel(EmojiCategory.PEOPLE, Arrays.asList( - new Emoji("\ud83e\udd1b", "\ud83e\udd1b\ud83c\udffb", "\ud83e\udd1b\ud83c\udffc", "\ud83e\udd1b\ud83c\udffd", "\ud83e\udd1b\ud83c\udffe", "\ud83e\udd1b\ud83c\udfff"), new Emoji("\ud83e\udd1c", "\ud83e\udd1c\ud83c\udffb", "\ud83e\udd1c\ud83c\udffc", "\ud83e\udd1c\ud83c\udffd", "\ud83e\udd1c\ud83c\udffe", "\ud83e\udd1c\ud83c\udfff"), new Emoji("\ud83e\udd1a", "\ud83e\udd1a\ud83c\udffb", "\ud83e\udd1a\ud83c\udffc", "\ud83e\udd1a\ud83c\udffd", "\ud83e\udd1a\ud83c\udffe", "\ud83e\udd1a\ud83c\udfff"), new Emoji("\ud83d\udc4b", "\ud83d\udc4b\ud83c\udffb", "\ud83d\udc4b\ud83c\udffc", "\ud83d\udc4b\ud83c\udffd", "\ud83d\udc4b\ud83c\udffe", "\ud83d\udc4b\ud83c\udfff"), new Emoji("\ud83e\udd1f", "\ud83e\udd1f\ud83c\udffb", "\ud83e\udd1f\ud83c\udffc", "\ud83e\udd1f\ud83c\udffd", "\ud83e\udd1f\ud83c\udffe", "\ud83e\udd1f\ud83c\udfff"), new Emoji("\u270d\ufe0f", "\u270d\ud83c\udffb", "\u270d\ud83c\udffc", "\u270d\ud83c\udffd", "\u270d\ud83c\udffe", "\u270d\ud83c\udfff"), new Emoji("\ud83d\udc4f", "\ud83d\udc4f\ud83c\udffb", "\ud83d\udc4f\ud83c\udffc", "\ud83d\udc4f\ud83c\udffd", "\ud83d\udc4f\ud83c\udffe", "\ud83d\udc4f\ud83c\udfff"), new Emoji("\ud83d\udc50", "\ud83d\udc50\ud83c\udffb", "\ud83d\udc50\ud83c\udffc", "\ud83d\udc50\ud83c\udffd", "\ud83d\udc50\ud83c\udffe", "\ud83d\udc50\ud83c\udfff"), new Emoji("\ud83d\ude4c", "\ud83d\ude4c\ud83c\udffb", "\ud83d\ude4c\ud83c\udffc", "\ud83d\ude4c\ud83c\udffd", "\ud83d\ude4c\ud83c\udffe", "\ud83d\ude4c\ud83c\udfff"), new Emoji("\ud83e\udd32", "\ud83e\udd32\ud83c\udffb", "\ud83e\udd32\ud83c\udffc", "\ud83e\udd32\ud83c\udffd", "\ud83e\udd32\ud83c\udffe", "\ud83e\udd32\ud83c\udfff"), new Emoji("\ud83d\ude4f", "\ud83d\ude4f\ud83c\udffb", "\ud83d\ude4f\ud83c\udffc", "\ud83d\ude4f\ud83c\udffd", "\ud83d\ude4f\ud83c\udffe", "\ud83d\ude4f\ud83c\udfff"), new Emoji("\ud83e\udd1d"), new Emoji("\ud83d\udc85", "\ud83d\udc85\ud83c\udffb", "\ud83d\udc85\ud83c\udffc", "\ud83d\udc85\ud83c\udffd", "\ud83d\udc85\ud83c\udffe", "\ud83d\udc85\ud83c\udfff"), new Emoji("\ud83d\udc42", "\ud83d\udc42\ud83c\udffb", "\ud83d\udc42\ud83c\udffc", "\ud83d\udc42\ud83c\udffd", "\ud83d\udc42\ud83c\udffe", "\ud83d\udc42\ud83c\udfff"), new Emoji("\ud83d\udc43", "\ud83d\udc43\ud83c\udffb", "\ud83d\udc43\ud83c\udffc", "\ud83d\udc43\ud83c\udffd", "\ud83d\udc43\ud83c\udffe", "\ud83d\udc43\ud83c\udfff"), new Emoji("\ud83d\udc63"), new Emoji("\ud83d\udc40"), new Emoji("\ud83d\udc41\ufe0f"), new Emoji("\ud83d\udc41\ufe0f\u200d\ud83d\udde8\ufe0f"), new Emoji("\ud83e\udde0"), new Emoji("\ud83d\udc45"), new Emoji("\ud83d\udc44"), new Emoji("\ud83d\udc8b"), new Emoji("\ud83d\udc98"), new Emoji("\u2764\ufe0f"), new Emoji("\ud83d\udc93"), new Emoji("\ud83d\udc94"), new Emoji("\ud83d\udc95"), new Emoji("\ud83d\udc96"), new Emoji("\ud83d\udc97"), new Emoji("\ud83d\udc99"), new Emoji("\ud83d\udc9a"), new Emoji("\ud83d\udc9b"), new Emoji("\ud83e\udde1"), new Emoji("\ud83d\udc9c"), new Emoji("\ud83d\udda4"), new Emoji("\ud83d\udc9d"), new Emoji("\ud83d\udc9e"), new Emoji("\ud83d\udc9f"), new Emoji("\u2763\ufe0f"), new Emoji("\ud83d\udc8c"), new Emoji("\ud83d\udca4"), new Emoji("\ud83d\udca2"), new Emoji("\ud83d\udca3"), new Emoji("\ud83d\udca5"), new Emoji("\ud83d\udca6"), new Emoji("\ud83d\udca8"), new Emoji("\ud83d\udcab"), new Emoji("\ud83d\udcac"), new Emoji("\ud83d\udde8\ufe0f"), new Emoji("\ud83d\uddef\ufe0f"), new Emoji("\ud83d\udcad"), new Emoji("\ud83d\udd73\ufe0f"), new Emoji("\ud83d\udc53"), new Emoji("\ud83d\udd76\ufe0f"), new Emoji("\ud83d\udc54"), new Emoji("\ud83d\udc55"), new Emoji("\ud83d\udc56"), new Emoji("\ud83e\udde3"), new Emoji("\ud83e\udde4"), new Emoji("\ud83e\udde5"), new Emoji("\ud83e\udde6"), new Emoji("\ud83d\udc57"), new Emoji("\ud83d\udc58"), new Emoji("\ud83d\udc59"), new Emoji("\ud83d\udc5a"), new Emoji("\ud83d\udc5b"), new Emoji("\ud83d\udc5c"), new Emoji("\ud83d\udc5d"), new Emoji("\ud83d\udecd\ufe0f"), new Emoji("\ud83c\udf92"), new Emoji("\ud83d\udc5e"), new Emoji("\ud83d\udc5f"), new Emoji("\ud83d\udc60"), new Emoji("\ud83d\udc61"), new Emoji("\ud83d\udc62"), new Emoji("\ud83d\udc51"), new Emoji("\ud83d\udc52"), new Emoji("\ud83c\udfa9"), new Emoji("\ud83c\udf93"), new Emoji("\ud83e\udde2"), new Emoji("\u26d1\ufe0f"), new Emoji("\ud83d\udcff"), new Emoji("\ud83d\udc84"), new Emoji("\ud83d\udc8d"), new Emoji("\ud83d\udc8e") - ), Uri.parse("emoji/People_3.png")); - - private static final EmojiPageModel PAGE_PEOPLE = new CompositeEmojiPageModel(R.attr.emoji_category_people, Arrays.asList(PAGE_PEOPLE_0, PAGE_PEOPLE_1, PAGE_PEOPLE_2, PAGE_PEOPLE_3)); - - private static final EmojiPageModel PAGE_NATURE = new StaticEmojiPageModel(EmojiCategory.NATURE, Arrays.asList( - new Emoji("\ud83d\udc35"), new Emoji("\ud83d\udc12"), new Emoji("\ud83e\udd8d"), new Emoji("\ud83d\udc36"), new Emoji("\ud83d\udc15"), new Emoji("\ud83d\udc29"), new Emoji("\ud83d\udc3a"), new Emoji("\ud83e\udd8a"), new Emoji("\ud83d\udc31"), new Emoji("\ud83d\udc08"), new Emoji("\ud83e\udd81"), new Emoji("\ud83d\udc2f"), new Emoji("\ud83d\udc05"), new Emoji("\ud83d\udc06"), new Emoji("\ud83d\udc34"), new Emoji("\ud83d\udc0e"), new Emoji("\ud83e\udd84"), new Emoji("\ud83e\udd93"), new Emoji("\ud83e\udd8c"), new Emoji("\ud83d\udc2e"), new Emoji("\ud83d\udc02"), new Emoji("\ud83d\udc03"), new Emoji("\ud83d\udc04"), new Emoji("\ud83d\udc37"), new Emoji("\ud83d\udc16"), new Emoji("\ud83d\udc17"), new Emoji("\ud83d\udc3d"), new Emoji("\ud83d\udc0f"), new Emoji("\ud83d\udc11"), new Emoji("\ud83d\udc10"), new Emoji("\ud83d\udc2a"), new Emoji("\ud83d\udc2b"), new Emoji("\ud83e\udd92"), new Emoji("\ud83d\udc18"), new Emoji("\ud83e\udd8f"), new Emoji("\ud83d\udc2d"), new Emoji("\ud83d\udc01"), new Emoji("\ud83d\udc00"), new Emoji("\ud83d\udc39"), new Emoji("\ud83d\udc30"), new Emoji("\ud83d\udc07"), new Emoji("\ud83d\udc3f\ufe0f"), new Emoji("\ud83e\udd94"), new Emoji("\ud83e\udd87"), new Emoji("\ud83d\udc3b"), new Emoji("\ud83d\udc28"), new Emoji("\ud83d\udc3c"), new Emoji("\ud83d\udc3e"), new Emoji("\ud83e\udd83"), new Emoji("\ud83d\udc14"), new Emoji("\ud83d\udc13"), new Emoji("\ud83d\udc23"), new Emoji("\ud83d\udc24"), new Emoji("\ud83d\udc25"), new Emoji("\ud83d\udc26"), new Emoji("\ud83d\udc27"), new Emoji("\ud83d\udd4a\ufe0f"), new Emoji("\ud83e\udd85"), new Emoji("\ud83e\udd86"), new Emoji("\ud83e\udd89"), new Emoji("\ud83d\udc38"), new Emoji("\ud83d\udc0a"), new Emoji("\ud83d\udc22"), new Emoji("\ud83e\udd8e"), new Emoji("\ud83d\udc0d"), new Emoji("\ud83d\udc32"), new Emoji("\ud83d\udc09"), new Emoji("\ud83e\udd95"), new Emoji("\ud83e\udd96"), new Emoji("\ud83d\udc33"), new Emoji("\ud83d\udc0b"), new Emoji("\ud83d\udc2c"), new Emoji("\ud83d\udc1f"), new Emoji("\ud83d\udc20"), new Emoji("\ud83d\udc21"), new Emoji("\ud83e\udd88"), new Emoji("\ud83d\udc19"), new Emoji("\ud83d\udc1a"), new Emoji("\ud83e\udd80"), new Emoji("\ud83e\udd90"), new Emoji("\ud83e\udd91"), new Emoji("\ud83d\udc0c"), new Emoji("\ud83e\udd8b"), new Emoji("\ud83d\udc1b"), new Emoji("\ud83d\udc1c"), new Emoji("\ud83d\udc1d"), new Emoji("\ud83d\udc1e"), new Emoji("\ud83e\udd97"), new Emoji("\ud83d\udd77\ufe0f"), new Emoji("\ud83d\udd78\ufe0f"), new Emoji("\ud83e\udd82"), new Emoji("\ud83d\udc90"), new Emoji("\ud83c\udf38"), new Emoji("\ud83d\udcae"), new Emoji("\ud83c\udff5\ufe0f"), new Emoji("\ud83c\udf39"), new Emoji("\ud83e\udd40"), new Emoji("\ud83c\udf3a"), new Emoji("\ud83c\udf3b"), new Emoji("\ud83c\udf3c"), new Emoji("\ud83c\udf37"), new Emoji("\ud83c\udf31"), new Emoji("\ud83c\udf32"), new Emoji("\ud83c\udf33"), new Emoji("\ud83c\udf34"), new Emoji("\ud83c\udf35"), new Emoji("\ud83c\udf3e"), new Emoji("\ud83c\udf3f"), new Emoji("\u2618\ufe0f"), new Emoji("\ud83c\udf40"), new Emoji("\ud83c\udf41"), new Emoji("\ud83c\udf42"), new Emoji("\ud83c\udf43") - ), Uri.parse("emoji/Nature.png")); - - private static final EmojiPageModel PAGE_FOODS = new StaticEmojiPageModel(EmojiCategory.FOODS, Arrays.asList( - new Emoji("\ud83c\udf47"), new Emoji("\ud83c\udf48"), new Emoji("\ud83c\udf49"), new Emoji("\ud83c\udf4a"), new Emoji("\ud83c\udf4b"), new Emoji("\ud83c\udf4c"), new Emoji("\ud83c\udf4d"), new Emoji("\ud83c\udf4e"), new Emoji("\ud83c\udf4f"), new Emoji("\ud83c\udf50"), new Emoji("\ud83c\udf51"), new Emoji("\ud83c\udf52"), new Emoji("\ud83c\udf53"), new Emoji("\ud83e\udd5d"), new Emoji("\ud83c\udf45"), new Emoji("\ud83e\udd65"), new Emoji("\ud83e\udd51"), new Emoji("\ud83c\udf46"), new Emoji("\ud83e\udd54"), new Emoji("\ud83e\udd55"), new Emoji("\ud83c\udf3d"), new Emoji("\ud83c\udf36\ufe0f"), new Emoji("\ud83e\udd52"), new Emoji("\ud83e\udd66"), new Emoji("\ud83c\udf44"), new Emoji("\ud83e\udd5c"), new Emoji("\ud83c\udf30"), new Emoji("\ud83c\udf5e"), new Emoji("\ud83e\udd50"), new Emoji("\ud83e\udd56"), new Emoji("\ud83e\udd68"), new Emoji("\ud83e\udd5e"), new Emoji("\ud83e\uddc0"), new Emoji("\ud83c\udf56"), new Emoji("\ud83c\udf57"), new Emoji("\ud83e\udd69"), new Emoji("\ud83e\udd53"), new Emoji("\ud83c\udf54"), new Emoji("\ud83c\udf5f"), new Emoji("\ud83c\udf55"), new Emoji("\ud83c\udf2d"), new Emoji("\ud83e\udd6a"), new Emoji("\ud83c\udf2e"), new Emoji("\ud83c\udf2f"), new Emoji("\ud83e\udd59"), new Emoji("\ud83e\udd5a"), new Emoji("\ud83c\udf73"), new Emoji("\ud83e\udd58"), new Emoji("\ud83c\udf72"), new Emoji("\ud83e\udd63"), new Emoji("\ud83e\udd57"), new Emoji("\ud83c\udf7f"), new Emoji("\ud83e\udd6b"), new Emoji("\ud83c\udf71"), new Emoji("\ud83c\udf58"), new Emoji("\ud83c\udf59"), new Emoji("\ud83c\udf5a"), new Emoji("\ud83c\udf5b"), new Emoji("\ud83c\udf5c"), new Emoji("\ud83c\udf5d"), new Emoji("\ud83c\udf60"), new Emoji("\ud83c\udf62"), new Emoji("\ud83c\udf63"), new Emoji("\ud83c\udf64"), new Emoji("\ud83c\udf65"), new Emoji("\ud83c\udf61"), new Emoji("\ud83e\udd5f"), new Emoji("\ud83e\udd60"), new Emoji("\ud83e\udd61"), new Emoji("\ud83c\udf66"), new Emoji("\ud83c\udf67"), new Emoji("\ud83c\udf68"), new Emoji("\ud83c\udf69"), new Emoji("\ud83c\udf6a"), new Emoji("\ud83c\udf82"), new Emoji("\ud83c\udf70"), new Emoji("\ud83e\udd67"), new Emoji("\ud83c\udf6b"), new Emoji("\ud83c\udf6c"), new Emoji("\ud83c\udf6d"), new Emoji("\ud83c\udf6e"), new Emoji("\ud83c\udf6f"), new Emoji("\ud83c\udf7c"), new Emoji("\ud83e\udd5b"), new Emoji("\u2615"), new Emoji("\ud83c\udf75"), new Emoji("\ud83c\udf76"), new Emoji("\ud83c\udf7e"), new Emoji("\ud83c\udf77"), new Emoji("\ud83c\udf78"), new Emoji("\ud83c\udf79"), new Emoji("\ud83c\udf7a"), new Emoji("\ud83c\udf7b"), new Emoji("\ud83e\udd42"), new Emoji("\ud83e\udd43"), new Emoji("\ud83e\udd64"), new Emoji("\ud83e\udd62"), new Emoji("\ud83c\udf7d\ufe0f"), new Emoji("\ud83c\udf74"), new Emoji("\ud83e\udd44"), new Emoji("\ud83d\udd2a"), new Emoji("\ud83c\udffa") - ), Uri.parse("emoji/Foods.png")); - - private static final EmojiPageModel PAGE_ACTIVITY = new StaticEmojiPageModel(EmojiCategory.ACTIVITY, Arrays.asList( - new Emoji("\ud83c\udf83"), new Emoji("\ud83c\udf84"), new Emoji("\ud83c\udf86"), new Emoji("\ud83c\udf87"), new Emoji("\u2728"), new Emoji("\ud83c\udf88"), new Emoji("\ud83c\udf89"), new Emoji("\ud83c\udf8a"), new Emoji("\ud83c\udf8b"), new Emoji("\ud83c\udf8d"), new Emoji("\ud83c\udf8e"), new Emoji("\ud83c\udf8f"), new Emoji("\ud83c\udf90"), new Emoji("\ud83c\udf91"), new Emoji("\ud83c\udf80"), new Emoji("\ud83c\udf81"), new Emoji("\ud83c\udf97\ufe0f"), new Emoji("\ud83c\udf9f\ufe0f"), new Emoji("\ud83c\udfab"), new Emoji("\ud83c\udf96\ufe0f"), new Emoji("\ud83c\udfc6"), new Emoji("\ud83c\udfc5"), new Emoji("\ud83e\udd47"), new Emoji("\ud83e\udd48"), new Emoji("\ud83e\udd49"), new Emoji("\u26bd"), new Emoji("\u26be"), new Emoji("\ud83c\udfc0"), new Emoji("\ud83c\udfd0"), new Emoji("\ud83c\udfc8"), new Emoji("\ud83c\udfc9"), new Emoji("\ud83c\udfbe"), new Emoji("\ud83c\udfb1"), new Emoji("\ud83c\udfb3"), new Emoji("\ud83c\udfcf"), new Emoji("\ud83c\udfd1"), new Emoji("\ud83c\udfd2"), new Emoji("\ud83c\udfd3"), new Emoji("\ud83c\udff8"), new Emoji("\ud83e\udd4a"), new Emoji("\ud83e\udd4b"), new Emoji("\ud83e\udd45"), new Emoji("\ud83c\udfaf"), new Emoji("\u26f3"), new Emoji("\u26f8\ufe0f"), new Emoji("\ud83c\udfa3"), new Emoji("\ud83c\udfbd"), new Emoji("\ud83c\udfbf"), new Emoji("\ud83d\udef7"), new Emoji("\ud83e\udd4c"), new Emoji("\ud83c\udfae"), new Emoji("\ud83d\udd79\ufe0f"), new Emoji("\ud83c\udfb2"), new Emoji("\u2660\ufe0f"), new Emoji("\u2665\ufe0f"), new Emoji("\u2666\ufe0f"), new Emoji("\u2663\ufe0f"), new Emoji("\ud83c\udccf"), new Emoji("\ud83c\udc04"), new Emoji("\ud83c\udfb4") - ), Uri.parse("emoji/Activity.png")); - - private static final EmojiPageModel PAGE_PLACES = new StaticEmojiPageModel(EmojiCategory.PLACES, Arrays.asList( - new Emoji("\ud83c\udf0d"), new Emoji("\ud83c\udf0e"), new Emoji("\ud83c\udf0f"), new Emoji("\ud83c\udf10"), new Emoji("\ud83d\uddfa\ufe0f"), new Emoji("\ud83d\uddfe"), new Emoji("\ud83c\udfd4\ufe0f"), new Emoji("\u26f0\ufe0f"), new Emoji("\ud83c\udf0b"), new Emoji("\ud83d\uddfb"), new Emoji("\ud83c\udfd5\ufe0f"), new Emoji("\ud83c\udfd6\ufe0f"), new Emoji("\ud83c\udfdc\ufe0f"), new Emoji("\ud83c\udfdd\ufe0f"), new Emoji("\ud83c\udfde\ufe0f"), new Emoji("\ud83c\udfdf\ufe0f"), new Emoji("\ud83c\udfdb\ufe0f"), new Emoji("\ud83c\udfd7\ufe0f"), new Emoji("\ud83c\udfd8\ufe0f"), new Emoji("\ud83c\udfd9\ufe0f"), new Emoji("\ud83c\udfda\ufe0f"), new Emoji("\ud83c\udfe0"), new Emoji("\ud83c\udfe1"), new Emoji("\ud83c\udfe2"), new Emoji("\ud83c\udfe3"), new Emoji("\ud83c\udfe4"), new Emoji("\ud83c\udfe5"), new Emoji("\ud83c\udfe6"), new Emoji("\ud83c\udfe8"), new Emoji("\ud83c\udfe9"), new Emoji("\ud83c\udfea"), new Emoji("\ud83c\udfeb"), new Emoji("\ud83c\udfec"), new Emoji("\ud83c\udfed"), new Emoji("\ud83c\udfef"), new Emoji("\ud83c\udff0"), new Emoji("\ud83d\udc92"), new Emoji("\ud83d\uddfc"), new Emoji("\ud83d\uddfd"), new Emoji("\u26ea"), new Emoji("\ud83d\udd4c"), new Emoji("\ud83d\udd4d"), new Emoji("\u26e9\ufe0f"), new Emoji("\ud83d\udd4b"), new Emoji("\u26f2"), new Emoji("\u26fa"), new Emoji("\ud83c\udf01"), new Emoji("\ud83c\udf03"), new Emoji("\ud83c\udf04"), new Emoji("\ud83c\udf05"), new Emoji("\ud83c\udf06"), new Emoji("\ud83c\udf07"), new Emoji("\ud83c\udf09"), new Emoji("\u2668\ufe0f"), new Emoji("\ud83c\udf0c"), new Emoji("\ud83c\udfa0"), new Emoji("\ud83c\udfa1"), new Emoji("\ud83c\udfa2"), new Emoji("\ud83d\udc88"), new Emoji("\ud83c\udfaa"), new Emoji("\ud83c\udfad"), new Emoji("\ud83d\uddbc\ufe0f"), new Emoji("\ud83c\udfa8"), new Emoji("\ud83c\udfb0"), new Emoji("\ud83d\ude82"), new Emoji("\ud83d\ude83"), new Emoji("\ud83d\ude84"), new Emoji("\ud83d\ude85"), new Emoji("\ud83d\ude86"), new Emoji("\ud83d\ude87"), new Emoji("\ud83d\ude88"), new Emoji("\ud83d\ude89"), new Emoji("\ud83d\ude8a"), new Emoji("\ud83d\ude9d"), new Emoji("\ud83d\ude9e"), new Emoji("\ud83d\ude8b"), new Emoji("\ud83d\ude8c"), new Emoji("\ud83d\ude8d"), new Emoji("\ud83d\ude8e"), new Emoji("\ud83d\ude90"), new Emoji("\ud83d\ude91"), new Emoji("\ud83d\ude92"), new Emoji("\ud83d\ude93"), new Emoji("\ud83d\ude94"), new Emoji("\ud83d\ude95"), new Emoji("\ud83d\ude96"), new Emoji("\ud83d\ude97"), new Emoji("\ud83d\ude98"), new Emoji("\ud83d\ude99"), new Emoji("\ud83d\ude9a"), new Emoji("\ud83d\ude9b"), new Emoji("\ud83d\ude9c"), new Emoji("\ud83d\udeb2"), new Emoji("\ud83d\udef4"), new Emoji("\ud83d\udef5"), new Emoji("\ud83d\ude8f"), new Emoji("\ud83d\udee3\ufe0f"), new Emoji("\ud83d\udee4\ufe0f"), new Emoji("\u26fd"), new Emoji("\ud83d\udea8"), new Emoji("\ud83d\udea5"), new Emoji("\ud83d\udea6"), new Emoji("\ud83d\udea7"), new Emoji("\ud83d\uded1"), new Emoji("\u2693"), new Emoji("\u26f5"), new Emoji("\ud83d\udef6"), new Emoji("\ud83d\udea4"), new Emoji("\ud83d\udef3\ufe0f"), new Emoji("\u26f4\ufe0f"), new Emoji("\ud83d\udee5\ufe0f"), new Emoji("\ud83d\udea2"), new Emoji("\u2708\ufe0f"), new Emoji("\ud83d\udee9\ufe0f"), new Emoji("\ud83d\udeeb"), new Emoji("\ud83d\udeec"), new Emoji("\ud83d\udcba"), new Emoji("\ud83d\ude81"), new Emoji("\ud83d\ude9f"), new Emoji("\ud83d\udea0"), new Emoji("\ud83d\udea1"), new Emoji("\ud83d\udef0\ufe0f"), new Emoji("\ud83d\ude80"), new Emoji("\ud83d\udef8"), new Emoji("\ud83d\udece\ufe0f"), new Emoji("\ud83d\udeaa"), new Emoji("\ud83d\udecf\ufe0f"), new Emoji("\ud83d\udecb\ufe0f"), new Emoji("\ud83d\udebd"), new Emoji("\ud83d\udebf"), new Emoji("\ud83d\udec1"), new Emoji("\u231b"), new Emoji("\u23f3"), new Emoji("\u231a"), new Emoji("\u23f0"), new Emoji("\u23f1\ufe0f"), new Emoji("\u23f2\ufe0f"), new Emoji("\ud83d\udd70\ufe0f"), new Emoji("\ud83d\udd5b"), new Emoji("\ud83d\udd67"), new Emoji("\ud83d\udd50"), new Emoji("\ud83d\udd5c"), new Emoji("\ud83d\udd51"), new Emoji("\ud83d\udd5d"), new Emoji("\ud83d\udd52"), new Emoji("\ud83d\udd5e"), new Emoji("\ud83d\udd53"), new Emoji("\ud83d\udd5f"), new Emoji("\ud83d\udd54"), new Emoji("\ud83d\udd60"), new Emoji("\ud83d\udd55"), new Emoji("\ud83d\udd61"), new Emoji("\ud83d\udd56"), new Emoji("\ud83d\udd62"), new Emoji("\ud83d\udd57"), new Emoji("\ud83d\udd63"), new Emoji("\ud83d\udd58"), new Emoji("\ud83d\udd64"), new Emoji("\ud83d\udd59"), new Emoji("\ud83d\udd65"), new Emoji("\ud83d\udd5a"), new Emoji("\ud83d\udd66"), new Emoji("\ud83c\udf11"), new Emoji("\ud83c\udf12"), new Emoji("\ud83c\udf13"), new Emoji("\ud83c\udf14"), new Emoji("\ud83c\udf15"), new Emoji("\ud83c\udf16"), new Emoji("\ud83c\udf17"), new Emoji("\ud83c\udf18"), new Emoji("\ud83c\udf19"), new Emoji("\ud83c\udf1a"), new Emoji("\ud83c\udf1b"), new Emoji("\ud83c\udf1c"), new Emoji("\ud83c\udf21\ufe0f"), new Emoji("\u2600\ufe0f"), new Emoji("\ud83c\udf1d"), new Emoji("\ud83c\udf1e"), new Emoji("\u2b50"), new Emoji("\ud83c\udf1f"), new Emoji("\ud83c\udf20"), new Emoji("\u2601\ufe0f"), new Emoji("\u26c5"), new Emoji("\u26c8\ufe0f"), new Emoji("\ud83c\udf24\ufe0f"), new Emoji("\ud83c\udf25\ufe0f"), new Emoji("\ud83c\udf26\ufe0f"), new Emoji("\ud83c\udf27\ufe0f"), new Emoji("\ud83c\udf28\ufe0f"), new Emoji("\ud83c\udf29\ufe0f"), new Emoji("\ud83c\udf2a\ufe0f"), new Emoji("\ud83c\udf2b\ufe0f"), new Emoji("\ud83c\udf2c\ufe0f"), new Emoji("\ud83c\udf00"), new Emoji("\ud83c\udf08"), new Emoji("\ud83c\udf02"), new Emoji("\u2602\ufe0f"), new Emoji("\u2614"), new Emoji("\u26f1\ufe0f"), new Emoji("\u26a1"), new Emoji("\u2744\ufe0f"), new Emoji("\u2603\ufe0f"), new Emoji("\u26c4"), new Emoji("\u2604\ufe0f"), new Emoji("\ud83d\udd25"), new Emoji("\ud83d\udca7"), new Emoji("\ud83c\udf0a") - ), Uri.parse("emoji/Places.png")); - - private static final EmojiPageModel PAGE_OBJECTS = new StaticEmojiPageModel(EmojiCategory.OBJECTS, Arrays.asList( - new Emoji("\ud83d\udd07"), new Emoji("\ud83d\udd08"), new Emoji("\ud83d\udd09"), new Emoji("\ud83d\udd0a"), new Emoji("\ud83d\udce2"), new Emoji("\ud83d\udce3"), new Emoji("\ud83d\udcef"), new Emoji("\ud83d\udd14"), new Emoji("\ud83d\udd15"), new Emoji("\ud83c\udfbc"), new Emoji("\ud83c\udfb5"), new Emoji("\ud83c\udfb6"), new Emoji("\ud83c\udf99\ufe0f"), new Emoji("\ud83c\udf9a\ufe0f"), new Emoji("\ud83c\udf9b\ufe0f"), new Emoji("\ud83c\udfa4"), new Emoji("\ud83c\udfa7"), new Emoji("\ud83d\udcfb"), new Emoji("\ud83c\udfb7"), new Emoji("\ud83c\udfb8"), new Emoji("\ud83c\udfb9"), new Emoji("\ud83c\udfba"), new Emoji("\ud83c\udfbb"), new Emoji("\ud83e\udd41"), new Emoji("\ud83d\udcf1"), new Emoji("\ud83d\udcf2"), new Emoji("\u260e\ufe0f"), new Emoji("\ud83d\udcde"), new Emoji("\ud83d\udcdf"), new Emoji("\ud83d\udce0"), new Emoji("\ud83d\udd0b"), new Emoji("\ud83d\udd0c"), new Emoji("\ud83d\udcbb"), new Emoji("\ud83d\udda5\ufe0f"), new Emoji("\ud83d\udda8\ufe0f"), new Emoji("\u2328\ufe0f"), new Emoji("\ud83d\uddb1\ufe0f"), new Emoji("\ud83d\uddb2\ufe0f"), new Emoji("\ud83d\udcbd"), new Emoji("\ud83d\udcbe"), new Emoji("\ud83d\udcbf"), new Emoji("\ud83d\udcc0"), new Emoji("\ud83c\udfa5"), new Emoji("\ud83c\udf9e\ufe0f"), new Emoji("\ud83d\udcfd\ufe0f"), new Emoji("\ud83c\udfac"), new Emoji("\ud83d\udcfa"), new Emoji("\ud83d\udcf7"), new Emoji("\ud83d\udcf8"), new Emoji("\ud83d\udcf9"), new Emoji("\ud83d\udcfc"), new Emoji("\ud83d\udd0d"), new Emoji("\ud83d\udd0e"), new Emoji("\ud83d\udd2c"), new Emoji("\ud83d\udd2d"), new Emoji("\ud83d\udce1"), new Emoji("\ud83d\udd6f\ufe0f"), new Emoji("\ud83d\udca1"), new Emoji("\ud83d\udd26"), new Emoji("\ud83c\udfee"), new Emoji("\ud83d\udcd4"), new Emoji("\ud83d\udcd5"), new Emoji("\ud83d\udcd6"), new Emoji("\ud83d\udcd7"), new Emoji("\ud83d\udcd8"), new Emoji("\ud83d\udcd9"), new Emoji("\ud83d\udcda"), new Emoji("\ud83d\udcd3"), new Emoji("\ud83d\udcd2"), new Emoji("\ud83d\udcc3"), new Emoji("\ud83d\udcdc"), new Emoji("\ud83d\udcc4"), new Emoji("\ud83d\udcf0"), new Emoji("\ud83d\uddde\ufe0f"), new Emoji("\ud83d\udcd1"), new Emoji("\ud83d\udd16"), new Emoji("\ud83c\udff7\ufe0f"), new Emoji("\ud83d\udcb0"), new Emoji("\ud83d\udcb4"), new Emoji("\ud83d\udcb5"), new Emoji("\ud83d\udcb6"), new Emoji("\ud83d\udcb7"), new Emoji("\ud83d\udcb8"), new Emoji("\ud83d\udcb3"), new Emoji("\ud83d\udcb9"), new Emoji("\ud83d\udcb1"), new Emoji("\ud83d\udcb2"), new Emoji("\u2709\ufe0f"), new Emoji("\ud83d\udce7"), new Emoji("\ud83d\udce8"), new Emoji("\ud83d\udce9"), new Emoji("\ud83d\udce4"), new Emoji("\ud83d\udce5"), new Emoji("\ud83d\udce6"), new Emoji("\ud83d\udceb"), new Emoji("\ud83d\udcea"), new Emoji("\ud83d\udcec"), new Emoji("\ud83d\udced"), new Emoji("\ud83d\udcee"), new Emoji("\ud83d\uddf3\ufe0f"), new Emoji("\u270f\ufe0f"), new Emoji("\u2712\ufe0f"), new Emoji("\ud83d\udd8b\ufe0f"), new Emoji("\ud83d\udd8a\ufe0f"), new Emoji("\ud83d\udd8c\ufe0f"), new Emoji("\ud83d\udd8d\ufe0f"), new Emoji("\ud83d\udcdd"), new Emoji("\ud83d\udcbc"), new Emoji("\ud83d\udcc1"), new Emoji("\ud83d\udcc2"), new Emoji("\ud83d\uddc2\ufe0f"), new Emoji("\ud83d\udcc5"), new Emoji("\ud83d\udcc6"), new Emoji("\ud83d\uddd2\ufe0f"), new Emoji("\ud83d\uddd3\ufe0f"), new Emoji("\ud83d\udcc7"), new Emoji("\ud83d\udcc8"), new Emoji("\ud83d\udcc9"), new Emoji("\ud83d\udcca"), new Emoji("\ud83d\udccb"), new Emoji("\ud83d\udccc"), new Emoji("\ud83d\udccd"), new Emoji("\ud83d\udcce"), new Emoji("\ud83d\udd87\ufe0f"), new Emoji("\ud83d\udccf"), new Emoji("\ud83d\udcd0"), new Emoji("\u2702\ufe0f"), new Emoji("\ud83d\uddc3\ufe0f"), new Emoji("\ud83d\uddc4\ufe0f"), new Emoji("\ud83d\uddd1\ufe0f"), new Emoji("\ud83d\udd12"), new Emoji("\ud83d\udd13"), new Emoji("\ud83d\udd0f"), new Emoji("\ud83d\udd10"), new Emoji("\ud83d\udd11"), new Emoji("\ud83d\udddd\ufe0f"), new Emoji("\ud83d\udd28"), new Emoji("\u26cf\ufe0f"), new Emoji("\u2692\ufe0f"), new Emoji("\ud83d\udee0\ufe0f"), new Emoji("\ud83d\udde1\ufe0f"), new Emoji("\u2694\ufe0f"), new Emoji("\ud83d\udd2b"), new Emoji("\ud83c\udff9"), new Emoji("\ud83d\udee1\ufe0f"), new Emoji("\ud83d\udd27"), new Emoji("\ud83d\udd29"), new Emoji("\u2699\ufe0f"), new Emoji("\ud83d\udddc\ufe0f"), new Emoji("\u2697\ufe0f"), new Emoji("\u2696\ufe0f"), new Emoji("\ud83d\udd17"), new Emoji("\u26d3\ufe0f"), new Emoji("\ud83d\udc89"), new Emoji("\ud83d\udc8a"), new Emoji("\ud83d\udeac"), new Emoji("\u26b0\ufe0f"), new Emoji("\u26b1\ufe0f"), new Emoji("\ud83d\uddff"), new Emoji("\ud83d\udee2\ufe0f"), new Emoji("\ud83d\udd2e"), new Emoji("\ud83d\uded2") - ), Uri.parse("emoji/Objects.png")); - - private static final EmojiPageModel PAGE_SYMBOLS = new StaticEmojiPageModel(EmojiCategory.SYMBOLS, Arrays.asList( - new Emoji("\ud83c\udfe7"), new Emoji("\ud83d\udeae"), new Emoji("\ud83d\udeb0"), new Emoji("\u267f"), new Emoji("\ud83d\udeb9"), new Emoji("\ud83d\udeba"), new Emoji("\ud83d\udebb"), new Emoji("\ud83d\udebc"), new Emoji("\ud83d\udebe"), new Emoji("\ud83d\udec2"), new Emoji("\ud83d\udec3"), new Emoji("\ud83d\udec4"), new Emoji("\ud83d\udec5"), new Emoji("\u26a0\ufe0f"), new Emoji("\ud83d\udeb8"), new Emoji("\u26d4"), new Emoji("\ud83d\udeab"), new Emoji("\ud83d\udeb3"), new Emoji("\ud83d\udead"), new Emoji("\ud83d\udeaf"), new Emoji("\ud83d\udeb1"), new Emoji("\ud83d\udeb7"), new Emoji("\ud83d\udcf5"), new Emoji("\ud83d\udd1e"), new Emoji("\u2622\ufe0f"), new Emoji("\u2623\ufe0f"), new Emoji("\u2b06\ufe0f"), new Emoji("\u2197\ufe0f"), new Emoji("\u27a1\ufe0f"), new Emoji("\u2198\ufe0f"), new Emoji("\u2b07\ufe0f"), new Emoji("\u2199\ufe0f"), new Emoji("\u2b05\ufe0f"), new Emoji("\u2196\ufe0f"), new Emoji("\u2195\ufe0f"), new Emoji("\u2194\ufe0f"), new Emoji("\u21a9\ufe0f"), new Emoji("\u21aa\ufe0f"), new Emoji("\u2934\ufe0f"), new Emoji("\u2935\ufe0f"), new Emoji("\ud83d\udd03"), new Emoji("\ud83d\udd04"), new Emoji("\ud83d\udd19"), new Emoji("\ud83d\udd1a"), new Emoji("\ud83d\udd1b"), new Emoji("\ud83d\udd1c"), new Emoji("\ud83d\udd1d"), new Emoji("\ud83d\uded0"), new Emoji("\u269b\ufe0f"), new Emoji("\ud83d\udd49\ufe0f"), new Emoji("\u2721\ufe0f"), new Emoji("\u2638\ufe0f"), new Emoji("\u262f\ufe0f"), new Emoji("\u271d\ufe0f"), new Emoji("\u2626\ufe0f"), new Emoji("\u262a\ufe0f"), new Emoji("\u262e\ufe0f"), new Emoji("\ud83d\udd4e"), new Emoji("\ud83d\udd2f"), new Emoji("\u2648"), new Emoji("\u2649"), new Emoji("\u264a"), new Emoji("\u264b"), new Emoji("\u264c"), new Emoji("\u264d"), new Emoji("\u264e"), new Emoji("\u264f"), new Emoji("\u2650"), new Emoji("\u2651"), new Emoji("\u2652"), new Emoji("\u2653"), new Emoji("\u26ce"), new Emoji("\ud83d\udd00"), new Emoji("\ud83d\udd01"), new Emoji("\ud83d\udd02"), new Emoji("\u25b6\ufe0f"), new Emoji("\u23e9"), new Emoji("\u23ed\ufe0f"), new Emoji("\u23ef\ufe0f"), new Emoji("\u25c0\ufe0f"), new Emoji("\u23ea"), new Emoji("\u23ee\ufe0f"), new Emoji("\ud83d\udd3c"), new Emoji("\u23eb"), new Emoji("\ud83d\udd3d"), new Emoji("\u23ec"), new Emoji("\u23f8\ufe0f"), new Emoji("\u23f9\ufe0f"), new Emoji("\u23fa\ufe0f"), new Emoji("\u23cf\ufe0f"), new Emoji("\ud83c\udfa6"), new Emoji("\ud83d\udd05"), new Emoji("\ud83d\udd06"), new Emoji("\ud83d\udcf6"), new Emoji("\ud83d\udcf3"), new Emoji("\ud83d\udcf4"), new Emoji("\u267b\ufe0f"), new Emoji("\u269c\ufe0f"), new Emoji("\ud83d\udd31"), new Emoji("\ud83d\udcdb"), new Emoji("\ud83d\udd30"), new Emoji("\u2b55"), new Emoji("\u2705"), new Emoji("\u2611\ufe0f"), new Emoji("\u2714\ufe0f"), new Emoji("\u2716\ufe0f"), new Emoji("\u274c"), new Emoji("\u274e"), new Emoji("\u2795"), new Emoji("\u2796"), new Emoji("\u2797"), new Emoji("\u27b0"), new Emoji("\u27bf"), new Emoji("\u303d\ufe0f"), new Emoji("\u2733\ufe0f"), new Emoji("\u2734\ufe0f"), new Emoji("\u2747\ufe0f"), new Emoji("\u203c\ufe0f"), new Emoji("\u2049\ufe0f"), new Emoji("\u2753"), new Emoji("\u2754"), new Emoji("\u2755"), new Emoji("\u2757"), new Emoji("\u3030\ufe0f"), new Emoji("\u00a9\ufe0f"), new Emoji("\u00ae\ufe0f"), new Emoji("\u2122\ufe0f"), new Emoji("\u0023\ufe0f\u20e3"), new Emoji("\u002a\ufe0f\u20e3"), new Emoji("\u0030\ufe0f\u20e3"), new Emoji("\u0031\ufe0f\u20e3"), new Emoji("\u0032\ufe0f\u20e3"), new Emoji("\u0033\ufe0f\u20e3"), new Emoji("\u0034\ufe0f\u20e3"), new Emoji("\u0035\ufe0f\u20e3"), new Emoji("\u0036\ufe0f\u20e3"), new Emoji("\u0037\ufe0f\u20e3"), new Emoji("\u0038\ufe0f\u20e3"), new Emoji("\u0039\ufe0f\u20e3"), new Emoji("\ud83d\udd1f"), new Emoji("\ud83d\udcaf"), new Emoji("\ud83d\udd20"), new Emoji("\ud83d\udd21"), new Emoji("\ud83d\udd22"), new Emoji("\ud83d\udd23"), new Emoji("\ud83d\udd24"), new Emoji("\ud83c\udd70\ufe0f"), new Emoji("\ud83c\udd8e"), new Emoji("\ud83c\udd71\ufe0f"), new Emoji("\ud83c\udd91"), new Emoji("\ud83c\udd92"), new Emoji("\ud83c\udd93"), new Emoji("\u2139\ufe0f"), new Emoji("\ud83c\udd94"), new Emoji("\u24c2\ufe0f"), new Emoji("\ud83c\udd95"), new Emoji("\ud83c\udd96"), new Emoji("\ud83c\udd7e\ufe0f"), new Emoji("\ud83c\udd97"), new Emoji("\ud83c\udd7f\ufe0f"), new Emoji("\ud83c\udd98"), new Emoji("\ud83c\udd99"), new Emoji("\ud83c\udd9a"), new Emoji("\ud83c\ude01"), new Emoji("\ud83c\ude02\ufe0f"), new Emoji("\ud83c\ude37\ufe0f"), new Emoji("\ud83c\ude36"), new Emoji("\ud83c\ude2f"), new Emoji("\ud83c\ude50"), new Emoji("\ud83c\ude39"), new Emoji("\ud83c\ude1a"), new Emoji("\ud83c\ude32"), new Emoji("\ud83c\ude51"), new Emoji("\ud83c\ude38"), new Emoji("\ud83c\ude34"), new Emoji("\ud83c\ude33"), new Emoji("\u3297\ufe0f"), new Emoji("\u3299\ufe0f"), new Emoji("\ud83c\ude3a"), new Emoji("\ud83c\ude35"), new Emoji("\u25aa\ufe0f"), new Emoji("\u25ab\ufe0f"), new Emoji("\u25fb\ufe0f"), new Emoji("\u25fc\ufe0f"), new Emoji("\u25fd"), new Emoji("\u25fe"), new Emoji("\u2b1b"), new Emoji("\u2b1c"), new Emoji("\ud83d\udd36"), new Emoji("\ud83d\udd37"), new Emoji("\ud83d\udd38"), new Emoji("\ud83d\udd39"), new Emoji("\ud83d\udd3a"), new Emoji("\ud83d\udd3b"), new Emoji("\ud83d\udca0"), new Emoji("\ud83d\udd18"), new Emoji("\ud83d\udd32"), new Emoji("\ud83d\udd33"), new Emoji("\u26aa"), new Emoji("\u26ab"), new Emoji("\ud83d\udd34"), new Emoji("\ud83d\udd35") - ), Uri.parse("emoji/Symbols.png")); - - private static final EmojiPageModel PAGE_FLAGS = new StaticEmojiPageModel(EmojiCategory.FLAGS, Arrays.asList( - new Emoji("\ud83c\udfc1"), new Emoji("\ud83d\udea9"), new Emoji("\ud83c\udf8c"), new Emoji("\ud83c\udff4"), new Emoji("\ud83c\udff3\ufe0f"), new Emoji("\ud83c\udff3\ufe0f\u200d\ud83c\udf08"), new Emoji("\ud83c\udde6\ud83c\udde8"), new Emoji("\ud83c\udde6\ud83c\udde9"), new Emoji("\ud83c\udde6\ud83c\uddea"), new Emoji("\ud83c\udde6\ud83c\uddeb"), new Emoji("\ud83c\udde6\ud83c\uddec"), new Emoji("\ud83c\udde6\ud83c\uddee"), new Emoji("\ud83c\udde6\ud83c\uddf1"), new Emoji("\ud83c\udde6\ud83c\uddf2"), new Emoji("\ud83c\udde6\ud83c\uddf4"), new Emoji("\ud83c\udde6\ud83c\uddf6"), new Emoji("\ud83c\udde6\ud83c\uddf7"), new Emoji("\ud83c\udde6\ud83c\uddf8"), new Emoji("\ud83c\udde6\ud83c\uddf9"), new Emoji("\ud83c\udde6\ud83c\uddfa"), new Emoji("\ud83c\udde6\ud83c\uddfc"), new Emoji("\ud83c\udde6\ud83c\uddfd"), new Emoji("\ud83c\udde6\ud83c\uddff"), new Emoji("\ud83c\udde7\ud83c\udde6"), new Emoji("\ud83c\udde7\ud83c\udde7"), new Emoji("\ud83c\udde7\ud83c\udde9"), new Emoji("\ud83c\udde7\ud83c\uddea"), new Emoji("\ud83c\udde7\ud83c\uddeb"), new Emoji("\ud83c\udde7\ud83c\uddec"), new Emoji("\ud83c\udde7\ud83c\udded"), new Emoji("\ud83c\udde7\ud83c\uddee"), new Emoji("\ud83c\udde7\ud83c\uddef"), new Emoji("\ud83c\udde7\ud83c\uddf1"), new Emoji("\ud83c\udde7\ud83c\uddf2"), new Emoji("\ud83c\udde7\ud83c\uddf3"), new Emoji("\ud83c\udde7\ud83c\uddf4"), new Emoji("\ud83c\udde7\ud83c\uddf6"), new Emoji("\ud83c\udde7\ud83c\uddf7"), new Emoji("\ud83c\udde7\ud83c\uddf8"), new Emoji("\ud83c\udde7\ud83c\uddf9"), new Emoji("\ud83c\udde7\ud83c\uddfb"), new Emoji("\ud83c\udde7\ud83c\uddfc"), new Emoji("\ud83c\udde7\ud83c\uddfe"), new Emoji("\ud83c\udde7\ud83c\uddff"), new Emoji("\ud83c\udde8\ud83c\udde6"), new Emoji("\ud83c\udde8\ud83c\udde8"), new Emoji("\ud83c\udde8\ud83c\udde9"), new Emoji("\ud83c\udde8\ud83c\uddeb"), new Emoji("\ud83c\udde8\ud83c\uddec"), new Emoji("\ud83c\udde8\ud83c\udded"), new Emoji("\ud83c\udde8\ud83c\uddee"), new Emoji("\ud83c\udde8\ud83c\uddf0"), new Emoji("\ud83c\udde8\ud83c\uddf1"), new Emoji("\ud83c\udde8\ud83c\uddf2"), new Emoji("\ud83c\udde8\ud83c\uddf3"), new Emoji("\ud83c\udde8\ud83c\uddf4"), new Emoji("\ud83c\udde8\ud83c\uddf5"), new Emoji("\ud83c\udde8\ud83c\uddf7"), new Emoji("\ud83c\udde8\ud83c\uddfa"), new Emoji("\ud83c\udde8\ud83c\uddfb"), new Emoji("\ud83c\udde8\ud83c\uddfc"), new Emoji("\ud83c\udde8\ud83c\uddfd"), new Emoji("\ud83c\udde8\ud83c\uddfe"), new Emoji("\ud83c\udde8\ud83c\uddff"), new Emoji("\ud83c\udde9\ud83c\uddea"), new Emoji("\ud83c\udde9\ud83c\uddec"), new Emoji("\ud83c\udde9\ud83c\uddef"), new Emoji("\ud83c\udde9\ud83c\uddf0"), new Emoji("\ud83c\udde9\ud83c\uddf2"), new Emoji("\ud83c\udde9\ud83c\uddf4"), new Emoji("\ud83c\udde9\ud83c\uddff"), new Emoji("\ud83c\uddea\ud83c\udde6"), new Emoji("\ud83c\uddea\ud83c\udde8"), new Emoji("\ud83c\uddea\ud83c\uddea"), new Emoji("\ud83c\uddea\ud83c\uddec"), new Emoji("\ud83c\uddea\ud83c\udded"), new Emoji("\ud83c\uddea\ud83c\uddf7"), new Emoji("\ud83c\uddea\ud83c\uddf8"), new Emoji("\ud83c\uddea\ud83c\uddf9"), new Emoji("\ud83c\uddea\ud83c\uddfa"), new Emoji("\ud83c\uddeb\ud83c\uddee"), new Emoji("\ud83c\uddeb\ud83c\uddef"), new Emoji("\ud83c\uddeb\ud83c\uddf0"), new Emoji("\ud83c\uddeb\ud83c\uddf2"), new Emoji("\ud83c\uddeb\ud83c\uddf4"), new Emoji("\ud83c\uddeb\ud83c\uddf7"), new Emoji("\ud83c\uddec\ud83c\udde6"), new Emoji("\ud83c\uddec\ud83c\udde7"), new Emoji("\ud83c\uddec\ud83c\udde9"), new Emoji("\ud83c\uddec\ud83c\uddea"), new Emoji("\ud83c\uddec\ud83c\uddeb"), new Emoji("\ud83c\uddec\ud83c\uddec"), new Emoji("\ud83c\uddec\ud83c\udded"), new Emoji("\ud83c\uddec\ud83c\uddee"), new Emoji("\ud83c\uddec\ud83c\uddf1"), new Emoji("\ud83c\uddec\ud83c\uddf2"), new Emoji("\ud83c\uddec\ud83c\uddf3"), new Emoji("\ud83c\uddec\ud83c\uddf5"), new Emoji("\ud83c\uddec\ud83c\uddf6"), new Emoji("\ud83c\uddec\ud83c\uddf7"), new Emoji("\ud83c\uddec\ud83c\uddf8"), new Emoji("\ud83c\uddec\ud83c\uddf9"), new Emoji("\ud83c\uddec\ud83c\uddfa"), new Emoji("\ud83c\uddec\ud83c\uddfc"), new Emoji("\ud83c\uddec\ud83c\uddfe"), new Emoji("\ud83c\udded\ud83c\uddf0"), new Emoji("\ud83c\udded\ud83c\uddf2"), new Emoji("\ud83c\udded\ud83c\uddf3"), new Emoji("\ud83c\udded\ud83c\uddf7"), new Emoji("\ud83c\udded\ud83c\uddf9"), new Emoji("\ud83c\udded\ud83c\uddfa"), new Emoji("\ud83c\uddee\ud83c\udde8"), new Emoji("\ud83c\uddee\ud83c\udde9"), new Emoji("\ud83c\uddee\ud83c\uddea"), new Emoji("\ud83c\uddee\ud83c\uddf1"), new Emoji("\ud83c\uddee\ud83c\uddf2"), new Emoji("\ud83c\uddee\ud83c\uddf3"), new Emoji("\ud83c\uddee\ud83c\uddf4"), new Emoji("\ud83c\uddee\ud83c\uddf6"), new Emoji("\ud83c\uddee\ud83c\uddf7"), new Emoji("\ud83c\uddee\ud83c\uddf8"), new Emoji("\ud83c\uddee\ud83c\uddf9"), new Emoji("\ud83c\uddef\ud83c\uddea"), new Emoji("\ud83c\uddef\ud83c\uddf2"), new Emoji("\ud83c\uddef\ud83c\uddf4"), new Emoji("\ud83c\uddef\ud83c\uddf5"), new Emoji("\ud83c\uddf0\ud83c\uddea"), new Emoji("\ud83c\uddf0\ud83c\uddec"), new Emoji("\ud83c\uddf0\ud83c\udded"), new Emoji("\ud83c\uddf0\ud83c\uddee"), new Emoji("\ud83c\uddf0\ud83c\uddf2"), new Emoji("\ud83c\uddf0\ud83c\uddf3"), new Emoji("\ud83c\uddf0\ud83c\uddf5"), new Emoji("\ud83c\uddf0\ud83c\uddf7"), new Emoji("\ud83c\uddf0\ud83c\uddfc"), new Emoji("\ud83c\uddf0\ud83c\uddfe"), new Emoji("\ud83c\uddf0\ud83c\uddff"), new Emoji("\ud83c\uddf1\ud83c\udde6"), new Emoji("\ud83c\uddf1\ud83c\udde7"), new Emoji("\ud83c\uddf1\ud83c\udde8"), new Emoji("\ud83c\uddf1\ud83c\uddee"), new Emoji("\ud83c\uddf1\ud83c\uddf0"), new Emoji("\ud83c\uddf1\ud83c\uddf7"), new Emoji("\ud83c\uddf1\ud83c\uddf8"), new Emoji("\ud83c\uddf1\ud83c\uddf9"), new Emoji("\ud83c\uddf1\ud83c\uddfa"), new Emoji("\ud83c\uddf1\ud83c\uddfb"), new Emoji("\ud83c\uddf1\ud83c\uddfe"), new Emoji("\ud83c\uddf2\ud83c\udde6"), new Emoji("\ud83c\uddf2\ud83c\udde8"), new Emoji("\ud83c\uddf2\ud83c\udde9"), new Emoji("\ud83c\uddf2\ud83c\uddea"), new Emoji("\ud83c\uddf2\ud83c\uddeb"), new Emoji("\ud83c\uddf2\ud83c\uddec"), new Emoji("\ud83c\uddf2\ud83c\udded"), new Emoji("\ud83c\uddf2\ud83c\uddf0"), new Emoji("\ud83c\uddf2\ud83c\uddf1"), new Emoji("\ud83c\uddf2\ud83c\uddf2"), new Emoji("\ud83c\uddf2\ud83c\uddf3"), new Emoji("\ud83c\uddf2\ud83c\uddf4"), new Emoji("\ud83c\uddf2\ud83c\uddf5"), new Emoji("\ud83c\uddf2\ud83c\uddf6"), new Emoji("\ud83c\uddf2\ud83c\uddf7"), new Emoji("\ud83c\uddf2\ud83c\uddf8"), new Emoji("\ud83c\uddf2\ud83c\uddf9"), new Emoji("\ud83c\uddf2\ud83c\uddfa"), new Emoji("\ud83c\uddf2\ud83c\uddfb"), new Emoji("\ud83c\uddf2\ud83c\uddfc"), new Emoji("\ud83c\uddf2\ud83c\uddfd"), new Emoji("\ud83c\uddf2\ud83c\uddfe"), new Emoji("\ud83c\uddf2\ud83c\uddff"), new Emoji("\ud83c\uddf3\ud83c\udde6"), new Emoji("\ud83c\uddf3\ud83c\udde8"), new Emoji("\ud83c\uddf3\ud83c\uddea"), new Emoji("\ud83c\uddf3\ud83c\uddeb"), new Emoji("\ud83c\uddf3\ud83c\uddec"), new Emoji("\ud83c\uddf3\ud83c\uddee"), new Emoji("\ud83c\uddf3\ud83c\uddf1"), new Emoji("\ud83c\uddf3\ud83c\uddf4"), new Emoji("\ud83c\uddf3\ud83c\uddf5"), new Emoji("\ud83c\uddf3\ud83c\uddf7"), new Emoji("\ud83c\uddf3\ud83c\uddfa"), new Emoji("\ud83c\uddf3\ud83c\uddff"), new Emoji("\ud83c\uddf4\ud83c\uddf2"), new Emoji("\ud83c\uddf5\ud83c\udde6"), new Emoji("\ud83c\uddf5\ud83c\uddea"), new Emoji("\ud83c\uddf5\ud83c\uddeb"), new Emoji("\ud83c\uddf5\ud83c\uddec"), new Emoji("\ud83c\uddf5\ud83c\udded"), new Emoji("\ud83c\uddf5\ud83c\uddf0"), new Emoji("\ud83c\uddf5\ud83c\uddf1"), new Emoji("\ud83c\uddf5\ud83c\uddf2"), new Emoji("\ud83c\uddf5\ud83c\uddf3"), new Emoji("\ud83c\uddf5\ud83c\uddf7"), new Emoji("\ud83c\uddf5\ud83c\uddf8"), new Emoji("\ud83c\uddf5\ud83c\uddf9"), new Emoji("\ud83c\uddf5\ud83c\uddfc"), new Emoji("\ud83c\uddf5\ud83c\uddfe"), new Emoji("\ud83c\uddf6\ud83c\udde6"), new Emoji("\ud83c\uddf7\ud83c\uddea"), new Emoji("\ud83c\uddf7\ud83c\uddf4"), new Emoji("\ud83c\uddf7\ud83c\uddf8"), new Emoji("\ud83c\uddf7\ud83c\uddfa"), new Emoji("\ud83c\uddf7\ud83c\uddfc"), new Emoji("\ud83c\uddf8\ud83c\udde6"), new Emoji("\ud83c\uddf8\ud83c\udde7"), new Emoji("\ud83c\uddf8\ud83c\udde8"), new Emoji("\ud83c\uddf8\ud83c\udde9"), new Emoji("\ud83c\uddf8\ud83c\uddea"), new Emoji("\ud83c\uddf8\ud83c\uddec"), new Emoji("\ud83c\uddf8\ud83c\udded"), new Emoji("\ud83c\uddf8\ud83c\uddee"), new Emoji("\ud83c\uddf8\ud83c\uddef"), new Emoji("\ud83c\uddf8\ud83c\uddf0"), new Emoji("\ud83c\uddf8\ud83c\uddf1"), new Emoji("\ud83c\uddf8\ud83c\uddf2"), new Emoji("\ud83c\uddf8\ud83c\uddf3"), new Emoji("\ud83c\uddf8\ud83c\uddf4"), new Emoji("\ud83c\uddf8\ud83c\uddf7"), new Emoji("\ud83c\uddf8\ud83c\uddf8"), new Emoji("\ud83c\uddf8\ud83c\uddf9"), new Emoji("\ud83c\uddf8\ud83c\uddfb"), new Emoji("\ud83c\uddf8\ud83c\uddfd"), new Emoji("\ud83c\uddf8\ud83c\uddfe"), new Emoji("\ud83c\uddf8\ud83c\uddff"), new Emoji("\ud83c\uddf9\ud83c\udde6"), new Emoji("\ud83c\uddf9\ud83c\udde8"), new Emoji("\ud83c\uddf9\ud83c\udde9"), new Emoji("\ud83c\uddf9\ud83c\uddeb"), new Emoji("\ud83c\uddf9\ud83c\uddec"), new Emoji("\ud83c\uddf9\ud83c\udded"), new Emoji("\ud83c\uddf9\ud83c\uddef"), new Emoji("\ud83c\uddf9\ud83c\uddf0"), new Emoji("\ud83c\uddf9\ud83c\uddf1"), new Emoji("\ud83c\uddf9\ud83c\uddf2"), new Emoji("\ud83c\uddf9\ud83c\uddf3"), new Emoji("\ud83c\uddf9\ud83c\uddf4"), new Emoji("\ud83c\uddf9\ud83c\uddf7"), new Emoji("\ud83c\uddf9\ud83c\uddf9"), new Emoji("\ud83c\uddf9\ud83c\uddfb"), new Emoji("\ud83c\uddf9\ud83c\uddfc"), new Emoji("\ud83c\uddf9\ud83c\uddff"), new Emoji("\ud83c\uddfa\ud83c\udde6"), new Emoji("\ud83c\uddfa\ud83c\uddec"), new Emoji("\ud83c\uddfa\ud83c\uddf2"), new Emoji("\ud83c\uddfa\ud83c\uddf8"), new Emoji("\ud83c\uddfa\ud83c\uddfe"), new Emoji("\ud83c\uddfa\ud83c\uddff"), new Emoji("\ud83c\uddfb\ud83c\udde6"), new Emoji("\ud83c\uddfb\ud83c\udde8"), new Emoji("\ud83c\uddfb\ud83c\uddea"), new Emoji("\ud83c\uddfb\ud83c\uddec"), new Emoji("\ud83c\uddfb\ud83c\uddee"), new Emoji("\ud83c\uddfb\ud83c\uddf3"), new Emoji("\ud83c\uddfb\ud83c\uddfa"), new Emoji("\ud83c\uddfc\ud83c\uddeb"), new Emoji("\ud83c\uddfc\ud83c\uddf8"), new Emoji("\ud83c\uddfd\ud83c\uddf0"), new Emoji("\ud83c\uddfe\ud83c\uddea"), new Emoji("\ud83c\uddfe\ud83c\uddf9"), new Emoji("\ud83c\uddff\ud83c\udde6"), new Emoji("\ud83c\uddff\ud83c\uddf2"), new Emoji("\ud83c\uddff\ud83c\uddfc"), new Emoji("\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f"), new Emoji("\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f"), new Emoji("\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f") - ), Uri.parse("emoji/Flags.png")); - - static final List DISPLAY_PAGES = Arrays.asList(PAGE_PEOPLE, - PAGE_NATURE, - PAGE_FOODS, - PAGE_ACTIVITY, - PAGE_PLACES, - PAGE_OBJECTS, - PAGE_SYMBOLS, - PAGE_FLAGS); - - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiStrings.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiStrings.java deleted file mode 100644 index e43fe4cc75..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiStrings.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.thoughtcrime.securesms.components.emoji; - -public final class EmojiStrings { - public static final String BUST_IN_SILHOUETTE = "\uD83D\uDC64"; -} 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 ffa2e197d3..ea23ca13d7 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 @@ -14,21 +14,16 @@ import network.loki.messenger.R; import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable; import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser; -import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.Util; import org.session.libsignal.utilities.guava.Optional; public class EmojiTextView extends AppCompatTextView { private final boolean scaleEmojis; - private static final char ELLIPSIS = '…'; - private CharSequence previousText; private BufferType previousBufferType = BufferType.NORMAL; private float originalFontSize; - private boolean useSystemEmoji; private boolean sizeChangeInProgress; - private int maxLength; private CharSequence overflowText; private CharSequence previousOverflowText; @@ -44,7 +39,6 @@ public EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); scaleEmojis = true; - maxLength = 1000; originalFontSize = getResources().getDimension(R.dimen.medium_font_size); } @@ -81,82 +75,15 @@ public EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr) { previousText = text; previousOverflowText = overflowText; previousBufferType = type; - useSystemEmoji = useSystemEmoji(); - if (useSystemEmoji || candidates == null || candidates.size() == 0) { + if (candidates == null || candidates.size() == 0) { super.setText(new SpannableStringBuilder(Optional.fromNullable(text).or("")).append(Optional.fromNullable(overflowText).or("")), BufferType.NORMAL); - - if (getEllipsize() == TextUtils.TruncateAt.END && maxLength > 0) { - ellipsizeAnyTextForMaxLength(); - } } else { CharSequence emojified = EmojiProvider.emojify(candidates, text, this, false); super.setText(new SpannableStringBuilder(emojified).append(Optional.fromNullable(overflowText).or("")), BufferType.SPANNABLE); - - // Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688) - // We ellipsize them ourselves by manually truncating the appropriate section. - if (getEllipsize() == TextUtils.TruncateAt.END) { - if (maxLength > 0) { - ellipsizeAnyTextForMaxLength(); - } else { - ellipsizeEmojiTextForMaxLines(); - } - } } } - public void setOverflowText(@Nullable CharSequence overflowText) { - this.overflowText = overflowText; - setText(previousText, BufferType.SPANNABLE); - } - - private void ellipsizeAnyTextForMaxLength() { - if (maxLength > 0 && getText().length() > maxLength + 1) { - SpannableStringBuilder newContent = new SpannableStringBuilder(); - newContent.append(getText().subSequence(0, maxLength)).append(ELLIPSIS).append(Optional.fromNullable(overflowText).or("")); - - EmojiParser.CandidateList newCandidates = EmojiProvider.getCandidates(newContent); - - if (useSystemEmoji || newCandidates == null || newCandidates.size() == 0) { - super.setText(newContent, BufferType.NORMAL); - } else { - CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, false); - super.setText(emojified, BufferType.SPANNABLE); - } - } - } - - private void ellipsizeEmojiTextForMaxLines() { - post(() -> { - if (getLayout() == null) { - ellipsizeEmojiTextForMaxLines(); - return; - } - - int maxLines = TextViewCompat.getMaxLines(EmojiTextView.this); - if (maxLines <= 0 && maxLength < 0) { - return; - } - - int lineCount = getLineCount(); - if (lineCount > maxLines) { - int overflowStart = getLayout().getLineStart(maxLines - 1); - CharSequence overflow = getText().subSequence(overflowStart, getText().length()); - CharSequence ellipsized = TextUtils.ellipsize(overflow, getPaint(), getWidth(), TextUtils.TruncateAt.END); - - SpannableStringBuilder newContent = new SpannableStringBuilder(); - newContent.append(getText().subSequence(0, overflowStart)) - .append(ellipsized.subSequence(0, ellipsized.length())) - .append(Optional.fromNullable(overflowText).or("")); - - EmojiParser.CandidateList newCandidates = EmojiProvider.getCandidates(newContent); - CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, false); - - super.setText(emojified, BufferType.SPANNABLE); - } - }); - } - private boolean unchanged(CharSequence text, CharSequence overflowText, BufferType bufferType) { CharSequence finalPrevText = (previousText == null || previousText.length() == 0 ? "" : previousText); CharSequence finalText = (text == null || text.length() == 0 ? "" : text); @@ -166,13 +93,9 @@ private boolean unchanged(CharSequence text, CharSequence overflowText, BufferTy return Util.equals(finalPrevText, finalText) && Util.equals(finalPrevOverflowText, finalOverflowText) && Util.equals(previousBufferType, bufferType) && - useSystemEmoji == useSystemEmoji() && !sizeChangeInProgress; } - private boolean useSystemEmoji() { - return TextSecurePreferences.isSystemEmojiPreferred(getContext()); - } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java deleted file mode 100644 index 5becb16292..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiToggle.java +++ /dev/null @@ -1,84 +0,0 @@ -package org.thoughtcrime.securesms.components.emoji; - -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.drawable.Drawable; -import android.util.AttributeSet; - -import androidx.annotation.NonNull; -import androidx.appcompat.widget.AppCompatImageButton; -import org.session.libsession.utilities.TextSecurePreferences; - -import network.loki.messenger.R; - -public class EmojiToggle extends AppCompatImageButton implements MediaKeyboard.MediaKeyboardListener { - - private Drawable emojiToggle; - private Drawable stickerToggle; - - private Drawable mediaToggle; - private Drawable imeToggle; - - - public EmojiToggle(Context context) { - super(context); - initialize(); - } - - public EmojiToggle(Context context, AttributeSet attrs) { - super(context, attrs); - initialize(); - } - - public EmojiToggle(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - initialize(); - } - - public void setToMedia() { - setImageDrawable(mediaToggle); - } - - public void setToIme() { - setImageDrawable(imeToggle); - } - - private void initialize() { - TypedArray drawables = getContext().obtainStyledAttributes(new int[] { - R.attr.conversation_emoji_toggle, - R.attr.conversation_sticker_toggle, - R.attr.conversation_keyboard_toggle}); - - this.emojiToggle = drawables.getDrawable(0); - this.stickerToggle = drawables.getDrawable(1); - this.imeToggle = drawables.getDrawable(2); - this.mediaToggle = emojiToggle; - - drawables.recycle(); - setToMedia(); - } - - public void attach(MediaKeyboard drawer) { - drawer.setKeyboardListener(this); - } - - public void setStickerMode(boolean stickerMode) { - this.mediaToggle = stickerMode ? stickerToggle : emojiToggle; - - if (getDrawable() != imeToggle) { - setToMedia(); - } - } - - @Override public void onShown() { - setToIme(); - } - - @Override public void onHidden() { - setToMedia(); - } - - @Override - public void onKeyboardProviderChanged(@NonNull MediaKeyboardProvider provider) { - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java deleted file mode 100644 index acb53f7767..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboard.java +++ /dev/null @@ -1,276 +0,0 @@ -package org.thoughtcrime.securesms.components.emoji; - -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.viewpager.widget.PagerAdapter; -import androidx.viewpager.widget.ViewPager; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.FrameLayout; - - -import org.thoughtcrime.securesms.components.InputAwareLayout.InputView; -import org.thoughtcrime.securesms.components.RepeatableImageKey; -import org.session.libsignal.utilities.Log; -import com.bumptech.glide.Glide; - -import java.util.Arrays; - -import network.loki.messenger.R; - -public class MediaKeyboard extends FrameLayout implements InputView, - MediaKeyboardProvider.Presenter, - MediaKeyboardProvider.Controller, - MediaKeyboardBottomTabAdapter.EventListener -{ - - private static final String TAG = Log.tag(MediaKeyboard.class); - - private RecyclerView categoryTabs; - private ViewPager categoryPager; - private ViewGroup providerTabs; - private RepeatableImageKey backspaceButton; - private RepeatableImageKey backspaceButtonBackup; - private View searchButton; - private View addButton; - private MediaKeyboardListener keyboardListener; - private MediaKeyboardProvider[] providers; - private int providerIndex; - - private MediaKeyboardBottomTabAdapter categoryTabAdapter; - - public MediaKeyboard(Context context) { - this(context, null); - } - - public MediaKeyboard(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public void setProviders(int startIndex, MediaKeyboardProvider... providers) { - if (!Arrays.equals(this.providers, providers)) { - this.providers = providers; - this.providerIndex = startIndex; - - requestPresent(providers, providerIndex); - } - } - - public void setKeyboardListener(MediaKeyboardListener listener) { - this.keyboardListener = listener; - } - - @Override - public boolean isShowing() { - return getVisibility() == VISIBLE; - } - - @Override - public void show(int height, boolean immediate) { - if (this.categoryPager == null) initView(); - - ViewGroup.LayoutParams params = getLayoutParams(); - params.height = height; - Log.i(TAG, "showing emoji drawer with height " + params.height); - setLayoutParams(params); - setVisibility(VISIBLE); - - if (keyboardListener != null) keyboardListener.onShown(); - - requestPresent(providers, providerIndex); - } - - @Override - public void hide(boolean immediate) { - setVisibility(GONE); - if (keyboardListener != null) keyboardListener.onHidden(); - Log.i(TAG, "hide()"); - } - - @Override - public void present(@NonNull MediaKeyboardProvider provider, - @NonNull PagerAdapter pagerAdapter, - @NonNull MediaKeyboardProvider.TabIconProvider tabIconProvider, - @Nullable MediaKeyboardProvider.BackspaceObserver backspaceObserver, - @Nullable MediaKeyboardProvider.AddObserver addObserver, - @Nullable MediaKeyboardProvider.SearchObserver searchObserver, - int startingIndex) - { - if (categoryPager == null) return; - if (!provider.equals(providers[providerIndex])) return; - if (keyboardListener != null) keyboardListener.onKeyboardProviderChanged(provider); - - boolean isSolo = providers.length == 1; - - presentProviderStrip(isSolo); - presentCategoryPager(pagerAdapter, tabIconProvider, startingIndex); - presentProviderTabs(providers, providerIndex); - presentSearchButton(searchObserver); - presentBackspaceButton(backspaceObserver, isSolo); - presentAddButton(addObserver); - } - - @Override - public int getCurrentPosition() { - return categoryPager != null ? categoryPager.getCurrentItem() : 0; - } - - @Override - public void requestDismissal() { - hide(true); - providerIndex = 0; - keyboardListener.onKeyboardProviderChanged(providers[providerIndex]); - } - - @Override - public boolean isVisible() { - return getVisibility() == View.VISIBLE; - } - - @Override - public void onTabSelected(int index) { - if (categoryPager != null) { - categoryPager.setCurrentItem(index); - categoryTabs.smoothScrollToPosition(index); - } - } - - @Override - public void setViewPagerEnabled(boolean enabled) { - if (categoryPager != null) { - categoryPager.setEnabled(enabled); - } - } - - private void initView() { - final View view = LayoutInflater.from(getContext()).inflate(R.layout.media_keyboard, this, true); - - this.categoryTabs = view.findViewById(R.id.media_keyboard_tabs); - this.categoryPager = view.findViewById(R.id.media_keyboard_pager); - this.providerTabs = view.findViewById(R.id.media_keyboard_provider_tabs); - this.backspaceButton = view.findViewById(R.id.media_keyboard_backspace); - this.backspaceButtonBackup = view.findViewById(R.id.media_keyboard_backspace_backup); - this.searchButton = view.findViewById(R.id.media_keyboard_search); - this.addButton = view.findViewById(R.id.media_keyboard_add); - - this.categoryTabAdapter = new MediaKeyboardBottomTabAdapter(Glide.with(this), this); - - categoryTabs.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.HORIZONTAL, false)); - categoryTabs.setAdapter(categoryTabAdapter); - } - - private void requestPresent(@NonNull MediaKeyboardProvider[] providers, int newIndex) { - providers[providerIndex].setController(null); - providerIndex = newIndex; - - providers[providerIndex].setController(this); - providers[providerIndex].requestPresentation(this, providers.length == 1); - } - - - private void presentCategoryPager(@NonNull PagerAdapter pagerAdapter, - @NonNull MediaKeyboardProvider.TabIconProvider iconProvider, - int startingIndex) { - if (categoryPager.getAdapter() != pagerAdapter) { - categoryPager.setAdapter(pagerAdapter); - } - - categoryPager.setCurrentItem(startingIndex); - - categoryPager.clearOnPageChangeListeners(); - categoryPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() { - @Override - public void onPageScrolled(int i, float v, int i1) { - } - - @Override - public void onPageSelected(int i) { - categoryTabAdapter.setActivePosition(i); - categoryTabs.smoothScrollToPosition(i); - } - - @Override - public void onPageScrollStateChanged(int i) { - } - }); - - categoryTabAdapter.setTabIconProvider(iconProvider, pagerAdapter.getCount()); - categoryTabAdapter.setActivePosition(startingIndex); - } - - private void presentProviderTabs(@NonNull MediaKeyboardProvider[] providers, int selected) { - providerTabs.removeAllViews(); - - LayoutInflater inflater = LayoutInflater.from(getContext()); - - for (int i = 0; i < providers.length; i++) { - MediaKeyboardProvider provider = providers[i]; - View view = inflater.inflate(provider.getProviderIconView(i == selected), providerTabs, false); - - view.setTag(provider); - - final int index = i; - view.setOnClickListener(v -> { - requestPresent(providers, index); - }); - - providerTabs.addView(view); - } - } - - private void presentBackspaceButton(@Nullable MediaKeyboardProvider.BackspaceObserver backspaceObserver, - boolean useBackupPosition) - { - if (backspaceObserver != null) { - if (useBackupPosition) { - backspaceButton.setVisibility(INVISIBLE); - backspaceButton.setOnKeyEventListener(null); - backspaceButtonBackup.setVisibility(VISIBLE); - backspaceButtonBackup.setOnKeyEventListener(backspaceObserver::onBackspaceClicked); - } else { - backspaceButton.setVisibility(VISIBLE); - backspaceButton.setOnKeyEventListener(backspaceObserver::onBackspaceClicked); - backspaceButtonBackup.setVisibility(GONE); - backspaceButtonBackup.setOnKeyEventListener(null); - } - } else { - backspaceButton.setVisibility(INVISIBLE); - backspaceButton.setOnKeyEventListener(null); - backspaceButtonBackup.setVisibility(GONE); - backspaceButton.setOnKeyEventListener(null); - } - } - - private void presentAddButton(@Nullable MediaKeyboardProvider.AddObserver addObserver) { - if (addObserver != null) { - addButton.setVisibility(VISIBLE); - addButton.setOnClickListener(v -> addObserver.onAddClicked()); - } else { - addButton.setVisibility(GONE); - addButton.setOnClickListener(null); - } - } - - private void presentSearchButton(@Nullable MediaKeyboardProvider.SearchObserver searchObserver) { - searchButton.setVisibility(searchObserver != null ? VISIBLE : INVISIBLE); - } - - private void presentProviderStrip(boolean isSolo) { - int visibility = isSolo ? View.GONE : View.VISIBLE; - - searchButton.setVisibility(visibility); - backspaceButton.setVisibility(visibility); - providerTabs.setVisibility(visibility); - } - - public interface MediaKeyboardListener { - void onShown(); - void onHidden(); - void onKeyboardProviderChanged(@NonNull MediaKeyboardProvider provider); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardBottomTabAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardBottomTabAdapter.java deleted file mode 100644 index 08a2ec528f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardBottomTabAdapter.java +++ /dev/null @@ -1,97 +0,0 @@ -package org.thoughtcrime.securesms.components.emoji; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; - - -import org.thoughtcrime.securesms.components.emoji.MediaKeyboardProvider.TabIconProvider; -import com.bumptech.glide.RequestManager; - -import network.loki.messenger.R; - -public class MediaKeyboardBottomTabAdapter extends RecyclerView.Adapter { - - private final RequestManager glideRequests; - private final EventListener eventListener; - - private TabIconProvider tabIconProvider; - private int activePosition; - private int count; - - public MediaKeyboardBottomTabAdapter(@NonNull RequestManager glideRequests, @NonNull EventListener eventListener) { - this.glideRequests = glideRequests; - this.eventListener = eventListener; - } - - @Override - public @NonNull MediaKeyboardBottomTabViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { - return new MediaKeyboardBottomTabViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.media_keyboard_bottom_tab_item, viewGroup, false)); - } - - @Override - public void onBindViewHolder(@NonNull MediaKeyboardBottomTabViewHolder viewHolder, int i) { - viewHolder.bind(glideRequests, eventListener, tabIconProvider, i, i == activePosition); - } - - @Override - public void onViewRecycled(@NonNull MediaKeyboardBottomTabViewHolder holder) { - holder.recycle(); - } - - @Override - public int getItemCount() { - return count; - } - - public void setTabIconProvider(@NonNull TabIconProvider iconProvider, int count) { - this.tabIconProvider = iconProvider; - this.count = count; - - notifyDataSetChanged(); - } - - public void setActivePosition(int position) { - this.activePosition = position; - notifyDataSetChanged(); - } - - static class MediaKeyboardBottomTabViewHolder extends RecyclerView.ViewHolder { - - private final ImageView image; - private final View indicator; - - public MediaKeyboardBottomTabViewHolder(@NonNull View itemView) { - super(itemView); - - this.image = itemView.findViewById(R.id.media_keyboard_bottom_tab_image); - this.indicator = itemView.findViewById(R.id.media_keyboard_bottom_tab_indicator); - } - - void bind(@NonNull RequestManager glideRequests, - @NonNull EventListener eventListener, - @NonNull TabIconProvider tabIconProvider, - int index, - boolean selected) - { - tabIconProvider.loadCategoryTabIcon(glideRequests, image, index); - image.setAlpha(selected ? 1 : 0.5f); - image.setSelected(selected); - - indicator.setVisibility(selected ? View.VISIBLE : View.INVISIBLE); - - itemView.setOnClickListener(v -> eventListener.onTabSelected(index)); - } - - void recycle() { - itemView.setOnClickListener(null); - } - } - - interface EventListener { - void onTabSelected(int index); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardProvider.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardProvider.java deleted file mode 100644 index 21bd6b3a48..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/MediaKeyboardProvider.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.thoughtcrime.securesms.components.emoji; - -import androidx.annotation.LayoutRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.viewpager.widget.PagerAdapter; -import android.widget.ImageView; - - - -import com.bumptech.glide.RequestManager; - -public interface MediaKeyboardProvider { - @LayoutRes int getProviderIconView(boolean selected); - /** @return True if the click was handled with provider-specific logic, otherwise false */ - void requestPresentation(@NonNull Presenter presenter, boolean isSoloProvider); - void setController(@Nullable Controller controller); - - interface BackspaceObserver { - void onBackspaceClicked(); - } - - interface AddObserver { - void onAddClicked(); - } - - interface SearchObserver { - void onSearchOpened(); - void onSearchClosed(); - void onSearchChanged(@NonNull String query); - } - - interface Controller { - void setViewPagerEnabled(boolean enabled); - } - - interface Presenter { - void present(@NonNull MediaKeyboardProvider provider, - @NonNull PagerAdapter pagerAdapter, - @NonNull TabIconProvider iconProvider, - @Nullable BackspaceObserver backspaceObserver, - @Nullable AddObserver addObserver, - @Nullable SearchObserver searchObserver, - int startingIndex); - int getCurrentPosition(); - void requestDismissal(); - boolean isVisible(); - } - - interface TabIconProvider { - void loadCategoryTabIcon(@NonNull RequestManager glideRequests, @NonNull ImageView imageView, int index); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt index d2efc8ceba..fa98891d39 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -24,7 +24,8 @@ import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 -import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2 +import org.session.libsession.snode.OwnedSwarmAuth +import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.ConfigFactoryProtocol @@ -32,6 +33,7 @@ import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol.Companion.NAME_PADDED_LENGTH import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType +import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey @@ -39,7 +41,6 @@ import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.MmsSmsDatabase -import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.groups.ClosedGroupManager @@ -61,13 +62,12 @@ class ConfigToDatabaseSync @Inject constructor( private val configFactory: ConfigFactoryProtocol, private val storage: StorageProtocol, private val threadDatabase: ThreadDatabase, - private val recipientDatabase: RecipientDatabase, private val clock: SnodeClock, private val profileManager: ProfileManager, private val preferences: TextSecurePreferences, private val conversationRepository: ConversationRepository, private val mmsSmsDatabase: MmsSmsDatabase, - private val legacyClosedGroupPollerV2: LegacyClosedGroupPollerV2, + private val openGroupManager: OpenGroupManager, ) { init { if (!preferences.migratedToGroupV2Config) { @@ -136,10 +136,10 @@ class ConfigToDatabaseSync @Inject constructor( // Update profile picture if (userProfile.userPic == UserPic.DEFAULT) { storage.clearUserPic(clearConfig = false) - } else if (userProfile.userPic.key.isNotEmpty() && userProfile.userPic.url.isNotEmpty() + } else if (userProfile.userPic.key.data.isNotEmpty() && userProfile.userPic.url.isNotEmpty() && preferences.getProfilePictureURL() != userProfile.userPic.url ) { - storage.setUserProfilePicture(userProfile.userPic.url, userProfile.userPic.key) + storage.setUserProfilePicture(userProfile.userPic.url, userProfile.userPic.key.data) } if (userProfile.ntsPriority == PRIORITY_HIDDEN) { @@ -172,22 +172,28 @@ class ConfigToDatabaseSync @Inject constructor( val name: String?, val destroyed: Boolean, val deleteBefore: Long?, - val deleteAttachmentsBefore: Long? + val deleteAttachmentsBefore: Long?, + val profilePic: UserPic? ) { constructor(groupInfoConfig: ReadableGroupInfoConfig) : this( - id = groupInfoConfig.id(), + id = AccountId(groupInfoConfig.id()), name = groupInfoConfig.getName(), destroyed = groupInfoConfig.isDestroyed(), deleteBefore = groupInfoConfig.getDeleteBefore(), - deleteAttachmentsBefore = groupInfoConfig.getDeleteAttachmentsBefore() + deleteAttachmentsBefore = groupInfoConfig.getDeleteAttachmentsBefore(), + profilePic = groupInfoConfig.getProfilePic() ) } private fun updateGroup(groupInfoConfig: UpdateGroupInfo) { val threadId = storage.getThreadId(fromSerialized(groupInfoConfig.id.hexString)) ?: return val recipient = storage.getRecipientForThread(threadId) ?: return - recipientDatabase.setProfileName(recipient, groupInfoConfig.name) profileManager.setName(context, recipient, groupInfoConfig.name.orEmpty()) + profileManager.setProfilePicture( + context, recipient, + profilePictureURL = groupInfoConfig.profilePic?.url, + profileKey = groupInfoConfig.profilePic?.key?.data + ) // Also update the name in the user groups config configFactory.withMutableUserConfigs { configs -> @@ -201,15 +207,32 @@ class ConfigToDatabaseSync @Inject constructor( } else { groupInfoConfig.deleteBefore?.let { removeBefore -> val messages = mmsSmsDatabase.getAllMessageRecordsBefore(threadId, TimeUnit.SECONDS.toMillis(removeBefore)) - val (controlMessages, visibleMessages) = messages.partition { it.isControlMessage } + val (controlMessages, visibleMessages) = messages.map { it.first }.partition { it.isControlMessage } // Mark visible messages as deleted, and control messages actually deleted. conversationRepository.markAsDeletedLocally(visibleMessages.toSet(), context.getString(R.string.deleteMessageDeletedGlobally)) conversationRepository.deleteMessages(controlMessages.toSet(), threadId) + + // if the current user is an admin of this group they should also remove the message from the swarm + // as a safety measure + val groupAdminAuth = configFactory.getGroup(groupInfoConfig.id)?.adminKey?.data?.let { + OwnedSwarmAuth.ofClosedGroup(groupInfoConfig.id, it) + } ?: return + + // remove messages from swarm SnodeAPI.deleteMessage + GlobalScope.launch(Dispatchers.Default) { + val cleanedHashes: List = + messages.asSequence().map { it.second }.filter { !it.isNullOrEmpty() }.filterNotNull().toList() + if (cleanedHashes.isNotEmpty()) SnodeAPI.deleteMessage( + groupInfoConfig.id.hexString, + groupAdminAuth, + cleanedHashes + ) + } } groupInfoConfig.deleteAttachmentsBefore?.let { removeAttachmentsBefore -> val messagesWithAttachment = mmsSmsDatabase.getAllMessageRecordsBefore(threadId, TimeUnit.SECONDS.toMillis(removeAttachmentsBefore)) - .filterTo(mutableSetOf()) { it is MmsMessageRecord && it.containsAttachment } + .map{ it.first}.filterTo(mutableSetOf()) { it is MmsMessageRecord && it.containsAttachment } conversationRepository.markAsDeletedLocally(messagesWithAttachment, context.getString(R.string.deleteMessageDeletedGlobally)) } @@ -222,7 +245,7 @@ class ConfigToDatabaseSync @Inject constructor( private data class UpdateContacts(val contacts: List) private fun updateContacts(contacts: UpdateContacts, messageTimestamp: Long?) { - storage.addLibSessionContacts(contacts.contacts, messageTimestamp) + storage.syncLibSessionContacts(contacts.contacts, messageTimestamp) } private data class UpdateUserGroupsInfo( @@ -259,7 +282,7 @@ class ConfigToDatabaseSync @Inject constructor( // delete the ones which are not listed in the config toDeleteCommunities.values.forEach { openGroup -> - OpenGroupManager.delete(openGroup.server, openGroup.room, context) + openGroupManager.delete(openGroup.server, openGroup.room, context) } toDeleteLegacyClosedGroups.forEach { deleteGroup -> @@ -291,7 +314,7 @@ class ConfigToDatabaseSync @Inject constructor( var current = reader.next while (current != null) { if (current.recipient?.isGroupV2Recipient == true) { - put(AccountId(current.recipient.address.serialize()), current.threadId) + put(AccountId(current.recipient.address.toString()), current.threadId) } current = reader.next @@ -302,7 +325,7 @@ class ConfigToDatabaseSync @Inject constructor( val groupThreadsToKeep = hashMapOf() for (closedGroup in userGroups.closedGroupInfo) { - val recipient = Recipient.from(context, fromSerialized(closedGroup.groupAccountId.hexString), false) + val recipient = Recipient.from(context, fromSerialized(closedGroup.groupAccountId), false) storage.setRecipientApprovedMe(recipient, true) storage.setRecipientApproved(recipient, !closedGroup.invited) profileManager.setName(context, recipient, closedGroup.name) @@ -316,7 +339,7 @@ class ConfigToDatabaseSync @Inject constructor( ) } - groupThreadsToKeep[closedGroup.groupAccountId] = threadId + groupThreadsToKeep[AccountId(closedGroup.groupAccountId)] = threadId storage.setPinned(threadId, closedGroup.priority == PRIORITY_PINNED) @@ -328,7 +351,7 @@ class ConfigToDatabaseSync @Inject constructor( val toRemove = existingClosedGroupThreads - groupThreadsToKeep.keys Log.d(TAG, "Removing ${toRemove.size} closed groups") toRemove.forEach { (_, threadId) -> - storage.removeClosedGroupThread(threadId) + storage.deleteConversation(threadId) } for (group in userGroups.legacyGroupInfo) { @@ -355,7 +378,7 @@ class ConfigToDatabaseSync @Inject constructor( // Add the group to the user's set of public keys to poll for storage.addClosedGroupPublicKey(group.accountId) // Store the encryption key pair - val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey)) + val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey.data), DjbECPrivateKey(group.encSecKey.data)) storage.addClosedGroupEncryptionKeyPair(keyPair, group.accountId, clock.currentTimeMills()) // Notify the PN server PushRegistryV1.subscribeGroup(group.accountId, publicKey = localUserPublicKey) @@ -366,10 +389,6 @@ class ConfigToDatabaseSync @Inject constructor( // Note: Commenting out this line prevents the timestamp of room creation being added to a new closed group, // which in turn allows us to show the `groupNoMessages` control message text. //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 - legacyClosedGroupPollerV2.startPolling(group.accountId) } if (messageTimestamp != null) { @@ -399,10 +418,19 @@ class ConfigToDatabaseSync @Inject constructor( is Conversation.LegacyGroup -> storage.getThreadIdFor("", conversation.groupId,null, createThread = false) is Conversation.Community -> storage.getThreadIdFor("",null, "${conversation.baseCommunityInfo.baseUrl.removeSuffix("/")}.${conversation.baseCommunityInfo.room}", createThread = false) is Conversation.ClosedGroup -> storage.getThreadIdFor(conversation.accountId, null, null, createThread = false) // New groups will be managed bia libsession + is Conversation.BlindedOneToOne -> { + // Not supported yet + continue + } } + if (threadId != null) { if (conversation.lastRead > storage.getLastSeen(threadId)) { - storage.markConversationAsRead(threadId, conversation.lastRead, force = true) + storage.markConversationAsRead( + threadId, + conversation.lastRead, + force = true + ) storage.updateThread(threadId, false) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt index b3ac243416..4680759e59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope +import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.util.ConfigPush import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth @@ -41,9 +42,9 @@ import org.session.libsession.utilities.getGroup import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.retryWithUniformInterval +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.util.NetworkConnectivity import javax.inject.Inject @@ -66,7 +67,7 @@ class ConfigUploader @Inject constructor( private val clock: SnodeClock, private val networkConnectivity: NetworkConnectivity, private val textSecurePreferences: TextSecurePreferences, -) { +) : OnAppStartupComponent { private var job: Job? = null /** @@ -93,7 +94,7 @@ class ConfigUploader @Inject constructor( @OptIn(DelicateCoroutinesApi::class, FlowPreview::class, ExperimentalCoroutinesApi::class) - fun start() { + override fun onPostAppStarted() { require(job == null) { "Already started" } job = GlobalScope.launch { @@ -138,7 +139,7 @@ class ConfigUploader @Inject constructor( configFactory.withUserConfigs { configs -> configs.userGroups.allClosedGroupInfo() } .asSequence() .filter { !it.destroyed && !it.kicked } - .map { it.groupAccountId } + .map { AccountId(it.groupAccountId) } .asFlow() }, @@ -177,7 +178,7 @@ class ConfigUploader @Inject constructor( return } - pushGroupConfigsChangesIfNeeded(adminKey, groupId) { groupConfigAccess -> + pushGroupConfigsChangesIfNeeded(adminKey.data, groupId) { groupConfigAccess -> configFactory.withMutableGroupConfigs(groupId) { groupConfigAccess(it) } @@ -208,11 +209,15 @@ class ConfigUploader @Inject constructor( // Gather data to push groupConfigAccess { configs -> if (configs.groupMembers.needsPush()) { - membersPush = configs.groupMembers.push() + membersPush = runCatching { configs.groupMembers.push() } + .onFailure { Log.w(TAG, "Error generating group members config push", it) } + .getOrNull() } if (configs.groupInfo.needsPush()) { - infoPush = configs.groupInfo.push() + infoPush = runCatching { configs.groupInfo.push() } + .onFailure { Log.w(TAG, "Error generating group info config push", it) } + .getOrNull() } keysPush = configs.groupKeys.pendingConfig() @@ -245,7 +250,7 @@ class ConfigUploader @Inject constructor( auth ), responseType = StoreMessageResponse::class.java - ).toConfigPushResult() + ).let(::listOf).toConfigPushResult() } // Spawn the config pushing concurrently @@ -272,12 +277,14 @@ class ConfigUploader @Inject constructor( // Confirm the push groupConfigAccess { configs -> - memberPushResult?.let { (push, result) -> configs.groupMembers.confirmPushed(push.seqNo, result.hash) } - infoPushResult?.let { (push, result) -> configs.groupInfo.confirmPushed(push.seqNo, result.hash) } - keysPushResult?.let { (hash, timestamp) -> + memberPushResult?.let { (push, result) -> configs.groupMembers.confirmPushed(push.seqNo, result.hashes.toTypedArray()) } + infoPushResult?.let { (push, result) -> configs.groupInfo.confirmPushed(push.seqNo, result.hashes.toTypedArray()) } + keysPushResult?.let { (hashes, timestamp) -> val pendingConfig = configs.groupKeys.pendingConfig() if (pendingConfig != null) { - configs.groupKeys.loadKey(pendingConfig, hash, timestamp) + for (hash in hashes) { + configs.groupKeys.loadKey(pendingConfig, hash, timestamp) + } } } } @@ -297,21 +304,36 @@ class ConfigUploader @Inject constructor( push: ConfigPush, namespace: Int ): ConfigPushResult { - val response = SnodeAPI.sendBatchRequest( - snode = snode, - publicKey = auth.accountId.hexString, - request = SnodeAPI.buildAuthenticatedStoreBatchInfo( - namespace, - SnodeMessage( - auth.accountId.hexString, - Base64.encodeBytes(push.config), - SnodeMessage.CONFIG_TTL, - clock.currentTimeMills(), - ), - auth, - ), - responseType = StoreMessageResponse::class.java - ) + // Use a coroutineScope to push all messages concurrently, and if one of them fails the whole + // process will be cancelled. This is the requirement of pushing config: all messages have + // to be sent successfully for us to consider this process as success + val responses = coroutineScope { + val timestamp = clock.currentTimeMills() + + Log.d(TAG, "Pushing ${push.messages.size} config messages") + + push.messages + .map { message -> + async { + SnodeAPI.sendBatchRequest( + snode = snode, + publicKey = auth.accountId.hexString, + request = SnodeAPI.buildAuthenticatedStoreBatchInfo( + namespace, + SnodeMessage( + auth.accountId.hexString, + Base64.encodeBytes(message.data), + SnodeMessage.CONFIG_TTL, + timestamp, + ), + auth, + ), + responseType = StoreMessageResponse::class.java + ) + } + } + .awaitAll() + } if (push.obsoleteHashes.isNotEmpty()) { SnodeAPI.sendBatchRequest( @@ -321,7 +343,7 @@ class ConfigUploader @Inject constructor( ) } - return response.toConfigPushResult() + return responses.toConfigPushResult() } private suspend fun pushUserConfigChangesIfNeeded() = coroutineScope { @@ -338,7 +360,12 @@ class ConfigUploader @Inject constructor( return@mapNotNull null } - type to config.push() + val configPush = runCatching { config.push() } + .onFailure { Log.w(TAG, "Error generating $type config", it) } + .getOrNull() + ?: return@mapNotNull null + + type to configPush } } @@ -352,17 +379,17 @@ class ConfigUploader @Inject constructor( val pushTasks = pushes.map { (configType, configPush) -> async { - (configType to configPush) to pushConfig( + Triple(configType, configPush, pushConfig( userAuth, snode, configPush, configType.namespace - ) + )) } } val pushResults = - pushTasks.awaitAll().associate { it.first.first to (it.first.second to it.second) } + pushTasks.awaitAll().associate { (configType, push, result) -> configType to (push to result) } Log.d(TAG, "Pushed ${pushResults.size} user configs") @@ -374,7 +401,10 @@ class ConfigUploader @Inject constructor( ) } - private fun StoreMessageResponse.toConfigPushResult(): ConfigPushResult { - return ConfigPushResult(hash, timestamp) + private fun List.toConfigPushResult(): ConfigPushResult { + return ConfigPushResult( + hashes = map { it.hash }, + timestamp = first().timestamp + ) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java index 01317bc9b9..8b0dd77064 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java @@ -51,18 +51,6 @@ public static synchronized ContactAccessor getInstance() { return instance; } - public String getNameFromContact(Context context, Uri uri) { - return "Anonymous"; - } - - public ContactData getContactData(Context context, Uri uri) { - return getContactData(context, getNameFromContact(context, uri), Long.parseLong(uri.getLastPathSegment())); - } - - private ContactData getContactData(Context context, String displayName, long id) { - return new ContactData(id, displayName); - } - public List getNumbersForThreadSearchFilter(Context context, String constraint) { LinkedList numberList = new LinkedList<>(); @@ -73,19 +61,9 @@ public List getNumbersForThreadSearchFilter(Context context, String cons } } -// if (context.getString(R.string.noteToSelf).toLowerCase().contains(constraint.toLowerCase()) && -// !numberList.contains(TextSecurePreferences.getLocalNumber(context))) -// { -// numberList.add(TextSecurePreferences.getLocalNumber(context)); -// } - return numberList; } - public CharSequence phoneTypeToString(Context mContext, int type, CharSequence label) { - return label; - } - public static class NumberData implements Parcelable { public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { @@ -101,10 +79,6 @@ public NumberData[] newArray(int size) { public final String number; public final String type; - public NumberData(String type, String number) { - this.type = type; - this.number = number; - } public NumberData(Parcel in) { number = in.readString(); @@ -137,12 +111,6 @@ public ContactData[] newArray(int size) { public final String name; public final List numbers; - public ContactData(long id, String name) { - this.id = id; - this.name = name; - this.numbers = new LinkedList(); - } - public ContactData(Parcel in) { id = in.readLong(); name = in.readString(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.kt deleted file mode 100644 index e299277bf5..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.kt +++ /dev/null @@ -1,100 +0,0 @@ -package org.thoughtcrime.securesms.contacts - -import android.content.Context -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import network.loki.messenger.databinding.ContactSelectionListDividerBinding -import org.session.libsession.utilities.recipients.Recipient -import com.bumptech.glide.RequestManager - -class ContactSelectionListAdapter(private val context: Context, private val multiSelect: Boolean) : RecyclerView.Adapter() { - lateinit var glide: RequestManager - val selectedContacts = mutableSetOf() - var items = listOf() - set(value) { field = value; notifyDataSetChanged() } - var contactClickListener: ContactClickListener? = null - - private object ViewType { - const val Contact = 0 - const val Divider = 1 - } - - class UserViewHolder(val view: UserView) : RecyclerView.ViewHolder(view) - class DividerViewHolder( - private val binding: ContactSelectionListDividerBinding - ) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: ContactSelectionListItem.Header) { - with(binding){ - label.text = item.name - } - } - } - - override fun getItemCount(): Int { - return items.size - } - - override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - super.onViewRecycled(holder) - if (holder is UserViewHolder) { - holder.view.unbind() - } - } - - override fun getItemViewType(position: Int): Int { - return when (items[position]) { - is ContactSelectionListItem.Header -> ViewType.Divider - else -> ViewType.Contact - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return if (viewType == ViewType.Contact) { - UserViewHolder(UserView(context)) - } else { - DividerViewHolder( - ContactSelectionListDividerBinding.inflate(LayoutInflater.from(context), parent, false) - ) - } - } - - override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) { - val item = items[position] - if (viewHolder is UserViewHolder) { - item as ContactSelectionListItem.Contact - viewHolder.view.setOnClickListener { contactClickListener?.onContactClick(item.recipient) } - val isSelected = selectedContacts.contains(item.recipient) - viewHolder.view.bind( - item.recipient, - glide, - if (multiSelect) UserView.ActionIndicator.Tick else UserView.ActionIndicator.None, - isSelected) - } else if (viewHolder is DividerViewHolder) { - viewHolder.bind(item as ContactSelectionListItem.Header) - } - } - - fun onContactClick(recipient: Recipient) { - if (selectedContacts.contains(recipient)) { - selectedContacts.remove(recipient) - contactClickListener?.onContactDeselected(recipient) - } else if (multiSelect || selectedContacts.isEmpty()) { - selectedContacts.add(recipient) - contactClickListener?.onContactSelected(recipient) - } - val index = items.indexOfFirst { - when (it) { - is ContactSelectionListItem.Header -> false - is ContactSelectionListItem.Contact -> it.recipient == recipient - } - } - notifyItemChanged(index) - } -} - -interface ContactClickListener { - fun onContactClick(contact: Recipient) - fun onContactSelected(contact: Recipient) - fun onContactDeselected(contact: Recipient) -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt deleted file mode 100644 index 995d194266..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt +++ /dev/null @@ -1,121 +0,0 @@ -package org.thoughtcrime.securesms.contacts - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.loader.app.LoaderManager -import androidx.loader.content.Loader -import androidx.recyclerview.widget.LinearLayoutManager -import network.loki.messenger.databinding.ContactSelectionListFragmentBinding -import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.Log -import com.bumptech.glide.Glide -import dagger.hilt.android.AndroidEntryPoint -import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager -import javax.inject.Inject - -@AndroidEntryPoint -class ContactSelectionListFragment : Fragment(), LoaderManager.LoaderCallbacks>, ContactClickListener { - private lateinit var binding: ContactSelectionListFragmentBinding - private var cursorFilter: String? = null - var onContactSelectedListener: OnContactSelectedListener? = null - - @Inject - lateinit var deprecationManager: LegacyGroupDeprecationManager - - private val multiSelect: Boolean by lazy { - requireActivity().intent.getBooleanExtra(MULTI_SELECT, false) - } - - private val listAdapter by lazy { - val result = ContactSelectionListAdapter(requireActivity(), multiSelect) - result.glide = Glide.with(this) - result.contactClickListener = this - result - } - - companion object { - @JvmField val DISPLAY_MODE = "display_mode" - @JvmField val MULTI_SELECT = "multi_select" - @JvmField val REFRESHABLE = "refreshable" - } - - interface OnContactSelectedListener { - fun onContactSelected(number: String?) - fun onContactDeselected(number: String?) - } - - override fun onStart() { - super.onStart() - LoaderManager.getInstance(this).initLoader(0, null, this) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = ContactSelectionListFragmentBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.recyclerView.layoutManager = LinearLayoutManager(activity) - binding.recyclerView.adapter = listAdapter - } - - override fun onStop() { - super.onStop() - LoaderManager.getInstance(this).destroyLoader(0) - } - - fun setQueryFilter(filter: String?) { - cursorFilter = filter - LoaderManager.getInstance(this).restartLoader(0, null, this) - } - - fun resetQueryFilter() { - setQueryFilter(null) - } - - override fun onCreateLoader(id: Int, args: Bundle?): Loader> { - return ContactSelectionListLoader( - context = requireActivity(), - mode = requireActivity().intent.getIntExtra(DISPLAY_MODE, ContactsCursorLoader.DisplayMode.FLAG_ALL), - filter = cursorFilter, - deprecationManager = deprecationManager - ) - } - - override fun onLoadFinished(loader: Loader>, items: List) { - update(items) - } - - override fun onLoaderReset(loader: Loader>) { - update(listOf()) - } - - private fun update(items: List) { - if (activity?.isDestroyed == true) { - Log.e(ContactSelectionListFragment::class.java.name, - "Received a loader callback after the fragment was detached from the activity.", - IllegalStateException()) - return - } - listAdapter.items = items - binding.loader.visibility = View.GONE - binding.recyclerView.visibility = if (items.isEmpty()) View.GONE else View.VISIBLE - binding.emptyStateContainer.visibility = if (items.isEmpty()) View.VISIBLE else View.GONE - } - - override fun onContactClick(contact: Recipient) { - listAdapter.onContactClick(contact) - } - - override fun onContactSelected(contact: Recipient) { - onContactSelectedListener?.onContactSelected(contact.address.serialize()) - } - - override fun onContactDeselected(contact: Recipient) { - onContactSelectedListener?.onContactDeselected(contact.address.serialize()) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt deleted file mode 100644 index 9164d53e7c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt +++ /dev/null @@ -1,81 +0,0 @@ -package org.thoughtcrime.securesms.contacts - -import android.content.Context -import network.loki.messenger.R -import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager -import org.thoughtcrime.securesms.util.ContactUtilities -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.util.AsyncLoader - -sealed class ContactSelectionListItem { - class Header(val name: String) : ContactSelectionListItem() - class Contact(val recipient: Recipient) : ContactSelectionListItem() -} - -class ContactSelectionListLoader( - context: Context, - val mode: Int, - val filter: String?, - private val deprecationManager: LegacyGroupDeprecationManager, -) : AsyncLoader>(context) { - - object DisplayMode { - const val FLAG_CONTACTS = 1 - const val FLAG_CLOSED_GROUPS = 1 shl 1 - const val FLAG_OPEN_GROUPS = 1 shl 2 - const val FLAG_ALL = FLAG_CONTACTS or FLAG_CLOSED_GROUPS or FLAG_OPEN_GROUPS - } - - private fun isFlagSet(flag: Int): Boolean { - return mode and flag > 0 - } - - override fun loadInBackground(): List { - val contacts = ContactUtilities.getAllContacts(context).filter { - if (filter.isNullOrEmpty()) return@filter true - it.toShortString().contains(filter.trim(), true) || it.address.serialize().contains(filter.trim(), true) - }.sortedBy { - it.toShortString() - } - val list = mutableListOf() - if (isFlagSet(DisplayMode.FLAG_CLOSED_GROUPS)) { - list.addAll(getGroups(contacts)) - } - if (isFlagSet(DisplayMode.FLAG_OPEN_GROUPS)) { - list.addAll(getCommunities(contacts)) - } - if (isFlagSet(DisplayMode.FLAG_CONTACTS)) { - list.addAll(getContacts(contacts)) - } - return list - } - - private fun getContacts(contacts: List): List { - return getItems(contacts, context.getString(R.string.contactContacts)) { - !it.isGroupOrCommunityRecipient - } - } - - private fun getGroups(contacts: List): List { - return getItems(contacts, context.getString(R.string.conversationsGroups)) { - val isDeprecatedLegacyGroup = it.isLegacyGroupRecipient && - deprecationManager.isDeprecated - it.address.isGroup && !isDeprecatedLegacyGroup - } - } - - private fun getCommunities(contacts: List): List { - return getItems(contacts, context.getString(R.string.conversationsCommunities)) { - it.address.isCommunity - } - } - - private fun getItems(contacts: List, title: String, contactFilter: (Recipient) -> Boolean): List { - val items = contacts.filter(contactFilter).map { - ContactSelectionListItem.Contact(it) - } - if (items.isEmpty()) return listOf() - val header = ContactSelectionListItem.Header(title) - return listOf(header) + items - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactUtil.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactUtil.java deleted file mode 100644 index 046c20002d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactUtil.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.thoughtcrime.securesms.contacts; - -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.text.TextUtils; - -import org.session.libsession.utilities.Contact; - -import org.thoughtcrime.securesms.components.emoji.EmojiStrings; -import org.thoughtcrime.securesms.util.SpanUtil; - -import network.loki.messenger.R; - -public final class ContactUtil { - - public static @NonNull CharSequence getStringSummary(@NonNull Context context, @NonNull Contact contact) { - String contactName = ContactUtil.getDisplayName(contact); - - if (!TextUtils.isEmpty(contactName)) { - return EmojiStrings.BUST_IN_SILHOUETTE + " " + contactName; - } - - return SpanUtil.italic(context.getString(R.string.unknown)); - } - - private static @NonNull String getDisplayName(@Nullable Contact contact) { - if (contact == null) { - return ""; - } - - if (!TextUtils.isEmpty(contact.getName().getDisplayName())) { - return contact.getName().getDisplayName(); - } - - if (!TextUtils.isEmpty(contact.getOrganization())) { - return contact.getOrganization(); - } - - return ""; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java deleted file mode 100644 index 83084d2673..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java +++ /dev/null @@ -1,243 +0,0 @@ -/* - * Copyright (C) 2013-2017 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 . - */ -package org.thoughtcrime.securesms.contacts; - -import android.content.Context; -import android.database.Cursor; -import android.database.MatrixCursor; -import android.database.MergeCursor; -import android.provider.ContactsContract; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.loader.content.CursorLoader; - -import org.session.libsession.utilities.GroupRecord; -import org.thoughtcrime.securesms.database.GroupDatabase; -import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.database.model.ThreadRecord; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; - -import java.util.ArrayList; -import java.util.List; - -import network.loki.messenger.R; - -/** - * CursorLoader that initializes a ContactsDatabase instance - * - * @author Jake McGinty - */ -public class ContactsCursorLoader extends CursorLoader { - private static final String TAG = ContactsCursorLoader.class.getSimpleName(); - - static final int NORMAL_TYPE = 0; - static final int PUSH_TYPE = 1; - static final int NEW_TYPE = 2; - static final int RECENT_TYPE = 3; - static final int DIVIDER_TYPE = 4; - - static final String CONTACT_TYPE_COLUMN = "contact_type"; - static final String LABEL_COLUMN = "label"; - static final String NUMBER_TYPE_COLUMN = "number_type"; - static final String NUMBER_COLUMN = "number"; - static final String NAME_COLUMN = "name"; - - public static final class DisplayMode { - public static final int FLAG_PUSH = 1; - public static final int FLAG_SMS = 1 << 1; - public static final int FLAG_GROUPS = 1 << 2; - public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_GROUPS; - } - - private static final String[] CONTACT_PROJECTION = new String[]{NAME_COLUMN, - NUMBER_COLUMN, - NUMBER_TYPE_COLUMN, - LABEL_COLUMN, - CONTACT_TYPE_COLUMN}; - - private static final int RECENT_CONVERSATION_MAX = 25; - - private final String filter; - private final int mode; - private final boolean recents; - - public ContactsCursorLoader(@NonNull Context context, int mode, String filter, boolean recents) - { - super(context); - - this.filter = filter; - this.mode = mode; - this.recents = recents; - } - - @Override - public Cursor loadInBackground() { - List cursorList = TextUtils.isEmpty(filter) ? getUnfilteredResults() - : getFilteredResults(); - if (cursorList.size() > 0) { - return new MergeCursor(cursorList.toArray(new Cursor[0])); - } - return null; - } - - private List getUnfilteredResults() { - ArrayList cursorList = new ArrayList<>(); - - if (recents) { - Cursor recentConversations = getRecentConversationsCursor(); - if (recentConversations.getCount() > 0) { - cursorList.add(getRecentsHeaderCursor()); - cursorList.add(recentConversations); - cursorList.add(getContactsHeaderCursor()); - } - } - cursorList.addAll(getContactsCursors()); - return cursorList; - } - - private List getFilteredResults() { - ArrayList cursorList = new ArrayList<>(); - - if (groupsEnabled(mode)) { - Cursor groups = getGroupsCursor(); - if (groups.getCount() > 0) { - List contacts = getContactsCursors(); - if (!isCursorListEmpty(contacts)) { - cursorList.add(getContactsHeaderCursor()); - cursorList.addAll(contacts); - cursorList.add(getGroupsHeaderCursor()); - } - cursorList.add(groups); - } else { - cursorList.addAll(getContactsCursors()); - } - } else { - cursorList.addAll(getContactsCursors()); - } - - return cursorList; - } - - private Cursor getRecentsHeaderCursor() { - MatrixCursor recentsHeader = new MatrixCursor(CONTACT_PROJECTION); - /* - recentsHeader.addRow(new Object[]{ getContext().getString(R.string.ContactsCursorLoader_recent_chats), - "", - ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, - "", - ContactsDatabase.DIVIDER_TYPE }); - */ - return recentsHeader; - } - - private Cursor getContactsHeaderCursor() { - MatrixCursor contactsHeader = new MatrixCursor(CONTACT_PROJECTION, 1); - /* - contactsHeader.addRow(new Object[] { getContext().getString(R.string.ContactsCursorLoader_contacts), - "", - ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, - "", - ContactsDatabase.DIVIDER_TYPE }); - */ - return contactsHeader; - } - - private Cursor getGroupsHeaderCursor() { - MatrixCursor groupHeader = new MatrixCursor(CONTACT_PROJECTION, 1); - groupHeader.addRow(new Object[]{ getContext().getString(R.string.conversationsGroups), - "", - ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, - "", - DIVIDER_TYPE }); - return groupHeader; - } - - - private Cursor getRecentConversationsCursor() { - ThreadDatabase threadDatabase = DatabaseComponent.get(getContext()).threadDatabase(); - - MatrixCursor recentConversations = new MatrixCursor(CONTACT_PROJECTION, RECENT_CONVERSATION_MAX); - try (Cursor rawConversations = threadDatabase.getRecentConversationList(RECENT_CONVERSATION_MAX)) { - ThreadDatabase.Reader reader = threadDatabase.readerFor(rawConversations); - ThreadRecord threadRecord; - while ((threadRecord = reader.getNext()) != null) { - recentConversations.addRow(new Object[] { threadRecord.getRecipient().toShortString(), - threadRecord.getRecipient().getAddress().serialize(), - ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, - "", - RECENT_TYPE }); - } - } - return recentConversations; - } - - private List getContactsCursors() { - return new ArrayList<>(2); - /* - if (!Permissions.hasAny(getContext(), Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) { - return cursorList; - } - - if (pushEnabled(mode)) { - cursorList.add(contactsDatabase.queryTextSecureContacts(filter)); - } - - if (pushEnabled(mode) && smsEnabled(mode)) { - cursorList.add(contactsDatabase.querySystemContacts(filter)); - } else if (smsEnabled(mode)) { - cursorList.add(filterNonPushContacts(contactsDatabase.querySystemContacts(filter))); - } - return cursorList; - */ - } - - private Cursor getGroupsCursor() { - MatrixCursor groupContacts = new MatrixCursor(CONTACT_PROJECTION); - try (GroupDatabase.Reader reader = DatabaseComponent.get(getContext()).groupDatabase().getGroupsFilteredByTitle(filter)) { - GroupRecord groupRecord; - while ((groupRecord = reader.getNext()) != null) { - groupContacts.addRow(new Object[] { groupRecord.getTitle(), - groupRecord.getEncodedId(), - ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM, - "", - NORMAL_TYPE }); - } - } - return groupContacts; - } - - private static boolean isCursorListEmpty(List list) { - int sum = 0; - for (Cursor cursor : list) { - sum += cursor.getCount(); - } - return sum == 0; - } - - private static boolean pushEnabled(int mode) { - return (mode & DisplayMode.FLAG_PUSH) > 0; - } - - private static boolean smsEnabled(int mode) { - return (mode & DisplayMode.FLAG_SMS) > 0; - } - - private static boolean groupsEnabled(int mode) { - return (mode & DisplayMode.FLAG_GROUPS) > 0; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsActivity.kt deleted file mode 100644 index 1ec5513ba1..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsActivity.kt +++ /dev/null @@ -1,98 +0,0 @@ -package org.thoughtcrime.securesms.contacts - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -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 -import com.bumptech.glide.Glide -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class SelectContactsActivity : PassphraseRequiredActionBarActivity(), LoaderManager.LoaderCallbacks> { - private lateinit var binding: ActivitySelectContactsBinding - private var members = listOf() - set(value) { field = value; selectContactsAdapter.members = value } - private lateinit var usersToExclude: Set - - private val selectContactsAdapter by lazy { - SelectContactsAdapter(this, Glide.with(this)) - } - - companion object { - val usersToExcludeKey = "usersToExcludeKey" - val emptyStateTextKey = "emptyStateTextKey" - val selectedContactsKey = "selectedContactsKey" - } - - // region Lifecycle - override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { - super.onCreate(savedInstanceState, isReady) - binding = ActivitySelectContactsBinding.inflate(layoutInflater) - setContentView(binding.root) - supportActionBar!!.title = resources.getString(R.string.membersInvite) - - usersToExclude = intent.getStringArrayExtra(usersToExcludeKey)?.toSet() ?: setOf() - val emptyStateText = intent.getStringExtra(emptyStateTextKey) - if (emptyStateText != null) { - binding.emptyStateMessageTextView.text = emptyStateText - } - - binding.recyclerView.adapter = selectContactsAdapter - binding.recyclerView.layoutManager = LinearLayoutManager(this) - - LoaderManager.getInstance(this).initLoader(0, null, this) - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_done, menu) - return members.isNotEmpty() - } - // endregion - - // region Updating - override fun onCreateLoader(id: Int, bundle: Bundle?): Loader> { - return SelectContactsLoader(this, usersToExclude) - } - - override fun onLoadFinished(loader: Loader>, members: List) { - update(members) - } - - override fun onLoaderReset(loader: Loader>) { - update(listOf()) - } - - private fun update(members: List) { - this.members = members - binding.recyclerView.visibility = if (members.isEmpty()) View.GONE else View.VISIBLE - binding.emptyStateContainer.visibility = if (members.isEmpty()) View.VISIBLE else View.GONE - invalidateOptionsMenu() - } - // endregion - - // region Interaction - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when(item.itemId) { - R.id.doneButton -> closeAndReturnSelected() - } - return super.onOptionsItemSelected(item) - } - - private fun closeAndReturnSelected() { - val selectedMembers = selectContactsAdapter.selectedMembers - val selectedContacts = selectedMembers.toTypedArray() - val intent = Intent() - intent.putExtra(selectedContactsKey, selectedContacts) - setResult(Activity.RESULT_OK, intent) - finish() - } - // endregion -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsAdapter.kt deleted file mode 100644 index 2a788f71a2..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsAdapter.kt +++ /dev/null @@ -1,73 +0,0 @@ -package org.thoughtcrime.securesms.contacts - -import android.content.Context -import androidx.recyclerview.widget.RecyclerView -import android.view.ViewGroup -import org.session.libsession.utilities.Address -import com.bumptech.glide.RequestManager -import org.session.libsession.utilities.recipients.Recipient - -class SelectContactsAdapter(private val context: Context, private val glide: RequestManager) : RecyclerView.Adapter() { - val selectedMembers = mutableSetOf() - var members = listOf() - set(value) { field = value; notifyDataSetChanged() } - - class ViewHolder(val view: UserView) : RecyclerView.ViewHolder(view) - - override fun getItemCount(): Int { - return members.size - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val view = UserView(context) - return ViewHolder(view) - } - - override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { - val member = members[position] - viewHolder.view.setOnClickListener { onMemberClick(member) } - val isSelected = selectedMembers.contains(member) - viewHolder.view.bind(Recipient.from( - context, - Address.fromSerialized(member), false), - glide, - UserView.ActionIndicator.Tick, - isSelected) - } - - override fun onBindViewHolder(viewHolder: ViewHolder, - position: Int, - payloads: MutableList) { - if (payloads.isNotEmpty()) { - // Because these updates can be batched, - // there can be multiple payloads for a single bind - when (payloads[0]) { - Payload.MEMBER_CLICKED -> { - val member = members[position] - val isSelected = selectedMembers.contains(member) - viewHolder.view.toggleCheckbox(isSelected) - } - } - } else { - // When payload list is empty, - // or we don't have logic to handle a given type, - // default to full bind: - this.onBindViewHolder(viewHolder, position) - } - } - - private fun onMemberClick(member: String) { - if (selectedMembers.contains(member)) { - selectedMembers.remove(member) - } else { - selectedMembers.add(member) - } - val index = members.indexOf(member) - notifyItemChanged(index, Payload.MEMBER_CLICKED) - } - - // define below the different events used to notify the adapter - enum class Payload { - MEMBER_CLICKED - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsLoader.kt deleted file mode 100644 index 5b9c5ad9d1..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsLoader.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.thoughtcrime.securesms.contacts - -import android.content.Context -import org.thoughtcrime.securesms.util.ContactUtilities -import org.thoughtcrime.securesms.util.AsyncLoader - -class SelectContactsLoader(context: Context, private val usersToExclude: Set) : AsyncLoader>(context) { - - override fun loadInBackground(): List { - val contacts = ContactUtilities.getAllContacts(context) - return contacts.filter { - !it.isGroupOrCommunityRecipient && !usersToExclude.contains(it.address.toString()) && it.hasApprovedMe() - }.map { - it.address.toString() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt deleted file mode 100644 index 95710d936e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ /dev/null @@ -1,95 +0,0 @@ -package org.thoughtcrime.securesms.contacts - -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.widget.LinearLayout -import network.loki.messenger.R -import network.loki.messenger.databinding.ViewUserBinding -import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import com.bumptech.glide.RequestManager -import org.session.libsession.messaging.MessagingModuleConfiguration - -class UserView : LinearLayout { - private lateinit var binding: ViewUserBinding - - enum class ActionIndicator { - None, - Menu, - Tick - } - - // 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 = ViewUserBinding.inflate(LayoutInflater.from(context), this, true) - } - // endregion - - // region Updating - fun bind(user: Recipient, glide: RequestManager, actionIndicator: ActionIndicator, isSelected: Boolean = false) { - val isLocalUser = user.isLocalNumber - - fun getUserDisplayName(publicKey: String): String { - if (isLocalUser) return context.getString(R.string.you) - - return MessagingModuleConfiguration.shared.storage.getContactNameWithAccountID(publicKey) - } - - val address = user.address.serialize() - binding.profilePictureView.update(user) - binding.actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24) - binding.nameTextView.text = if (user.isGroupOrCommunityRecipient) user.name else getUserDisplayName(address) - when (actionIndicator) { - ActionIndicator.None -> { - binding.actionIndicatorImageView.visibility = View.GONE - } - ActionIndicator.Menu -> { - binding.actionIndicatorImageView.visibility = View.VISIBLE - binding.actionIndicatorImageView.setImageResource(R.drawable.ic_more_horiz_white) - } - ActionIndicator.Tick -> { - binding.actionIndicatorImageView.visibility = View.VISIBLE - if (isSelected) { - binding.actionIndicatorImageView.setImageResource(R.drawable.padded_circle_accent) - } else { - binding.actionIndicatorImageView.setImageDrawable(null) - } - } - } - } - - fun toggleCheckbox(isSelected: Boolean = false) { - binding.actionIndicatorImageView.visibility = View.VISIBLE - if (isSelected) { - binding.actionIndicatorImageView.setImageResource(R.drawable.padded_circle_accent) - } else { - binding.actionIndicatorImageView.setImageDrawable(null) - } - } - - fun handleAdminStatus(isAdmin: Boolean){ - binding.adminIcon.visibility = if (isAdmin) View.VISIBLE else View.GONE - } - - fun unbind() { binding.profilePictureView.recycle() } - // endregion -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt deleted file mode 100644 index 93ec9d0d5c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt +++ /dev/null @@ -1,207 +0,0 @@ -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.AfterRead -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.StringSubstitutionConstants.TIME_KEY -import org.session.libsession.utilities.modifyLayoutParams -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.database.GroupDatabase -import org.thoughtcrime.securesms.database.LokiAPIDatabase -import org.thoughtcrime.securesms.ui.getSubbedString -import org.thoughtcrime.securesms.database.Storage -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 - @Inject lateinit var storage: Storage - - var delegate: ConversationActionBarDelegate? = null - - private val settingsAdapter = ConversationSettingsAdapter { setting -> - if (setting.settingType == ConversationSettingType.EXPIRATION) { - delegate?.onDisappearingMessagesClicked() - } - } - - val profilePictureView - get() = binding.profilePictureView - - 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.isGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size - ).let { LayoutParams(it, it) } - 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.noteToSelf) - updateSubtitle(recipient, openGroup, config) - - binding.conversationTitleContainer.modifyLayoutParams { - marginEnd = if (recipient.showCallMenu()) 0 else binding.profilePictureView.width - } - } - - fun updateSubtitle(recipient: Recipient, openGroup: OpenGroup? = null, config: ExpirationConfiguration? = null) { - val settings = mutableListOf() - - // Specify the disappearing messages subtitle if we should - if (config?.isEnabled == true) { - // Get the type of disappearing message and the abbreviated duration.. - val dmTypeString = when (config.expiryMode) { - is AfterRead -> R.string.disappearingMessagesDisappearAfterReadState - else -> R.string.disappearingMessagesDisappearAfterSendState - } - val durationAbbreviated = ExpirationUtil.getExpirationAbbreviatedDisplayValue(config.expiryMode.expirySeconds) - - // ..then substitute into the string.. - val subtitleTxt = context.getSubbedString(dmTypeString, - TIME_KEY to durationAbbreviated - ) - - // .. and apply to the subtitle. - settings += ConversationSetting( - subtitleTxt, - ConversationSettingType.EXPIRATION, - R.drawable.ic_timer, - resources.getString(R.string.AccessibilityId_disappearingMessagesDisappear) - ) - } - - if (recipient.isMuted) { - settings += ConversationSetting( - recipient.mutedUntil.takeUnless { it == Long.MAX_VALUE } - ?.let { - context.getString(R.string.notificationsMuted) - } - ?: context.getString(R.string.notificationsMuted), - ConversationSettingType.NOTIFICATION, - R.drawable.ic_outline_notifications_off_24 - ) - } - - if (recipient.isGroupOrCommunityRecipient) { - val title = if (recipient.isCommunityRecipient) { - val userCount = openGroup?.let { lokiApiDb.getUserCount(it.room, it.server) } ?: 0 - resources.getQuantityString(R.plurals.membersActive, userCount, userCount) - } else { - val userCount = if (recipient.isGroupV2Recipient) { - storage.getMembers(recipient.address.serialize()).size - } else { // legacy closed groups - groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size - } - resources.getQuantityString(R.plurals.members, userCount, userCount) - } - settings += ConversationSetting(title, ConversationSettingType.MEMBER_COUNT) - } - - settingsAdapter.submitList(settings) - binding.settingsTabLayout.isVisible = settings.size > 1 - } - - class ConversationSettingsAdapter( - private val settingsListener: (ConversationSetting) -> Unit - ) : ListAdapter(SettingsDiffer()) { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder { - val layoutInflater = LayoutInflater.from(parent.context) - return SettingViewHolder(ViewConversationSettingBinding.inflate(layoutInflater, parent, false)) - } - - override fun onBindViewHolder(holder: SettingViewHolder, position: Int) { - holder.bind(getItem(position), itemCount) { - settingsListener.invoke(it) - } - } - - class SettingViewHolder( - private val binding: ViewConversationSettingBinding - ): RecyclerView.ViewHolder(binding.root) { - - fun bind(setting: ConversationSetting, itemCount: Int, listener: (ConversationSetting) -> Unit) { - binding.root.setOnClickListener { listener.invoke(setting) } - binding.root.contentDescription = setting.contentDescription - binding.iconImageView.setImageResource(setting.iconResId) - binding.iconImageView.isVisible = setting.iconResId > 0 - binding.titleView.text = setting.title - binding.leftArrowImageView.isVisible = itemCount > 1 - binding.rightArrowImageView.isVisible = itemCount > 1 - } - } - - class SettingsDiffer: DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: ConversationSetting, newItem: ConversationSetting): Boolean = oldItem.settingType === newItem.settingType - override fun areContentsTheSame(oldItem: ConversationSetting, newItem: ConversationSetting): Boolean = oldItem == newItem - } - } -} - -fun interface ConversationActionBarDelegate { - fun onDisappearingMessagesClicked() -} - -data class ConversationSetting( - val title: String, - val settingType: ConversationSettingType, - val iconResId: Int = 0, - val contentDescription: String = "" -) - -enum class ConversationSettingType { - EXPIRATION, - MEMBER_COUNT, - NOTIFICATION -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt index b55f11c131..8fd103003d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt @@ -1,10 +1,7 @@ package org.thoughtcrime.securesms.conversation.disappearingmessages import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch +import androidx.annotation.StringRes import network.loki.messenger.R import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.database.StorageProtocol @@ -12,7 +9,6 @@ import org.session.libsession.messaging.groups.GroupManagerV2 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.snode.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.ExpirationUtil @@ -20,13 +16,12 @@ import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerP import org.session.libsession.utilities.StringSubstitutionConstants.DISAPPEARING_MESSAGES_TYPE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.getExpirationTypeDisplayValue +import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.AccountId -import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.ui.getSubbedCharSequence import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds class DisappearingMessages @Inject constructor( private val textSecurePreferences: TextSecurePreferences, @@ -40,13 +35,13 @@ class DisappearingMessages @Inject constructor( storage.setExpirationConfiguration(ExpirationConfiguration(threadId, mode, expiryChangeTimestampMs)) if (address.isGroupV2) { - groupManagerV2.setExpirationTimer(AccountId(address.serialize()), mode, expiryChangeTimestampMs) + groupManagerV2.setExpirationTimer(AccountId(address.toString()), mode, expiryChangeTimestampMs) } else { val message = ExpirationTimerUpdate(isGroup = isGroup).apply { expiryMode = mode sender = textSecurePreferences.getLocalNumber() isSenderSelf = true - recipient = address.serialize() + recipient = address.toString() sentTimestamp = expiryChangeTimestampMs } @@ -55,26 +50,60 @@ class DisappearingMessages @Inject constructor( } } - fun showFollowSettingDialog(context: Context, message: MessageRecord) = context.showSessionDialog { + fun showFollowSettingDialog(context: Context, + threadId: Long, + recipient: Recipient, + content: DisappearingMessageUpdate) = context.showSessionDialog { title(R.string.disappearingMessagesFollowSetting) - text(if (message.expiresIn == 0L) { - context.getText(R.string.disappearingMessagesFollowSettingOff) - } else { - context.getSubbedCharSequence(R.string.disappearingMessagesFollowSettingOn, - TIME_KEY to ExpirationUtil.getExpirationDisplayValue(context, message.expiresIn.milliseconds), - DISAPPEARING_MESSAGES_TYPE_KEY to context.getExpirationTypeDisplayValue(message.isNotDisappearAfterRead)) - }) + + val bodyText: CharSequence + @StringRes + val dangerButtonText: Int + @StringRes + val dangerButtonContentDescription: Int + + when (content.expiryMode) { + ExpiryMode.NONE -> { + bodyText = context.getText(R.string.disappearingMessagesFollowSettingOff) + dangerButtonText = R.string.confirm + dangerButtonContentDescription = R.string.AccessibilityId_confirm + } + is ExpiryMode.AfterSend -> { + bodyText = context.getSubbedCharSequence( + R.string.disappearingMessagesFollowSettingOn, + TIME_KEY to ExpirationUtil.getExpirationDisplayValue( + context, + content.expiryMode.duration + ), + DISAPPEARING_MESSAGES_TYPE_KEY to context.getString(R.string.disappearingMessagesTypeSent) + ) + + dangerButtonText = R.string.set + dangerButtonContentDescription = R.string.AccessibilityId_setButton + } + is ExpiryMode.AfterRead -> { + bodyText = context.getSubbedCharSequence( + R.string.disappearingMessagesFollowSettingOn, + TIME_KEY to ExpirationUtil.getExpirationDisplayValue( + context, + content.expiryMode.duration + ), + DISAPPEARING_MESSAGES_TYPE_KEY to context.getString(R.string.disappearingMessagesTypeRead) + ) + + dangerButtonText = R.string.set + dangerButtonContentDescription = R.string.AccessibilityId_setButton + } + } + + text(bodyText) dangerButton( - text = if (message.expiresIn == 0L) R.string.confirm else R.string.set, - contentDescriptionRes = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_setButton + text = dangerButtonText, + contentDescriptionRes = dangerButtonContentDescription, ) { - set(message.threadId, message.recipient.address, message.expiryMode, message.recipient.isGroupRecipient) + set(threadId, recipient.address, content.expiryMode, recipient.isGroupRecipient) } 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 index 2716a3d883..33b1bc2b26 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt @@ -1,92 +1,38 @@ 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 androidx.hilt.navigation.compose.hiltViewModel 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.setThemedContent -import javax.inject.Inject +import network.loki.messenger.BuildConfig +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.thoughtcrime.securesms.FullComposeScreenLockActivity +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.DisappearingMessagesScreen @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 +class DisappearingMessagesActivity: FullComposeScreenLockActivity() { 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.setThemedContent { DisappearingMessagesScreen() } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.event.collect { - when (it) { - Event.SUCCESS -> finish() - Event.FAIL -> showToast(getString(R.string.communityErrorDescription)) - } - } - } - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.state.collect { - supportActionBar?.subtitle = it.subtitle(this@DisappearingMessagesActivity) - } + @Composable + override fun ComposeContent() { + val viewModel: DisappearingMessagesViewModel = + hiltViewModel { factory -> + factory.create( + threadId = threadId, + isNewConfigEnabled = ExpirationConfiguration.isNewConfigEnabled, + showDebugOptions = BuildConfig.BUILD_TYPE != "release" + ) } - } - } - - private fun showToast(message: String) { - Toast.makeText(this, message, Toast.LENGTH_SHORT).show() - } - private fun setUpToolbar() { - setSupportActionBar(binding.searchToolbar) - supportActionBar?.apply { - title = getString(R.string.disappearingMessages) - setDisplayHomeAsUpEnabled(true) - setHomeButtonEnabled(true) - } + DisappearingMessagesScreen( + viewModel = viewModel, + onBack = { finish() }, + ) } companion object { const val THREAD_ID = "thread_id" } - - @Composable - fun DisappearingMessagesScreen() { - val uiState by viewModel.uiState.collectAsState(UiState()) - 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 index 3a48007da5..1d25ee253b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt @@ -1,46 +1,45 @@ package org.thoughtcrime.securesms.conversation.disappearingmessages -import android.app.Application -import androidx.lifecycle.AndroidViewModel +import android.content.Context +import android.widget.Toast import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.channels.Channel +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext 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.R import network.loki.messenger.libsession_util.util.ExpiryMode -import org.session.libsession.messaging.messages.ExpirationConfiguration 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.conversation.v2.settings.ConversationSettingsDestination import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.ui.UINavigator -class DisappearingMessagesViewModel( - private val threadId: Long, - private val application: Application, +@HiltViewModel(assistedFactory = DisappearingMessagesViewModel.Factory::class) +class DisappearingMessagesViewModel @AssistedInject constructor( + @Assisted("threadId") private val threadId: Long, + @Assisted("isNewConfigEnabled") private val isNewConfigEnabled: Boolean, + @Assisted("showDebugOptions") private val showDebugOptions: Boolean, + @ApplicationContext private val context: Context, private val textSecurePreferences: TextSecurePreferences, private val disappearingMessages: DisappearingMessages, private val threadDb: ThreadDatabase, private val groupDb: GroupDatabase, private val storage: Storage, - isNewConfigEnabled: Boolean, - showDebugOptions: Boolean -) : AndroidViewModel(application), ExpiryCallbacks { - - private val _event = Channel() - val event = _event.receiveAsFlow() + private val navigator: UINavigator, +) : ViewModel() { private val _state = MutableStateFlow( State( @@ -62,12 +61,12 @@ class DisappearingMessagesViewModel( val isAdmin = when { recipient.isGroupV2Recipient -> { // Handle the new closed group functionality - storage.getMembers(recipient.address.serialize()).any { it.accountIdString() == textSecurePreferences.getLocalNumber() && it.admin } + storage.getMembers(recipient.address.toString()).any { it.accountId() == textSecurePreferences.getLocalNumber() && it.admin } } recipient.isLegacyGroupRecipient -> { val groupRecord = groupDb.getGroup(recipient.address.toGroupString()).orNull() // Handle as legacy group - groupRecord?.admins?.any{ it.serialize() == textSecurePreferences.getLocalNumber() } == true + groupRecord?.admins?.any{ it.toString() == textSecurePreferences.getLocalNumber() } == true } else -> !recipient.isGroupOrCommunityRecipient } @@ -76,7 +75,7 @@ class DisappearingMessagesViewModel( it.copy( address = recipient.address, isGroup = recipient.isGroupRecipient, - isNoteToSelf = recipient.address.serialize() == textSecurePreferences.getLocalNumber(), + isNoteToSelf = recipient.address.toString() == textSecurePreferences.getLocalNumber(), isSelfAdmin = isAdmin, expiryMode = expiryMode, persistedMode = expiryMode @@ -85,48 +84,30 @@ class DisappearingMessagesViewModel( } } - override fun setValue(value: ExpiryMode) = _state.update { it.copy(expiryMode = value) } + fun onOptionSelected(value: ExpiryMode) = _state.update { it.copy(expiryMode = value) } - override fun onSetClick() = viewModelScope.launch { + fun onSetClicked() = viewModelScope.launch { val state = _state.value val mode = state.expiryMode val address = state.address if (address == null || mode == null) { - _event.send(Event.FAIL) + Toast.makeText( + context, context.getString(R.string.communityErrorDescription), Toast.LENGTH_SHORT + ).show() return@launch } disappearingMessages.set(threadId, address, mode, state.isGroup) - _event.send(Event.SUCCESS) - } - - @dagger.assisted.AssistedFactory - interface AssistedFactory { - fun create(threadId: Long): Factory + navigator.navigateUp() } - @Suppress("UNCHECKED_CAST") - class Factory @AssistedInject constructor( - @Assisted private val threadId: Long, - private val application: Application, - private val textSecurePreferences: TextSecurePreferences, - private val disappearingMessages: DisappearingMessages, - private val threadDb: ThreadDatabase, - private val groupDb: GroupDatabase, - private val storage: Storage - ) : ViewModelProvider.Factory { - - override fun create(modelClass: Class): T = DisappearingMessagesViewModel( - threadId, - application, - textSecurePreferences, - disappearingMessages, - threadDb, - groupDb, - storage, - ExpirationConfiguration.isNewConfigEnabled, - BuildConfig.DEBUG - ) as T + @AssistedFactory + interface Factory { + fun create( + @Assisted("threadId") threadId: Long, + @Assisted("isNewConfigEnabled") isNewConfigEnabled: Boolean, + @Assisted("showDebugOptions") showDebugOptions: Boolean + ): DisappearingMessagesViewModel } } 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 index eb4114ab54..54555461de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/State.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/State.kt @@ -9,10 +9,6 @@ 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, 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 index d4b3b0602a..1c9d75282c 100644 --- 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 @@ -17,7 +17,9 @@ fun State.toUiState() = UiState( timeOptions()?.let { ExpiryOptionsCardData(GetString(R.string.disappearingMessagesTimer), it) } ), showGroupFooter = isGroup && isNewConfigEnabled, - showSetButton = isSelfAdmin + showSetButton = isSelfAdmin, + disableSetButton = persistedMode == expiryMode, + subtitle = subtitle ) private fun State.typeOptions(): List? = if (typeOptionsHidden) null else { @@ -58,7 +60,7 @@ private fun State.typeOption( value = type.defaultMode(persistedMode), title = GetString(type.title), subtitle = type.subtitle?.let(::GetString), - contentDescription = GetString(type.contentDescription), + qaTag = GetString(type.contentDescription), selected = expiryType == type, enabled = enabled ) @@ -93,7 +95,7 @@ private fun State.timeOption( value = mode, title = title, subtitle = subtitle, - contentDescription = title, + qaTag = 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 index 8375a8a650..fb58e0a48f 100644 --- 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 @@ -2,11 +2,15 @@ package org.thoughtcrime.securesms.conversation.disappearingmessages.ui import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -17,67 +21,105 @@ import androidx.compose.ui.text.style.TextAlign import network.loki.messenger.R import network.loki.messenger.libsession_util.util.ExpiryMode import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox -import org.thoughtcrime.securesms.ui.Callbacks -import org.thoughtcrime.securesms.ui.NoOpCallbacks import org.thoughtcrime.securesms.ui.OptionsCard import org.thoughtcrime.securesms.ui.RadioOption -import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton -import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.components.AppBarBackIcon +import org.thoughtcrime.securesms.ui.components.AppBarText +import org.thoughtcrime.securesms.ui.components.AccentOutlineButton +import org.thoughtcrime.securesms.ui.components.appBarColors +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType -typealias ExpiryCallbacks = Callbacks typealias ExpiryRadioOption = RadioOption +@OptIn(ExperimentalMaterial3Api::class) @Composable fun DisappearingMessages( state: UiState, - modifier: Modifier = Modifier, - callbacks: ExpiryCallbacks = NoOpCallbacks + onOptionSelected: (ExpiryMode) -> Unit, + onSetClicked: () -> Unit, + onBack: () -> Unit ) { - Column(modifier = modifier.padding(horizontal = LocalDimensions.current.spacing)) { - BottomFadingEdgeBox(modifier = Modifier.weight(1f)) { bottomContentPadding -> - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(vertical = LocalDimensions.current.spacing), - ) { - state.cards.forEachIndexed { index, option -> - OptionsCard(option, callbacks) + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + AppBarText( + title = stringResource(R.string.disappearingMessages), + singleLine = true + ) - // add spacing if not the last item - if(index != state.cards.lastIndex){ - Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + if (state.subtitle?.string()?.isEmpty() == false) { + Text( + modifier = Modifier.padding(horizontal = LocalDimensions.current.xlargeSpacing), + text = state.subtitle.string(), + textAlign = TextAlign.Center, + color = LocalColors.current.text, + style = LocalType.current.extraSmall + ) + } } + }, + navigationIcon = { + AppBarBackIcon(onBack = onBack) + }, + colors = appBarColors(LocalColors.current.background) + ) + }, + ) { paddings -> + Column( + modifier = Modifier.padding(paddings).consumeWindowInsets(paddings) + ) { + BottomFadingEdgeBox(modifier = Modifier.weight(1f)) { bottomContentPadding -> + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = LocalDimensions.current.spacing) + ) { + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + state.cards.forEachIndexed { index, option -> + OptionsCard(option, onOptionSelected) + + // add spacing if not the last item + if (index != state.cards.lastIndex) { + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + } + } + + if (state.showGroupFooter) Text( + text = stringResource(R.string.disappearingMessagesDescription) + + "\n" + + stringResource(R.string.disappearingMessagesOnlyAdmins), + style = LocalType.current.extraSmall, + fontWeight = FontWeight(400), + color = LocalColors.current.textSecondary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = LocalDimensions.current.xsSpacing) + ) + + Spacer(modifier = Modifier.height(bottomContentPadding)) } + } - if (state.showGroupFooter) Text( - text = stringResource(R.string.disappearingMessagesDescription) + - "\n" + - stringResource(R.string.disappearingMessagesOnlyAdmins), - style = LocalType.current.extraSmall, - fontWeight = FontWeight(400), - color = LocalColors.current.textSecondary, - textAlign = TextAlign.Center, + if (state.showSetButton) { + AccentOutlineButton( + stringResource(R.string.set), modifier = Modifier - .fillMaxWidth() - .padding(top = LocalDimensions.current.xsSpacing) + .qaTag(R.string.AccessibilityId_setButton) + .align(Alignment.CenterHorizontally) + .padding(bottom = LocalDimensions.current.spacing), + enabled = !state.disableSetButton, + onClick = onSetClicked ) - - Spacer(modifier = Modifier.height(bottomContentPadding)) } } - - if (state.showSetButton) { - PrimaryOutlineButton( - stringResource(R.string.set), - modifier = Modifier - .contentDescription(R.string.AccessibilityId_setButton) - .align(Alignment.CenterHorizontally) - .padding(bottom = LocalDimensions.current.spacing), - 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 index 48d6539d8a..7def693e9d 100644 --- 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 @@ -21,7 +21,10 @@ fun PreviewStates( ) { PreviewTheme { DisappearingMessages( - state.toUiState() + state.toUiState(), + onOptionSelected = {}, + onSetClicked = {}, + onBack = {}, ) } } @@ -53,7 +56,9 @@ fun PreviewThemes( PreviewTheme(colors) { DisappearingMessages( State(expiryMode = ExpiryMode.AfterSend(43200)).toUiState(), - modifier = Modifier.size(400.dp, 600.dp) + onOptionSelected = {}, + onSetClicked = {}, + onBack = {} ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesScreen.kt new file mode 100644 index 0000000000..9309113fad --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesScreen.kt @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesViewModel + +@Composable +fun DisappearingMessagesScreen( + viewModel: DisappearingMessagesViewModel, + onBack: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsState(UiState()) + DisappearingMessages( + state = uiState, + onOptionSelected = viewModel::onOptionSelected, + onSetClicked = viewModel::onSetClicked, + onBack = onBack + ) +} \ No newline at end of file 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 index 47159571f8..92959849d1 100644 --- 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 @@ -1,32 +1,29 @@ 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 +import org.thoughtcrime.securesms.ui.OptionsCardData typealias ExpiryOptionsCardData = OptionsCardData data class UiState( val cards: List = emptyList(), val showGroupFooter: Boolean = false, - val showSetButton: Boolean = true + val showSetButton: Boolean = true, + val disableSetButton: Boolean = false, + val subtitle: GetString? = null, ) { constructor( vararg cards: ExpiryOptionsCardData, showGroupFooter: Boolean = false, showSetButton: Boolean = true, + disableSetButton: Boolean = false, + subtitle: GetString? = null, ): this( - cards.asList(), - showGroupFooter, - showSetButton + cards = cards.asList(), + showGroupFooter = showGroupFooter, + showSetButton = showSetButton, + disableSetButton = disableSetButton, + subtitle = subtitle, ) } - -data class OptionsCardData( - val title: GetString, - val options: List> -) { - constructor(title: GetString, vararg options: RadioOption): this(title, options.asList()) - constructor(@StringRes title: Int, vararg options: RadioOption): this(GetString(title), options.asList()) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationDelegate.kt deleted file mode 100644 index 04b16b64ef..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationDelegate.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.thoughtcrime.securesms.conversation.start - -interface StartConversationDelegate { - fun onNewMessageSelected() - fun onCreateGroupSelected() - fun onJoinCommunitySelected() - fun onContactSelected(address: String) - fun onDialogBackPressed() - fun onDialogClosePressed() - fun onInviteFriend() -} - -object NullStartConversationDelegate: StartConversationDelegate { - override fun onNewMessageSelected() {} - override fun onCreateGroupSelected() {} - override fun onJoinCommunitySelected() {} - override fun onContactSelected(address: String) {} - override fun onDialogBackPressed() {} - override fun onDialogClosePressed() {} - override fun onInviteFriend() {} -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt deleted file mode 100644 index 3fb861defe..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt +++ /dev/null @@ -1,119 +0,0 @@ -package org.thoughtcrime.securesms.conversation.start - -import android.app.Dialog -import android.content.Intent -import android.content.res.Resources -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams -import androidx.fragment.app.Fragment -import androidx.fragment.app.commit -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.R -import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.modifyLayoutParams -import org.thoughtcrime.securesms.conversation.start.home.StartConversationHomeFragment -import org.thoughtcrime.securesms.conversation.start.invitefriend.InviteFriendFragment -import org.thoughtcrime.securesms.conversation.start.newmessage.NewMessageFragment -import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.groups.CreateGroupFragment -import org.thoughtcrime.securesms.groups.JoinCommunityFragment -import org.thoughtcrime.securesms.groups.legacy.CreateLegacyGroupFragment -import javax.inject.Inject - -@AndroidEntryPoint -class StartConversationFragment : BottomSheetDialogFragment(), StartConversationDelegate { - - companion object{ - const val PEEK_RATIO = 0.94f - } - - private val defaultPeekHeight: Int by lazy { (Resources.getSystem().displayMetrics.heightPixels * PEEK_RATIO).toInt() } - - @Inject - lateinit var deprecationManager: LegacyGroupDeprecationManager - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_new_conversation, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - replaceFragment( - fragment = StartConversationHomeFragment().also { it.delegate.value = this }, - fragmentKey = StartConversationHomeFragment::class.java.simpleName - ) - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = - BottomSheetDialog(requireContext(), R.style.Theme_Session_BottomSheet).apply { - setOnShowListener { _ -> - findViewById(com.google.android.material.R.id.design_bottom_sheet)?.apply { - modifyLayoutParams { height = defaultPeekHeight } - }?.let { BottomSheetBehavior.from(it) }?.apply { - skipCollapsed = true - state = BottomSheetBehavior.STATE_EXPANDED - } - } - } - - - override fun onNewMessageSelected() { - replaceFragment(NewMessageFragment().also { it.delegate = this }) - } - - override fun onCreateGroupSelected() { - val fragment = if (deprecationManager.deprecationState.value == LegacyGroupDeprecationManager.DeprecationState.NOT_DEPRECATING) { - CreateLegacyGroupFragment() - } else { - CreateGroupFragment() - } - - replaceFragment(fragment) - } - - override fun onJoinCommunitySelected() { - replaceFragment(JoinCommunityFragment().also { it.delegate = this }) - } - - override fun onContactSelected(address: String) { - val intent = Intent(requireContext(), ConversationActivityV2::class.java) - intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(address)) - requireContext().startActivity(intent) - requireActivity().overridePendingTransition(R.anim.slide_from_bottom, R.anim.fade_scale_out) - } - - override fun onDialogBackPressed() { - childFragmentManager.popBackStack() - } - - override fun onInviteFriend() { - replaceFragment(InviteFriendFragment().also { it.delegate = this }) - } - - override fun onDialogClosePressed() { - dismiss() - } - - private fun replaceFragment(fragment: Fragment, fragmentKey: String? = null) { - childFragmentManager.commit { - setCustomAnimations( - R.anim.slide_from_right, - R.anim.fade_scale_out, - 0, - R.anim.slide_to_right - ) - replace(R.id.new_conversation_fragment_container, fragment) - addToBackStack(fragmentKey) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt deleted file mode 100644 index f65dce4974..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversation.kt +++ /dev/null @@ -1,126 +0,0 @@ -package org.thoughtcrime.securesms.conversation.start.home - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.rememberNestedScrollInteropConnection -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import network.loki.messenger.R -import org.thoughtcrime.securesms.conversation.start.NullStartConversationDelegate -import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate -import org.thoughtcrime.securesms.ui.Divider -import org.thoughtcrime.securesms.ui.ItemButton -import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon -import org.thoughtcrime.securesms.ui.components.BasicAppBar -import org.thoughtcrime.securesms.ui.components.QrImage -import org.thoughtcrime.securesms.ui.contentDescription -import org.thoughtcrime.securesms.ui.theme.LocalColors -import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.LocalType -import org.thoughtcrime.securesms.ui.theme.PreviewTheme -import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider -import org.thoughtcrime.securesms.ui.theme.ThemeColors - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun StartConversationScreen( - accountId: String, - delegate: StartConversationDelegate -) { - val context = LocalContext.current - - Column(modifier = Modifier.background( - LocalColors.current.backgroundSecondary, - shape = MaterialTheme.shapes.small - )) { - BasicAppBar( - title = stringResource(R.string.conversationsStart), - backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container - actions = { AppBarCloseIcon(onClose = delegate::onDialogClosePressed) } - ) - Surface( - modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()), - color = LocalColors.current.backgroundSecondary - ) { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()) - ) { - val newMessageTitleTxt:String = context.resources.getQuantityString(R.plurals.messageNew, 1, 1) - ItemButton( - text = newMessageTitleTxt, - icon = R.drawable.ic_message, - modifier = Modifier.contentDescription(R.string.AccessibilityId_messageNew), - onClick = delegate::onNewMessageSelected) - Divider(startIndent = LocalDimensions.current.minItemButtonHeight) - ItemButton( - textId = R.string.groupCreate, - icon = R.drawable.ic_group, - modifier = Modifier.contentDescription(R.string.AccessibilityId_groupCreate), - onClick = delegate::onCreateGroupSelected - ) - Divider(startIndent = LocalDimensions.current.minItemButtonHeight) - ItemButton( - textId = R.string.communityJoin, - icon = R.drawable.ic_globe, - modifier = Modifier.contentDescription(R.string.AccessibilityId_communityJoin), - onClick = delegate::onJoinCommunitySelected - ) - Divider(startIndent = LocalDimensions.current.minItemButtonHeight) - ItemButton( - textId = R.string.sessionInviteAFriend, - icon = R.drawable.ic_invite_friend, - Modifier.contentDescription(R.string.AccessibilityId_sessionInviteAFriendButton), - onClick = delegate::onInviteFriend - ) - Column( - modifier = Modifier - .padding(horizontal = LocalDimensions.current.spacing) - .padding(top = LocalDimensions.current.spacing) - .padding(bottom = LocalDimensions.current.spacing) - ) { - Text(stringResource(R.string.accountIdYours), style = LocalType.current.xl) - Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) - Text( - text = stringResource(R.string.qrYoursDescription), - color = LocalColors.current.textSecondary, - style = LocalType.current.small - ) - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - QrImage( - string = accountId, - Modifier.contentDescription(R.string.AccessibilityId_qrCode), - icon = R.drawable.session - ) - } - } - } - } -} - -@Preview -@Composable -private fun PreviewStartConversationScreen( - @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors -) { - PreviewTheme(colors) { - StartConversationScreen( - accountId = "059287129387123", - NullStartConversationDelegate - ) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversationFragment.kt deleted file mode 100644 index 1934c5e5bb..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/home/StartConversationFragment.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.thoughtcrime.securesms.conversation.start.home - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.fragment.app.Fragment -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.MutableStateFlow -import org.session.libsession.utilities.TextSecurePreferences -import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate -import org.thoughtcrime.securesms.conversation.start.NullStartConversationDelegate -import org.thoughtcrime.securesms.ui.createThemedComposeView -import javax.inject.Inject - -@AndroidEntryPoint -class StartConversationHomeFragment : Fragment() { - - @Inject - lateinit var textSecurePreferences: TextSecurePreferences - - var delegate = MutableStateFlow(NullStartConversationDelegate) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = createThemedComposeView { - StartConversationScreen( - accountId = TextSecurePreferences.getLocalNumber(requireContext())!!, - delegate = delegate.collectAsState().value - ) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt deleted file mode 100644 index bae328c78e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt +++ /dev/null @@ -1,109 +0,0 @@ -package org.thoughtcrime.securesms.conversation.start.invitefriend - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import com.squareup.phrase.Phrase -import network.loki.messenger.R -import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY -import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon -import org.thoughtcrime.securesms.ui.components.BackAppBar -import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY -import org.thoughtcrime.securesms.ui.components.SlimOutlineButton -import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton -import org.thoughtcrime.securesms.ui.components.border -import org.thoughtcrime.securesms.ui.contentDescription -import org.thoughtcrime.securesms.ui.theme.LocalColors -import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.LocalType -import org.thoughtcrime.securesms.ui.theme.PreviewTheme - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun InviteFriend( - accountId: String, - onBack: () -> Unit = {}, - onClose: () -> Unit = {}, - copyPublicKey: () -> Unit = {}, - sendInvitation: () -> Unit = {}, -) { - Column(modifier = Modifier.background( - LocalColors.current.backgroundSecondary, - shape = MaterialTheme.shapes.small - )) { - BackAppBar( - title = stringResource(R.string.sessionInviteAFriend), - backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container - onBack = onBack, - actions = { AppBarCloseIcon(onClose = onClose) } - ) - Column( - modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing) - .padding(top = LocalDimensions.current.spacing), - ) { - Text( - accountId, - modifier = Modifier - .contentDescription(R.string.AccessibilityId_shareAccountId) - .fillMaxWidth() - .border() - .padding(LocalDimensions.current.spacing), - textAlign = TextAlign.Center, - style = LocalType.current.base - ) - - Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) - - Text( - stringResource(R.string.shareAccountIdDescription).let { txt -> - val c = LocalContext.current - Phrase.from(txt).put(APP_NAME_KEY, c.getString(R.string.app_name)).format().toString() - }, - textAlign = TextAlign.Center, - style = LocalType.current.small, - color = LocalColors.current.textSecondary, - modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing) - ) - - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - - Row(horizontalArrangement = spacedBy(LocalDimensions.current.smallSpacing)) { - SlimOutlineButton( - stringResource(R.string.share), - modifier = Modifier - .weight(1f) - .contentDescription("Share button"), - onClick = sendInvitation - ) - - SlimOutlineCopyButton( - modifier = Modifier.weight(1f), - onClick = copyPublicKey - ) - } - } - } -} - -@Preview -@Composable -private fun PreviewInviteFriend() { - PreviewTheme { - InviteFriend("050000000") - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriendFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriendFragment.kt deleted file mode 100644 index 4239a7a067..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriendFragment.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.thoughtcrime.securesms.conversation.start.invitefriend - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.LocalContext -import androidx.fragment.app.Fragment -import dagger.hilt.android.AndroidEntryPoint -import org.session.libsession.utilities.TextSecurePreferences -import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate -import org.thoughtcrime.securesms.preferences.copyPublicKey -import org.thoughtcrime.securesms.preferences.sendInvitationToUseSession -import org.thoughtcrime.securesms.ui.createThemedComposeView - -@AndroidEntryPoint -class InviteFriendFragment : Fragment() { - lateinit var delegate: StartConversationDelegate - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View = createThemedComposeView { - InviteFriend( - TextSecurePreferences.getLocalNumber(LocalContext.current)!!, - onBack = { delegate.onDialogBackPressed() }, - onClose = { delegate.onDialogClosePressed() }, - copyPublicKey = requireContext()::copyPublicKey, - sendInvitation = requireContext()::sendInvitationToUseSession, - ) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/Callbacks.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/Callbacks.kt deleted file mode 100644 index 02d39b1327..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/Callbacks.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.thoughtcrime.securesms.conversation.start.newmessage - -internal interface Callbacks { - fun onChange(value: String) {} - fun onContinue() {} - fun onScanQrCode(value: String) {} -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt deleted file mode 100644 index e825d41280..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt +++ /dev/null @@ -1,224 +0,0 @@ -package org.thoughtcrime.securesms.conversation.start.newmessage - -import android.graphics.Rect -import android.os.Build -import android.view.ViewTreeObserver -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.platform.rememberNestedScrollInteropConnection -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow -import network.loki.messenger.R -import org.thoughtcrime.securesms.conversation.start.StartConversationFragment.Companion.PEEK_RATIO -import org.thoughtcrime.securesms.ui.LoadingArcOr -import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon -import org.thoughtcrime.securesms.ui.components.BackAppBar -import org.thoughtcrime.securesms.ui.components.BorderlessButtonWithIcon -import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton -import org.thoughtcrime.securesms.ui.components.QRScannerScreen -import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField -import org.thoughtcrime.securesms.ui.components.SessionTabRow -import org.thoughtcrime.securesms.ui.contentDescription -import org.thoughtcrime.securesms.ui.qaTag -import org.thoughtcrime.securesms.ui.theme.LocalColors -import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.LocalType -import org.thoughtcrime.securesms.ui.theme.PreviewTheme -import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider -import org.thoughtcrime.securesms.ui.theme.ThemeColors -import kotlin.math.max - -private val TITLES = listOf(R.string.accountIdEnter, R.string.qrScan) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun NewMessage( - state: State, - qrErrors: Flow = emptyFlow(), - callbacks: Callbacks = object: Callbacks {}, - onClose: () -> Unit = {}, - onBack: () -> Unit = {}, - onHelp: () -> Unit = {}, -) { - val pagerState = rememberPagerState { TITLES.size } - - Column(modifier = Modifier.background( - LocalColors.current.backgroundSecondary, - shape = MaterialTheme.shapes.small - )) { - // `messageNew` is now a plurals string so get the singular version - val context = LocalContext.current - val newMessageTitleTxt:String = context.resources.getQuantityString(R.plurals.messageNew, 1, 1) - - BackAppBar( - title = newMessageTitleTxt, - backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container - onBack = onBack, - actions = { AppBarCloseIcon(onClose = onClose) } - ) - SessionTabRow(pagerState, TITLES) - HorizontalPager(pagerState) { - when (TITLES[it]) { - R.string.accountIdEnter -> EnterAccountId(state, callbacks, onHelp) - R.string.qrScan -> QRScannerScreen(qrErrors, onScan = callbacks::onScanQrCode) - } - } - } -} - -@Composable -private fun EnterAccountId( - state: State, - callbacks: Callbacks, - onHelp: () -> Unit = {} -) { - // the scaffold is required to provide the contentPadding. That contentPadding is needed - // to properly handle the ime padding. - Scaffold() { contentPadding -> - // we need this extra surface to handle nested scrolling properly, - // because this scrollable component is inside a bottomSheet dialog which is itself scrollable - Surface( - modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()), - color = LocalColors.current.backgroundSecondary - ) { - - var accountModifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - - // There is a known issue with the ime padding on android versions below 30 - // So on these older versions we need to resort to some manual padding based on the visible height - // when the keyboard is up - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - val keyboardHeight by keyboardHeight() - accountModifier = accountModifier.padding(bottom = keyboardHeight) - } else { - accountModifier = accountModifier - .consumeWindowInsets(contentPadding) - .imePadding() - } - - Column( - modifier = accountModifier - ) { - Column( - modifier = Modifier.padding(vertical = LocalDimensions.current.spacing), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - SessionOutlinedTextField( - text = state.newMessageIdOrOns, - modifier = Modifier - .padding(horizontal = LocalDimensions.current.spacing) - .qaTag(stringResource(R.string.AccessibilityId_sessionIdInput)), - placeholder = stringResource(R.string.accountIdOrOnsEnter), - onChange = callbacks::onChange, - onContinue = callbacks::onContinue, - error = state.error?.string(), - isTextErrorColor = state.isTextErrorColor - ) - - Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) - - BorderlessButtonWithIcon( - text = stringResource(R.string.messageNewDescriptionMobile), - modifier = Modifier - .contentDescription(R.string.AccessibilityId_messageNewDescriptionMobile) - .padding(horizontal = LocalDimensions.current.mediumSpacing) - .fillMaxWidth(), - style = LocalType.current.small, - color = LocalColors.current.textSecondary, - iconRes = R.drawable.ic_circle_question_mark, - onClick = onHelp - ) - } - - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - Spacer(Modifier.weight(2f)) - - PrimaryOutlineButton( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(horizontal = LocalDimensions.current.xlargeSpacing) - .padding(bottom = LocalDimensions.current.smallSpacing) - .fillMaxWidth() - .contentDescription(R.string.next), - enabled = state.isNextButtonEnabled, - onClick = callbacks::onContinue - ) { - LoadingArcOr(state.loading) { - Text(stringResource(R.string.next)) - } - } - } - } - } -} - -@Composable -fun keyboardHeight(): MutableState { - val view = LocalView.current - var keyboardHeight = remember { mutableStateOf(0.dp) } - val density = LocalDensity.current - - DisposableEffect(view) { - val listener = ViewTreeObserver.OnGlobalLayoutListener { - val rect = Rect() - view.getWindowVisibleDisplayFrame(rect) - val screenHeight = view.rootView.height * PEEK_RATIO - val keypadHeightPx = max( screenHeight - rect.bottom, 0f) - - keyboardHeight.value = with(density) { keypadHeightPx.toDp() } - } - - view.viewTreeObserver.addOnGlobalLayoutListener(listener) - onDispose { - view.viewTreeObserver.removeOnGlobalLayoutListener(listener) - } - } - - return keyboardHeight -} - -@Preview -@Composable -private fun PreviewNewMessage( - @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors -) { - PreviewTheme(colors) { - NewMessage(State("z")) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageFragment.kt deleted file mode 100644 index 8c383fe1a9..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageFragment.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.thoughtcrime.securesms.conversation.start.newmessage - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.launch -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate -import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.openUrl -import org.thoughtcrime.securesms.ui.createThemedComposeView - -class NewMessageFragment : Fragment() { - private val viewModel: NewMessageViewModel by viewModels() - - lateinit var delegate: StartConversationDelegate - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - lifecycleScope.launch { - viewModel.success.collect { - createPrivateChat(it.publicKey) - } - } - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View = createThemedComposeView { - val uiState by viewModel.state.collectAsState(State()) - NewMessage( - uiState, - viewModel.qrErrors, - viewModel, - onClose = { delegate.onDialogClosePressed() }, - onBack = { delegate.onDialogBackPressed() }, - onHelp = { requireContext().openUrl("https://sessionapp.zendesk.com/hc/en-us/articles/4439132747033-How-do-Account-ID-usernames-work") } - ) - } - - private fun createPrivateChat(hexEncodedPublicKey: String) { - val recipient = Recipient.from(requireContext(), Address.fromSerialized(hexEncodedPublicKey), false) - Intent(requireContext(), ConversationActivityV2::class.java).apply { - putExtra(ConversationActivityV2.ADDRESS, recipient.address) - setDataAndType(requireActivity().intent.data, requireActivity().intent.type) - putExtra(ConversationActivityV2.THREAD_ID, DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient)) - }.let(requireContext()::startActivity) - delegate.onDialogClosePressed() - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt deleted file mode 100644 index 4d70f40471..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt +++ /dev/null @@ -1,114 +0,0 @@ -package org.thoughtcrime.securesms.conversation.start.newmessage - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import java.util.concurrent.TimeoutException -import javax.inject.Inject -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout -import network.loki.messenger.R -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.utilities.await -import org.session.libsignal.utilities.PublicKeyValidation -import org.thoughtcrime.securesms.ui.GetString - -@HiltViewModel -internal class NewMessageViewModel @Inject constructor( - private val application: Application -): AndroidViewModel(application), Callbacks { - - private val _state = MutableStateFlow(State()) - val state = _state.asStateFlow() - - private val _success = MutableSharedFlow() - val success get() = _success.asSharedFlow() - - private val _qrErrors = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - val qrErrors = _qrErrors.asSharedFlow() - - private var loadOnsJob: Job? = null - - override fun onChange(value: String) { - loadOnsJob?.cancel() - loadOnsJob = null - _state.update { it.copy(newMessageIdOrOns = value, isTextErrorColor = false, loading = false) } - } - - override fun onContinue() { - val idOrONS = state.value.newMessageIdOrOns.trim() - - if (PublicKeyValidation.isValid(idOrONS, isPrefixRequired = false)) { - onUnvalidatedPublicKey(publicKey = idOrONS) - } else { - resolveONS(ons = idOrONS) - } - } - - override fun onScanQrCode(value: String) { - if (PublicKeyValidation.isValid(value, isPrefixRequired = false) && PublicKeyValidation.hasValidPrefix(value)) { - onPublicKey(value) - } else { - _qrErrors.tryEmit(application.getString(R.string.qrNotAccountId)) - } - } - - private fun resolveONS(ons: String) { - if (loadOnsJob?.isActive == true) return - - // This could be an ONS name - _state.update { it.copy(isTextErrorColor = false, error = null, loading = true) } - - loadOnsJob = viewModelScope.launch { - try { - val publicKey = withTimeout(30_000L, { - SnodeAPI.getAccountID(ons).await() - }) - onPublicKey(publicKey) - } catch (e: Exception) { - onError(e) - } - } - } - - private fun onError(e: Exception) { - _state.update { it.copy(loading = false, isTextErrorColor = true, error = GetString(e) { it.toMessage() }) } - } - - private fun onPublicKey(publicKey: String) { - _state.update { it.copy(loading = false) } - viewModelScope.launch { _success.emit(Success(publicKey)) } - } - - private fun onUnvalidatedPublicKey(publicKey: String) { - if (PublicKeyValidation.hasValidPrefix(publicKey)) { - onPublicKey(publicKey) - } else { - _state.update { it.copy(isTextErrorColor = true, error = GetString(R.string.accountIdErrorInvalid), loading = false) } - } - } - - private fun Exception.toMessage() = when (this) { - is SnodeAPI.Error.Generic -> application.getString(R.string.onsErrorNotRecognized) - else -> application.getString(R.string.onsErrorUnableToSearch) - } -} - -internal data class State( - val newMessageIdOrOns: String = "", - val isTextErrorColor: Boolean = false, - val error: GetString? = null, - val loading: Boolean = false -) { - val isNextButtonEnabled: Boolean get() = newMessageIdOrOns.isNotBlank() -} - -internal data class Success(val publicKey: String) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/AttachmentDownloadHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/AttachmentDownloadHandler.kt index 7278be71bc..4fa5947298 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/AttachmentDownloadHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/AttachmentDownloadHandler.kt @@ -1,5 +1,8 @@ package org.thoughtcrime.securesms.conversation.v2 +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -8,13 +11,12 @@ import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.plus import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.AttachmentUploadJob import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.util.flatten @@ -24,14 +26,14 @@ import org.thoughtcrime.securesms.util.timedBuffer * [AttachmentDownloadHandler] is responsible for handling attachment download requests. These * requests will go through different level of checking before they are queued for download. * - * To use this handler, call [onAttachmentDownloadRequest] with the attachment that needs to be - * downloaded. The call to [onAttachmentDownloadRequest] is cheap and can be called multiple times. + * To use this handler, call [downloadPendingAttachment] with the attachment that needs to be + * downloaded. The call to [downloadPendingAttachment] is cheap and can be called multiple times. */ -class AttachmentDownloadHandler( +class AttachmentDownloadHandler @AssistedInject constructor( private val storage: StorageProtocol, private val messageDataProvider: MessageDataProvider, - jobQueue: JobQueue = JobQueue.shared, - scope: CoroutineScope = CoroutineScope(Dispatchers.Default) + SupervisorJob(), + @Assisted private val scope: CoroutineScope, + private val downloadJobFactory: AttachmentDownloadJob.Factory ) { companion object { private const val BUFFER_TIMEOUT_MILLS = 500L @@ -40,6 +42,7 @@ class AttachmentDownloadHandler( } private val downloadRequests = Channel(UNLIMITED) + private val jobQueue: JobQueue = JobQueue.shared init { scope.launch(Dispatchers.Default) { @@ -50,9 +53,9 @@ class AttachmentDownloadHandler( .flatten() .collect { attachment -> jobQueue.add( - AttachmentDownloadJob( + downloadJobFactory.create( attachmentID = attachment.attachmentId.rowId, - databaseMessageID = attachment.mmsId + mmsMessageId = attachment.mmsId ) ) } @@ -101,15 +104,35 @@ class AttachmentDownloadHandler( } - fun onAttachmentDownloadRequest(attachment: DatabaseAttachment) { - if (attachment.transferState != AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING) { + fun downloadPendingAttachment(attachment: DatabaseAttachment) { + if (attachment.transferState != AttachmentState.PENDING.value) { Log.i( LOG_TAG, - "Attachment ${attachment.attachmentId} is not pending, skipping download" + "Attachment ${attachment.attachmentId} is not pending nor failed, skipping download (state = ${attachment.transferState})}" ) return } downloadRequests.trySend(attachment) } + + fun retryFailedAttachments(attachments: List){ + attachments.forEach { attachment -> + if (attachment.transferState != AttachmentState.FAILED.value){ + Log.d( + LOG_TAG, + "Attachment ${attachment.attachmentId} is not failed, skipping retry" + ) + + return@forEach + } + + downloadRequests.trySend(attachment) + } + } + + @AssistedFactory + interface Factory { + fun create(scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)): AttachmentDownloadHandler + } } 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 a8512df14d..938d57bd30 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 @@ -26,8 +26,6 @@ import android.text.style.ImageSpan import android.util.Pair import android.util.TypedValue import android.view.ActionMode -import android.view.Menu -import android.view.MenuItem import android.view.MotionEvent import android.view.View import android.view.ViewGroup.LayoutParams @@ -40,7 +38,11 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import androidx.fragment.app.DialogFragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.Observer @@ -51,17 +53,22 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.RecyclerView import com.annimon.stream.Stream import com.bumptech.glide.Glide import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -71,7 +78,6 @@ import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ActivityConversationV2Binding import network.loki.messenger.libsession_util.util.ExpiryMode -import nl.komponents.kovenant.ui.successUi import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.messages.ExpirationConfiguration @@ -87,6 +93,7 @@ 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.snode.SnodeAPI +import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.GroupUtil @@ -97,29 +104,29 @@ import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_K import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.Stub import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences.Companion.CALL_NOTIFICATIONS_ENABLED import org.session.libsession.utilities.concurrent.SimpleTask import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientModifiedListener import org.session.libsignal.crypto.MnemonicCodec +import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.hexEncodedPrivateKey import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.FullComposeActivity.Companion.applyCommonPropertiesForCompose +import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.attachments.ScreenshotObserver -import org.thoughtcrime.securesms.audio.AudioRecorder +import org.thoughtcrime.securesms.audio.AudioRecorderHandle +import org.thoughtcrime.securesms.audio.recordAudio +import org.thoughtcrime.securesms.components.TypingStatusSender import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel -import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey -import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener -import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.* -import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_COPY import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_DELETE import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_REPLY @@ -130,24 +137,25 @@ import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate -import org.thoughtcrime.securesms.conversation.v2.input_bar.VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS -import org.thoughtcrime.securesms.conversation.v2.input_bar.VoiceRecorderConstants.SHOW_HIDE_VOICE_UI_DURATION_MS +import org.thoughtcrime.securesms.conversation.v2.input_bar.VoiceRecorderConstants import org.thoughtcrime.securesms.conversation.v2.input_bar.VoiceRecorderState import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidateAdapter import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate -import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsActivity +import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsActivity import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager 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.AttachmentDatabase import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase @@ -164,8 +172,8 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.giph.ui.GiphyActivity +import org.thoughtcrime.securesms.groups.GroupMembersActivity import org.thoughtcrime.securesms.groups.OpenGroupManager -import org.thoughtcrime.securesms.home.UserDetailsBottomSheet import org.thoughtcrime.securesms.home.search.getSearchName import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil @@ -177,51 +185,63 @@ import org.thoughtcrime.securesms.mms.AudioSlide import org.thoughtcrime.securesms.mms.GifSlide import org.thoughtcrime.securesms.mms.ImageSlide import org.thoughtcrime.securesms.mms.MediaConstraints +import org.thoughtcrime.securesms.mms.MmsException import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.mms.SlideDeck import org.thoughtcrime.securesms.mms.VideoSlide import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository +import org.thoughtcrime.securesms.ui.components.ConversationAppBar +import org.thoughtcrime.securesms.ui.getSubbedString +import org.thoughtcrime.securesms.ui.setThemedContent import org.thoughtcrime.securesms.util.ActivityDispatcher import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.FilenameUtils import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.PaddedImageSpan import org.thoughtcrime.securesms.util.SaveAttachmentTask +import org.thoughtcrime.securesms.util.applySafeInsetsPaddings import org.thoughtcrime.securesms.util.drawToBitmap -import org.thoughtcrime.securesms.util.isScrolledToBottom -import org.thoughtcrime.securesms.util.isScrolledToWithin30dpOfBottom +import org.thoughtcrime.securesms.util.fadeIn +import org.thoughtcrime.securesms.util.fadeOut +import org.thoughtcrime.securesms.util.getConversationUnread +import org.thoughtcrime.securesms.util.isFullyScrolled +import org.thoughtcrime.securesms.util.isNearBottom import org.thoughtcrime.securesms.util.push -import org.thoughtcrime.securesms.util.show import org.thoughtcrime.securesms.util.toPx -import java.lang.ref.WeakReference +import org.thoughtcrime.securesms.webrtc.WebRtcCallActivity +import org.thoughtcrime.securesms.webrtc.WebRtcCallActivity.Companion.ACTION_START_CALL +import org.thoughtcrime.securesms.webrtc.WebRtcCallBridge.Companion.EXTRA_RECIPIENT_ADDRESS +import java.io.File import java.util.LinkedList -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 +import kotlin.concurrent.thread import kotlin.math.abs import kotlin.math.min import kotlin.math.roundToInt import kotlin.math.sqrt import kotlin.time.Duration.Companion.minutes - private const val TAG = "ConversationActivityV2" +private const val TAG_REACTION_FRAGMENT = "ReactionsDialog" // 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. @AndroidEntryPoint -class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate, +class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher, ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener, - SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks, ConversationActionBarDelegate, - OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback, - ConversationMenuHelper.ConversationMenuListener, UserDetailsBottomSheet.UserDetailsBottomSheetCallback { + SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks, + OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback { private lateinit var binding: ActivityConversationV2Binding @@ -238,8 +258,20 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe @Inject lateinit var reactionDb: ReactionDatabase @Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory @Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory + @Inject lateinit var dateUtils: DateUtils @Inject lateinit var configFactory: ConfigFactory @Inject lateinit var groupManagerV2: GroupManagerV2 + @Inject lateinit var typingStatusRepository: TypingStatusRepository + @Inject lateinit var typingStatusSender: TypingStatusSender + @Inject lateinit var openGroupManager: OpenGroupManager + @Inject lateinit var attachmentDatabase: AttachmentDatabase + @Inject lateinit var clock: SnodeClock + + override val applyDefaultWindowInsets: Boolean + get() = false + + override val applyAutoScrimForNavigationBar: Boolean + get() = false private val screenshotObserver by lazy { ScreenshotObserver(this, Handler(Looper.getMainLooper())) { @@ -258,9 +290,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe var threadId = intent.getLongExtra(THREAD_ID, -1L) if (threadId == -1L) { intent.getParcelableExtra
(ADDRESS)?.let { it -> - threadId = threadDb.getThreadIdIfExistsFor(it.serialize()) + threadId = threadDb.getThreadIdIfExistsFor(it.toString()) if (threadId == -1L) { - val accountId = AccountId(it.serialize()) + val accountId = AccountId(it.toString()) val openGroup = lokiThreadDb.getOpenGroupChat(intent.getLongExtra(FROM_GROUP_THREAD_ID, -1)) val address = if (accountId.prefix == IdPrefix.BLINDED && openGroup != null) { storage.getOrCreateBlindedIdMapping(accountId.hexString, openGroup.server, openGroup.publicKey).accountId?.let { @@ -284,7 +316,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private var actionMode: ActionMode? = null private var unreadCount = Int.MAX_VALUE // Attachments - private val audioRecorder = AudioRecorder(this) + private var voiceMessageStartTimestamp: Long = 0L + private var audioRecorderHandle: AudioRecorderHandle? = null private val stopAudioHandler = Handler(Looper.getMainLooper()) private val stopVoiceMessageRecordingTask = Runnable { sendVoiceMessage() } private val attachmentManager by lazy { AttachmentManager(this, this) } @@ -296,10 +329,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private val mentionCandidateAdapter = MentionCandidateAdapter { mentionViewModel.onCandidateSelected(it.member.publicKey) + + // make sure to reverify text length here as the onTextChanged happens before this step + viewModel.onTextChanged(mentionViewModel.deconstructMessageMentions()) } // Search val searchViewModel: SearchViewModel by viewModels() - var searchViewItem: MenuItem? = null private val bufferedLastSeenChannel = Channel(capacity = 512, onBufferOverflow = BufferOverflow.DROP_OLDEST) @@ -313,11 +348,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private val EMOJI_REACTIONS_ALLOWED_PER_MINUTE = 20 private val ONE_MINUTE_IN_MILLISECONDS = 1.minutes.inWholeMilliseconds - private val isScrolledToBottom: Boolean - get() = binding.conversationRecyclerView.isScrolledToBottom - - private val isScrolledToWithin30dpOfBottom: Boolean - get() = binding.conversationRecyclerView.isScrolledToWithin30dpOfBottom + private var conversationLoadAnimationJob: Job? = null private val layoutManager: LinearLayoutManager? get() { return binding.conversationRecyclerView.layoutManager as LinearLayoutManager? } @@ -335,18 +366,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe 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 var firstCursorLoad = false private val adapter by lazy { - val cursor = mmsSmsDb.getConversation(viewModel.threadId, reverseMessageList) val adapter = ConversationAdapter( this, - cursor, + null, viewModel.recipient, storage.getLastSeen(viewModel.threadId), - reverseMessageList, + false, onItemPress = { message, position, view, event -> handlePress(message, position, view, event) }, @@ -354,50 +382,115 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe handleSwipeToReply(message) }, onItemLongPress = { message, position, view -> - if (!viewModel.isMessageRequestThread) { - showConversationReaction(message, view) - } else { - selectMessage(message, position) + // long pressing message for blocked users should show unblock dialog + if(viewModel.recipient?.isBlocked == true) unblock() + else { + if (!viewModel.isMessageRequestThread) { + showConversationReaction(message, view) + } else { + selectMessage(message) + } } }, - onDeselect = { message, position -> + onDeselect = { message -> actionMode?.let { - onDeselect(message, position, it) + onDeselect(message, it) } }, - onAttachmentNeedsDownload = viewModel::onAttachmentDownloadRequest, + downloadPendingAttachment = viewModel::downloadPendingAttachment, + retryFailedAttachments = viewModel::retryFailedAttachments, 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 } private val glide by lazy { Glide.with(this) } private val lockViewHitMargin by lazy { toPx(40, resources) } - private val gifButton by lazy { InputBarButton(this, R.drawable.ic_gif_white_24dp, hasOpaqueBackground = true, isGIFButton = true) } - private val documentButton by lazy { InputBarButton(this, R.drawable.ic_document_small_dark, hasOpaqueBackground = true) } - private val libraryButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_library_24, hasOpaqueBackground = true) } - private val cameraButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_camera_24, hasOpaqueBackground = true) } + + private val gifButton by lazy { InputBarButton(this, R.drawable.ic_gif, hasOpaqueBackground = true) } + private val documentButton by lazy { InputBarButton(this, R.drawable.ic_file, hasOpaqueBackground = true) } + private val libraryButton by lazy { InputBarButton(this, R.drawable.ic_images, hasOpaqueBackground = true) } + private val cameraButton by lazy { InputBarButton(this, R.drawable.ic_camera, hasOpaqueBackground = true) } + private val messageToScrollTimestamp = AtomicLong(-1) private val messageToScrollAuthor = AtomicReference(null) private val firstLoad = AtomicBoolean(true) + private var isKeyboardVisible = false + 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 + private val voiceNoteTooShortToast: Toast by lazy { + Toast.makeText( + applicationContext, + applicationContext.getString(R.string.messageVoiceErrorShort), + Toast.LENGTH_SHORT + ).apply { + // On Android API 30 and above we can use callbacks to control our toast visible flag. + // Note: We have to do this hoop-jumping to prevent the possibility of a window layout + // crash when attempting to show a toast that is already visible should the user spam + // the microphone button, and because `someToast.view?.isShown` is deprecated. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + addCallback(object : Toast.Callback() { + override fun onToastShown() { isVoiceToastShowing = true } + override fun onToastHidden() { isVoiceToastShowing = false } + }) + } + } + } - // Lower limit for the length of voice messages - any lower and we inform the user rather than sending - private val MINIMUM_VOICE_MESSAGE_DURATION_MS = 1000L + private var isVoiceToastShowing = false + + // launcher that handles getting results back from the settings page + private val settingsLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { res -> + if (res.resultCode == RESULT_OK && + res.data?.getBooleanExtra(SHOW_SEARCH, false) == true) { + onSearchOpened() + } + } + + // Only show a toast related to voice messages if the toast is not already showing (used if to + // rate limit & prevent toast queueing when the user spams the microphone button). + private fun showVoiceMessageToastIfNotAlreadyVisible() { + if (!isVoiceToastShowing) { + voiceNoteTooShortToast.show() + + // Use a delayed callback to reset the toast visible flag after Toast.LENGTH_SHORT duration (~2000ms) ONLY on + // Android APIs < 30 which lack the onToastShown & onToastHidden callbacks. + // Note: While Toast.LENGTH_SHORT is roughly 2000ms, it is subject to change with varying Android versions or + // even between devices - we have no control over this. + // TODO: Remove the lines below and just use the callbacks when our minimum API is >= 30. + isVoiceToastShowing = true + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + Handler(Looper.getMainLooper()).postDelayed( { isVoiceToastShowing = false }, 2000) + } + } + } + + private val isScrolledToBottom: Boolean + get() = binding.conversationRecyclerView.isNearBottom + + // When the user clicks on the original message in a reply then we scroll to and highlight that original + // message. To do this we keep track of the replied-to message's location in the recycler view. + private var pendingHighlightMessagePosition: Int? = null + + // Used to target a specific message and scroll to it with some breathing room above (offset) for all messages but the first + private var currentTargetedScrollOffsetPx: Int = 0 + private val nonFirstMessageOffsetPx by lazy { resources.getDimensionPixelSize(R.dimen.massive_spacing) * -1 } + private val linearSmoothScroller by lazy { + object : LinearSmoothScroller(binding.conversationRecyclerView.context) { + override fun getVerticalSnapPreference(): Int = SNAP_TO_START + override fun calculateDyToMakeVisible(view: View, snapPreference: Int): Int { + return super.calculateDyToMakeVisible(view, snapPreference) - currentTargetedScrollOffsetPx + } + } + } + + // The coroutine job that was used to submit a message approval response to the snode + private var conversationApprovalJob: Job? = null // region Settings companion object { @@ -407,34 +500,49 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe const val FROM_GROUP_THREAD_ID = "from_group_thread_id" const val SCROLL_MESSAGE_ID = "scroll_message_id" const val SCROLL_MESSAGE_AUTHOR = "scroll_message_author" + const val SHOW_SEARCH = "show_search" // Request codes const val PICK_DOCUMENT = 2 const val TAKE_PHOTO = 7 const val PICK_GIF = 10 const val PICK_FROM_LIBRARY = 12 - const val INVITE_CONTACTS = 124 - const val CONVERSATION_SETTINGS = 125 // used to open conversation search on result } // endregion - fun showOpenUrlDialog(url: String){ - viewModel.onCommand(ShowOpenUrlDialog(url)) - } + fun showOpenUrlDialog(url: String) = viewModel.onCommand(ShowOpenUrlDialog(url)) // region Lifecycle + override fun onCreate(savedInstanceState: Bundle?) { + applyCommonPropertiesForCompose() + super.onCreate(savedInstanceState) + } + override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) binding = ActivityConversationV2Binding.inflate(layoutInflater) setContentView(binding.root) + setupWindowInsets() + + startConversationLoaderWithDelay() + // set the compose dialog content binding.dialogOpenUrl.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { + setThemedContent { val dialogsState by viewModel.dialogsState.collectAsState() + val inputBarDialogState by viewModel.inputBarStateDialogsState.collectAsState() ConversationV2Dialogs( dialogsState = dialogsState, - sendCommand = viewModel::onCommand + inputBarDialogsState = inputBarDialogState, + sendCommand = viewModel::onCommand, + sendInputBarCommand = viewModel::onInputBarCommand, + onPostUserProfileModalAction = { + // this function is to perform logic once an action + // has been taken in the UPM, like messaging a user + // in this case we want to make sure the reaction dialog is dismissed + dismissReactionsDialog() + } ) } } @@ -457,68 +565,33 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding.scrollToBottomButton.setOnClickListener { val layoutManager = binding.conversationRecyclerView.layoutManager as LinearLayoutManager - val targetPosition = if (reverseMessageList) 0 else adapter.itemCount + val targetPosition = adapter.itemCount - 1 if (layoutManager.isSmoothScrolling) { + // Tapping while smooth scrolling is in progress will instantly jump to the bottom. binding.conversationRecyclerView.scrollToPosition(targetPosition) } else { - // 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) - } + // First tap: smooth scroll + linearSmoothScroller.targetPosition = targetPosition + layoutManager.startSmoothScroll(linearSmoothScroller) } } - updateUnreadCountIndicator() - updatePlaceholder() - setUpBlockedBanner() + // in case a phone call is in progress, this banner is visible and should bring the user back to the call + binding.conversationHeader.callInProgress.setOnClickListener { + startActivity(WebRtcCallActivity.getCallActivityIntent(this)) + } + setUpExpiredGroupBanner() binding.searchBottomBar.setEventListener(this) updateSendAfterApprovalText() setUpMessageRequests() - 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() - setUpLegacyGroupUI() - - if (author != null && messageTimestamp >= 0 && targetPosition >= 0) { - binding.conversationRecyclerView.scrollToPosition(targetPosition) - } - else { - scrollToFirstUnreadMessageIfNeeded(true) - } - } - } + setUpRecyclerView() + setUpTypingObserver() + setUpRecipientObserver() + setUpSearchResultObserver() + setUpLegacyGroupUI() val reactionOverlayStub: Stub = ViewUtil.findStubById(this, R.id.conversation_reaction_scrubber_stub) @@ -546,6 +619,48 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe setupUiEventsObserver() } + private fun startConversationLoaderWithDelay() { + conversationLoadAnimationJob = lifecycleScope.launch { + delay(700) + binding.conversationLoader.isVisible = true + } + } + + private fun stopConversationLoader() { + // Cancel the delayed load animation + conversationLoadAnimationJob?.cancel() + conversationLoadAnimationJob = null + + binding.conversationLoader.isVisible = false + } + + private fun setupWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, windowInsets -> + val systemBarsInsets = + windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.ime()) + + val imeHeight = windowInsets.getInsets(WindowInsetsCompat.Type.ime()).bottom + val keyboardVisible = imeHeight > 0 + + if (keyboardVisible != isKeyboardVisible) { + isKeyboardVisible = keyboardVisible + if (keyboardVisible) { + // when showing the keyboard, we should auto scroll the conversation recyclerview + // if we are near the bottom (within 50dp of bottom) + if (binding.conversationRecyclerView.isNearBottom) { + binding.conversationRecyclerView.smoothScrollToPosition(adapter.itemCount) + } + } + } + + binding.bottomSpacer.updateLayoutParams { + height = systemBarsInsets.bottom + } + + windowInsets.inset(systemBarsInsets) + } + } + private fun setupUiEventsObserver() { lifecycleScope.launch { viewModel.uiEvents.collect { event -> @@ -556,15 +671,49 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe .putExtra(THREAD_ID, event.threadId) ) } + + is ConversationUiEvent.ShowDisappearingMessages -> { + val intent = Intent(this@ConversationActivityV2, DisappearingMessagesActivity::class.java).apply { + putExtra(DisappearingMessagesActivity.THREAD_ID, event.threadId) + } + startActivity(intent) + } + + is ConversationUiEvent.ShowGroupMembers -> { + val intent = Intent(this@ConversationActivityV2, GroupMembersActivity::class.java).apply { + putExtra(GroupMembersActivity.GROUP_ID, event.groupId) + } + startActivity(intent) + } + + is ConversationUiEvent.ShowNotificationSettings -> { + val intent = Intent(this@ConversationActivityV2, NotificationSettingsActivity::class.java).apply { + putExtra(NotificationSettingsActivity.THREAD_ID, event.threadId) + } + startActivity(intent) + } + + is ConversationUiEvent.ShowUnblockConfirmation -> { + unblock() + } + + is ConversationUiEvent.ShowConversationSettings -> { + val intent = ConversationSettingsActivity.createIntent( + context = this@ConversationActivityV2, + threadId = event.threadId, + threadAddress = event.threadAddress + ) + startActivity(intent) + } } } } } private fun setupMentionView() { - binding?.conversationMentionCandidates?.let { view -> - view.adapter = mentionCandidateAdapter - view.itemAnimator = null + binding.conversationMentionCandidates.apply { + adapter = mentionCandidateAdapter + itemAnimator = null } lifecycleScope.launch { @@ -577,7 +726,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - binding?.inputBar?.setInputBarEditableFactory(mentionViewModel.editableFactory) + binding.inputBar.setInputBarEditableFactory(mentionViewModel.editableFactory) } override fun onResume() { @@ -589,9 +738,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe true, screenshotObserver ) - viewModel.run { - binding.toolbarContent?.update(recipient ?: return, openGroup, expirationConfiguration) - } + + viewModel.onResume() } override fun onPause() { @@ -601,10 +749,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun getSystemService(name: String): Any? { - if (name == ActivityDispatcher.SERVICE) { - return this - } - return super.getSystemService(name) + return if (name == ActivityDispatcher.SERVICE) { this } else { super.getSystemService(name) } } override fun dispatchIntent(body: (Context) -> Intent?) { @@ -616,7 +761,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { - return ConversationLoader(viewModel.threadId, reverseMessageList, this@ConversationActivityV2) + return ConversationLoader(viewModel.threadId, false, this@ConversationActivityV2) } override fun onLoadFinished(loader: Loader, cursor: Cursor?) { @@ -625,6 +770,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe adapter.changeCursor(cursor) if (cursor != null) { + firstCursorLoad = true val messageTimestamp = messageToScrollTimestamp.getAndSet(-1) val author = messageToScrollAuthor.getAndSet(null) val initialUnreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId) @@ -641,40 +787,78 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (author != null && messageTimestamp >= 0) { jumpToMessage(author, messageTimestamp, firstLoad.get(), null) } else { - if (firstLoad.getAndSet(false)) scrollToFirstUnreadMessageIfNeeded(true) + if (firstLoad.getAndSet(false)) { + scrollToFirstUnreadMessageOrBottom() + + // On the first load, check if there unread messages + if (unreadCount == 0 && adapter.itemCount > 0) { + // Get the last visible timestamp + + lifecycleScope.launch(Dispatchers.IO) { + viewModel.recipient?.let { recipient -> + val isUnread = configFactory.withUserConfigs { + it.convoInfoVolatile.getConversationUnread( + recipient, + viewModel.threadId + ) + } + + if (isUnread) { + storage.markConversationAsRead( + viewModel.threadId, + clock.currentTimeMills() + ) + } + } + } + } + } else { + // If there are new data updated, we'll try to stay scrolled at the bottom (if we were at the bottom). + // scrolled to bottom has a leniency of 50dp, so if we are within the 50dp but not fully at the bottom, scroll down + if (binding.conversationRecyclerView.isNearBottom && !binding.conversationRecyclerView.isFullyScrolled) { + binding.conversationRecyclerView.smoothScrollToPosition(adapter.itemCount) + } + } + handleRecyclerViewScrolled() } + + updatePlaceholder() } - updatePlaceholder() + + stopConversationLoader() + viewModel.recipient?.let { - maybeUpdateToolbar(recipient = it) setUpOutdatedClientBanner() } } - override fun onLoaderReset(cursor: Loader) { - adapter.changeCursor(null) - } + override fun onLoaderReset(cursor: Loader) = adapter.changeCursor(null) // called from onCreate private fun setUpRecyclerView() { binding.conversationRecyclerView.adapter = adapter - val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, reverseMessageList) + val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) 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 + // If we were scrolling towards a specific message to highlight when scrolling stops then do so + if (newState == RecyclerView.SCROLL_STATE_IDLE) { + pendingHighlightMessagePosition?.let { position -> + recyclerView.findViewHolderForLayoutPosition(position)?.let { viewHolder -> + (viewHolder.itemView as? VisibleMessageView)?.playHighlight() + ?: Log.w(TAG, "View at position $position is not a VisibleMessageView - cannot highlight.") + } ?: Log.w(TAG, "ViewHolder at position $position is null - cannot highlight.") + pendingHighlightMessagePosition = null + } + } } }) @@ -683,54 +867,39 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe adapter.isAdmin = it } } - } - 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) - } + lifecycleScope.launch { + viewModel + .lastSeenMessageId + .collectLatest { adapter.lastSentMessageId = it } } - - // Update our previous last visible view index to the current one - previousLastVisibleRecyclerViewIndex = currentLastVisibleRecyclerViewIndex } // called from onCreate private fun setUpToolBar() { - setSupportActionBar(binding.toolbar) - val actionBar = supportActionBar ?: return - val recipient = viewModel.recipient ?: return - actionBar.title = "" - actionBar.setDisplayHomeAsUpEnabled(true) - actionBar.setHomeButtonEnabled(true) - binding.toolbarContent.bind( - this, - viewModel.threadId, - recipient, - viewModel.expirationConfiguration, - viewModel.openGroup - ) - maybeUpdateToolbar(recipient) + binding.conversationAppBar.setThemedContent { + val data by viewModel.appBarData.collectAsState() + val query by searchViewModel.searchQuery.collectAsState() + + ConversationAppBar( + data = data, + onBackPressed = ::finish, + onCallPressed = ::callRecipient, + searchQuery = query ?: "", + onSearchQueryChanged = ::onSearchQueryUpdated, + onSearchQueryClear = { onSearchQueryUpdated("") }, + onSearchCanceled = ::onSearchClosed, + onAvatarPressed = { + val intent = ConversationSettingsActivity.createIntent( + context = this, + threadId = viewModel.threadId, + threadAddress = viewModel.recipient?.address + ) + + settingsLauncher.launch(intent) + } + ) + } } // called from onCreate @@ -740,19 +909,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // GIF button binding.gifButtonContainer.addView(gifButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) gifButton.onUp = { showGIFPicker() } - gifButton.snIsEnabled = false // Document button binding.documentButtonContainer.addView(documentButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) documentButton.onUp = { showDocumentPicker() } - documentButton.snIsEnabled = false // Library button binding.libraryButtonContainer.addView(libraryButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) libraryButton.onUp = { pickFromLibrary() } - libraryButton.snIsEnabled = false // Camera button binding.cameraButtonContainer.addView(cameraButton, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) cameraButton.onUp = { showCamera() } - cameraButton.snIsEnabled = false } // called from onCreate @@ -760,10 +925,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val mediaURI = intent.data val mediaType = AttachmentManager.MediaType.from(intent.type) val mimeType = MediaUtil.getMimeType(this, mediaURI) + if (mediaURI != null && mediaType != null) { - if (mimeType != null && (AttachmentManager.MediaType.IMAGE == mediaType || AttachmentManager.MediaType.GIF == mediaType || AttachmentManager.MediaType.VIDEO == mediaType)) { - val media = Media(mediaURI, mimeType, 0, 0, 0, 0, Optional.absent(), Optional.absent()) - startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), viewModel.recipient!!, ""), PICK_FROM_LIBRARY) + val filename = FilenameUtils.getFilenameFromUri(this, mediaURI) + + if (mimeType != null && + (AttachmentManager.MediaType.IMAGE == mediaType || + AttachmentManager.MediaType.GIF == mediaType || + AttachmentManager.MediaType.VIDEO == mediaType) + ) { + val media = Media(mediaURI, filename, mimeType, 0, 0, 0, 0, null, null) + startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), viewModel.recipient!!, threadId, getMessageBody()), PICK_FROM_LIBRARY) return } else { prepMediaForSending(mediaURI, mediaType).addListener(object : ListenableFuture.Listener { @@ -790,7 +962,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // called from onCreate private fun setUpTypingObserver() { - ApplicationContext.getInstance(this).typingStatusRepository.getTypists(viewModel.threadId).observe(this) { state -> + typingStatusRepository.getTypists(viewModel.threadId).observe(this) { state -> val recipients = if (state != null) state.typists else listOf() // FIXME: Also checking isScrolledToBottom is a quick fix for an issue where the // typing indicator overlays the recycler view when scrolled up @@ -800,35 +972,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } if (textSecurePreferences.isTypingIndicatorsEnabled()) { binding.inputBar.addTextChangedListener { - ApplicationContext.getInstance(this).typingStatusSender.onTypingStarted(viewModel.threadId) + if(it.isNotEmpty()) { + typingStatusSender.onTypingStarted(viewModel.threadId) + } } } } - private fun setUpRecipientObserver() { - viewModel.recipient?.addListener(this) - } - - private fun tearDownRecipientObserver() { - viewModel.recipient?.removeListener(this) - } - - private fun getLatestOpenGroupInfoIfNeeded() { - val openGroup = viewModel.openGroup ?: return - OpenGroupApi.getMemberCount(openGroup.room, openGroup.server) successUi { - binding.toolbarContent.updateSubtitle(viewModel.recipient!!, openGroup, viewModel.expirationConfiguration) - maybeUpdateToolbar(viewModel.recipient!!) - } - } + private fun setUpRecipientObserver() = viewModel.recipient?.addListener(this) + private fun tearDownRecipientObserver() = viewModel.recipient?.removeListener(this) // called from onCreate - private fun setUpBlockedBanner() { - val recipient = viewModel.recipient?.takeUnless { it.isGroupOrCommunityRecipient } ?: return - binding.conversationHeader.blockedBannerTextView.text = applicationContext.getString(R.string.blockBlockedDescription) - binding.conversationHeader.blockedBanner.isVisible = recipient.isBlocked - binding.conversationHeader.blockedBanner.setOnClickListener { unblock() } - } - private fun setUpExpiredGroupBanner() { lifecycleScope.launch { viewModel.showExpiredGroupBanner @@ -848,7 +1002,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (shouldShowLegacy) { val txt = Phrase.from(this, R.string.disappearingMessagesLegacy) - .put(NAME_KEY, legacyRecipient!!.toShortString()) + .put(NAME_KEY, legacyRecipient!!.name) .format() binding.conversationHeader.outdatedDisappearingBannerTextView.text = txt } @@ -867,9 +1021,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe .apply { // Append a space as a placeholder append(" ") - + // we need to add the inline icon - val drawable = ContextCompat.getDrawable(this@ConversationActivityV2, R.drawable.ic_external)!! + val drawable = ContextCompat.getDrawable(this@ConversationActivityV2, R.drawable.ic_square_arrow_up_right)!! val imageSize = toPx(10, resources) val imagePadding = toPx(4, resources) drawable.setBounds(0, 0, imageSize, imageSize) @@ -958,10 +1112,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state -> - binding.inputBar.run { - isVisible = state.showInput - showMediaControls = state.enableInputMediaControls - } + binding.root.requestApplyInsets() // show or hide loading indicator binding.loader.isVisible = state.showLoader @@ -970,24 +1121,41 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } } - } - private fun scrollToFirstUnreadMessageIfNeeded(isFirstLoad: Boolean = false, shouldHighlight: Boolean = false): Int { - val lastSeenTimestamp = threadDb.getLastSeenAndHasSent(viewModel.threadId).first() - val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return -1 + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.inputBarState.collect { state -> + binding.inputBar.setState(state) + } + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.callBanner.collect { callBanner -> + when (callBanner) { + null -> binding.conversationHeader.callInProgress.fadeOut() + else -> { + binding.conversationHeader.callInProgress.text = callBanner + binding.conversationHeader.callInProgress.fadeIn() + } + } + } + } + } + } - // 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 + private fun scrollToFirstUnreadMessageOrBottom() { + // if there are no unread messages, go straight to the very bottom of the list + if (unreadCount == 0) { + layoutManager?.scrollToPositionWithOffset(adapter.itemCount - 1, Int.MIN_VALUE) + return } - if (lastSeenItemPosition <= 3) { return lastSeenItemPosition } + val lastSeenTimestamp = threadDb.getLastSeenAndHasSent(viewModel.threadId).first() + val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return - binding.conversationRecyclerView.scrollToPosition(lastSeenItemPosition) - return lastSeenItemPosition + layoutManager?.scrollToPositionWithOffset(lastSeenItemPosition, ((layoutManager?.height ?: 0) / 2)) } private fun highlightViewAtPosition(position: Int) { @@ -996,58 +1164,36 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - val recipient = viewModel.recipient ?: return false - if (viewModel.showOptionsMenu) { - ConversationMenuHelper.onPrepareOptionsMenu( - menu = menu, - inflater = menuInflater, - thread = recipient, - context = this, - configFactory = configFactory, - deprecationManager = viewModel.legacyGroupDeprecationManager - ) - } - maybeUpdateToolbar(recipient) - return true - } - override fun onDestroy() { if(::binding.isInitialized) { viewModel.saveDraft(binding.inputBar.text.trim()) cancelVoiceMessage() tearDownRecipientObserver() } + + // Delete any files we might have locally cached when sharing (which we need to do + // when passing through files when the app is locked). + cleanupCachedFiles() + super.onDestroy() } // endregion // region Animation & Updating override fun onModified(recipient: Recipient) { - viewModel.updateRecipient() - runOnUiThread { - val threadRecipient = viewModel.recipient ?: return@runOnUiThread - if (threadRecipient.isContactRecipient) { - binding.conversationHeader.blockedBanner.isVisible = threadRecipient.isBlocked - } invalidateOptionsMenu() updateSendAfterApprovalText() - 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 setUpMessageRequests() { binding.messageRequestBar.acceptMessageRequestButton.setOnClickListener { - viewModel.acceptMessageRequest() + conversationApprovalJob = viewModel.acceptMessageRequest() } binding.messageRequestBar.messageRequestBlock.setOnClickListener { @@ -1062,7 +1208,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe showSessionDialog { title(R.string.delete) - text(resources.getString(R.string.messageRequestsDelete)) + text(resources.getString(R.string.messageRequestsContactDelete)) dangerButton(R.string.delete) { doDecline() } button(R.string.cancel) } @@ -1086,13 +1232,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - private fun acceptMessageRequest() { - binding.messageRequestBar.root.isVisible = false - viewModel.acceptMessageRequest() - } - override fun inputBarEditTextContentChanged(newContent: CharSequence) { - val inputBarText = binding.inputBar.text // TODO check if we should be referencing newContent here instead + val inputBarText = binding.inputBar.text if (textSecurePreferences.isLinkPreviewsEnabled()) { linkPreviewViewModel.onTextChanged(this, inputBarText, 0, 0) } @@ -1105,6 +1246,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe }.show(supportFragmentManager, "Link Preview Dialog") textSecurePreferences.setHasSeenLinkPreviewSuggestionDialog() } + + // use the normalised version of the text's body to get the characters amount with the + // mentions as their account id + viewModel.onTextChanged(mentionViewModel.deconstructMessageMentions()) } override fun toggleAttachmentOptions() { @@ -1128,66 +1273,71 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe animation.start() } isShowingAttachmentOptions = !isShowingAttachmentOptions + + // Note: These custom buttons exist invisibly in the layout even when the attachments bar is not + // expanded so they MUST be disabled in such circumstances. val allButtons = listOf( cameraButton, libraryButton, documentButton, gifButton ) - allButtons.forEach { it.snIsEnabled = isShowingAttachmentOptions } + allButtons.forEach { it.isEnabled = isShowingAttachmentOptions } } override fun showVoiceMessageUI() { binding.inputBarRecordingView.show(lifecycleScope) - binding.inputBar.alpha = 0.0f - val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) - animation.duration = SHOW_HIDE_VOICE_UI_DURATION_MS - animation.addUpdateListener { animator -> - binding.inputBar.alpha = animator.animatedValue as Float - } - animation.start() + + // Cancel any previous input bar animations and fade out the bar + val inputBar = binding.inputBar + inputBar.animate().cancel() + inputBar.animate() + .alpha(0f) + .setDuration(VoiceRecorderConstants.SHOW_HIDE_VOICE_UI_DURATION_MS) + .start() } private fun expandVoiceMessageLockView() { val lockView = binding.inputBarRecordingView.lockView - val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.10f) - animation.duration = ANIMATE_LOCK_DURATION_MS - animation.addUpdateListener { animator -> - lockView.scaleX = animator.animatedValue as Float - lockView.scaleY = animator.animatedValue as Float - } - animation.start() + + lockView.animate().cancel() + lockView.animate() + .scaleX(1.10f) + .scaleY(1.10f) + .setDuration(VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS) + .start() } private fun collapseVoiceMessageLockView() { val lockView = binding.inputBarRecordingView.lockView - val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.0f) - animation.duration = ANIMATE_LOCK_DURATION_MS - animation.addUpdateListener { animator -> - lockView.scaleX = animator.animatedValue as Float - lockView.scaleY = animator.animatedValue as Float - } - animation.start() + + lockView.animate().cancel() + lockView.animate() + .scaleX(1.0f) + .scaleY(1.0f) + .setDuration(VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS) + .start() } private fun hideVoiceMessageUI() { - val chevronImageView = binding.inputBarRecordingView.chevronImageView - val slideToCancelTextView = binding.inputBarRecordingView.slideToCancelTextView - listOf( chevronImageView, slideToCancelTextView ).forEach { view -> - val animation = ValueAnimator.ofObject(FloatEvaluator(), view.translationX, 0.0f) - animation.duration = ANIMATE_LOCK_DURATION_MS - animation.addUpdateListener { animator -> - view.translationX = animator.animatedValue as Float - } - animation.start() + listOf( + binding.inputBarRecordingView.chevronImageView, + binding.inputBarRecordingView.slideToCancelTextView + ).forEach { view -> + view.animate().cancel() + view.animate() + .translationX(0.0f) + .setDuration(VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS) + .start() } + binding.inputBarRecordingView.hide() } override fun handleVoiceMessageUIHidden() { val inputBar = binding.inputBar - inputBar.alpha = 1.0f - val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f) - animation.duration = SHOW_HIDE_VOICE_UI_DURATION_MS - animation.addUpdateListener { animator -> - inputBar.alpha = animator.animatedValue as Float - } - animation.start() + + // Cancel any previous input bar animations and fade in the bar + inputBar.animate().cancel() + inputBar.animate() + .alpha(1.0f) + .setDuration(VoiceRecorderConstants.SHOW_HIDE_VOICE_UI_DURATION_MS) + .start() } private fun handleRecyclerViewScrolled() { @@ -1197,28 +1347,27 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom showScrollToBottomButtonIfApplicable() - val maybeTargetVisiblePosition = if (reverseMessageList) layoutManager?.findFirstVisibleItemPosition() else layoutManager?.findLastVisibleItemPosition() + + val maybeTargetVisiblePosition = 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()) - } + 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) - } + val layoutUnreadCount = layoutManager?.let { (it.itemCount - 1) - it.findLastVisibleItemPosition() } + ?: RecyclerView.NO_POSITION + unreadCount = min(unreadCount, layoutUnreadCount).coerceAtLeast(0) + updateUnreadCountIndicator() } // Update placeholder / control messages in a conversation private fun updatePlaceholder() { + if(!firstCursorLoad) return val recipient = viewModel.recipient ?: return Log.w("Loki", "recipient was null in placeholder update") val blindedRecipient = viewModel.blindedRecipient val openGroup = viewModel.openGroup @@ -1231,12 +1380,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding.conversationRecyclerView.isVisible = false binding.placeholderText.text = when (groupThreadStatus) { GroupThreadStatus.Kicked -> Phrase.from(this, R.string.groupRemovedYou) - .put(GROUP_NAME_KEY, recipient.toShortString()) + .put(GROUP_NAME_KEY, recipient.name) .format() .toString() GroupThreadStatus.Destroyed -> Phrase.from(this, R.string.groupDeletedMemberDescription) - .put(GROUP_NAME_KEY, recipient.toShortString()) + .put(GROUP_NAME_KEY, recipient.name) .format() .toString() @@ -1260,14 +1409,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // If we're trying to message someone who has blocked community message requests blindedRecipient?.blocksCommunityMessageRequests == true -> { Phrase.from(applicationContext, R.string.messageRequestsTurnedOff) - .put(NAME_KEY, recipient.toShortString()) + .put(NAME_KEY, recipient.name) .format() } - // 10n1 and groups - recipient.is1on1 || recipient.isGroupOrCommunityRecipient -> { + // 10n1 and groups and blinded 1on1 + recipient.isCommunityInboxRecipient || recipient.isCommunityOutboxRecipient || + recipient.is1on1 || recipient.isGroupOrCommunityRecipient -> { Phrase.from(applicationContext, R.string.groupNoMessages) - .put(GROUP_NAME_KEY, recipient.toShortString()) + .put(GROUP_NAME_KEY, recipient.name) .format() } @@ -1301,26 +1451,61 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // endregion // region Interaction - override fun onDisappearingMessagesClicked() { - viewModel.recipient?.let { showDisappearingMessages(it) } - } + private fun callRecipient() { + if(viewModel.recipient == null) return + + // if the user is blocked, show unblock modal + if(viewModel.recipient?.isBlocked == true){ + unblock() + return + } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - return false + // if the user has not enabled voice/video calls + if (!TextSecurePreferences.isCallNotificationsEnabled(this)) { + showSessionDialog { + title(R.string.callsPermissionsRequired) + text(R.string.callsPermissionsRequiredDescription) + button(R.string.sessionSettings, R.string.AccessibilityId_sessionSettings) { + val intent = Intent(context, PrivacySettingsActivity::class.java) + // allow the screen to auto scroll to the appropriate toggle + intent.putExtra(PrivacySettingsActivity.SCROLL_AND_TOGGLE_KEY, CALL_NOTIFICATIONS_ENABLED) + context.startActivity(intent) + } + cancelButton() + } + return } + // or if the user has not granted audio/microphone permissions + else if (!Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO)) { + Log.d("Loki", "Attempted to make a call without audio permissions") + + Permissions.with(this) + .request(Manifest.permission.RECORD_AUDIO) + .withPermanentDenialDialog( + getSubbedString(R.string.permissionsMicrophoneAccessRequired, + APP_NAME_KEY to getString(R.string.app_name)) + ) + .execute() - return viewModel.onOptionItemSelected(this, item) + return + } + + WebRtcCallActivity.getCallActivityIntent(this) + .apply { + action = ACTION_START_CALL + putExtra(EXTRA_RECIPIENT_ADDRESS, viewModel.recipient!!.address) + } + .let(::startActivity) } - override fun block(deleteThread: Boolean) { + fun block(deleteThread: Boolean) { val recipient = viewModel.recipient ?: return Log.w("Loki", "Recipient was null for block action") val invitingAdmin = viewModel.invitingAdmin val name = if (recipient.isGroupV2Recipient && invitingAdmin != null) { invitingAdmin.getSearchName() } else { - recipient.toShortString() + recipient.name } showSessionDialog { @@ -1346,36 +1531,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - override fun copyAccountID(accountId: String) { - val clip = ClipData.newPlainText("Account ID", accountId) - val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager - manager.setPrimaryClip(clip) - Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show() - } - - override fun copyOpenGroupUrl(thread: Recipient) { - if (!thread.isCommunityRecipient) { return } - - val threadId = threadDb.getThreadIdIfExistsFor(thread) - 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, Toast.LENGTH_SHORT).show() - } - - // TODO: don't need to allow new closed group check here, removed in new disappearing messages - override fun showDisappearingMessages(thread: Recipient) { - if (thread.isLegacyGroupRecipient) { - 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() { + fun unblock() { val recipient = viewModel.recipient ?: return Log.w("Loki", "Recipient was null for unblock action") if (!recipient.isContactRecipient) { @@ -1386,7 +1542,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe title(R.string.blockUnblock) text( Phrase.from(context, R.string.blockUnblockName) - .put(NAME_KEY, recipient.toShortString()) + .put(NAME_KEY, recipient.name) .format() ) dangerButton(R.string.blockUnblock, R.string.AccessibilityId_unblockConfirm) { viewModel.unblock() } @@ -1398,7 +1554,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun handlePress(message: MessageRecord, position: Int, view: VisibleMessageView, event: MotionEvent) { val actionMode = this.actionMode if (actionMode != null) { - onDeselect(message, position, actionMode) + onDeselect(message, actionMode) } else { // NOTE: We have to use onContentClick (rather than a click listener directly on // the view) so as to not interfere with all the other gestures. Do not add @@ -1407,13 +1563,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - private fun onDeselect(message: MessageRecord, position: Int, actionMode: ActionMode) { - adapter.toggleSelection(message, position) + private fun onDeselect(message: MessageRecord, actionMode: ActionMode) { + adapter.toggleSelection(message) val actionModeCallback = ConversationActionModeCallback( adapter = adapter, threadID = viewModel.threadId, context = this, - deprecationManager = viewModel.legacyGroupDeprecationManager + deprecationManager = viewModel.legacyGroupDeprecationManager, + openGroupManager = openGroupManager, ) actionModeCallback.delegate = this actionModeCallback.updateActionModeMenu(actionMode.menu) @@ -1426,26 +1583,26 @@ 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) + reply(setOf(message)) } // `position` is the adapter position; not the visual position - private fun selectMessage(message: MessageRecord, position: Int) { + private fun selectMessage(message: MessageRecord) { val actionMode = this.actionMode val actionModeCallback = ConversationActionModeCallback( adapter = adapter, threadID = viewModel.threadId, context = this, - deprecationManager = viewModel.legacyGroupDeprecationManager + deprecationManager = viewModel.legacyGroupDeprecationManager, + openGroupManager = openGroupManager, ) actionModeCallback.delegate = this - searchViewItem?.collapseActionView() + if(binding.searchBottomBar.isVisible) onSearchClosed() if (actionMode == null) { // Nothing should be selected if this is the case - adapter.toggleSelection(message, position) + adapter.toggleSelection(message) this.actionMode = startActionMode(actionModeCallback, ActionMode.TYPE_PRIMARY) } else { - adapter.toggleSelection(message, position) + adapter.toggleSelection(message) actionModeCallback.updateActionModeMenu(actionMode.menu) if (adapter.selectedItems.isEmpty()) { actionMode.finish() @@ -1469,24 +1626,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } emojiPickerVisible = true ViewUtil.hideKeyboard(this, messageView) - binding.reactionsShade.isVisible = true binding.scrollToBottomButton.isVisible = false binding.conversationRecyclerView.suppressLayout(true) reactionDelegate.setOnActionSelectedListener(ReactionsToolbarListener(message)) reactionDelegate.setOnHideListener(object: ConversationReactionOverlay.OnHideListener { override fun startHide() { emojiPickerVisible = false - binding.reactionsShade.let { - ViewUtil.fadeOut(it, resources.getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE) - } showScrollToBottomButtonIfApplicable() } override fun onHide() { binding.conversationRecyclerView.suppressLayout(false) - - WindowUtil.setLightStatusBarFromTheme(this@ConversationActivityV2); - WindowUtil.setLightNavigationBarFromTheme(this@ConversationActivityV2); } }) @@ -1502,9 +1652,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe reactionDelegate.show(this, message, selectedConversationModel, viewModel.blindedPublicKey) } - override fun dispatchTouchEvent(ev: MotionEvent): Boolean { - return reactionDelegate.applyTouchEvent(ev) || super.dispatchTouchEvent(ev) - } + override fun dispatchTouchEvent(ev: MotionEvent): Boolean = reactionDelegate.applyTouchEvent(ev) || super.dispatchTouchEvent(ev) override fun onReactionSelected(messageRecord: MessageRecord, emoji: String) { reactionDelegate.hide() @@ -1519,7 +1667,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // Method to add an emoji to a queue and remove it a short while later - this is used as a // rate-limiting mechanism and is called from the `sendEmojiReaction` method, below. - fun canPerformEmojiReaction(timestamp: Long): Boolean { // If the emoji reaction queue is full.. if (emojiRateLimiterQueue.size >= EMOJI_REACTIONS_ALLOWED_PER_MINUTE) { @@ -1569,25 +1716,24 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } else { // Put the message in the database val reaction = ReactionRecord( - messageId = originalMessage.id, - isMms = originalMessage.isMms, + messageId = originalMessage.messageId, author = author, emoji = emoji, count = 1, dateSent = emojiTimestamp, dateReceived = emojiTimestamp ) - reactionDb.addReaction(MessageId(originalMessage.id, originalMessage.isMms), reaction, false) + reactionDb.addReaction(reaction) val originalAuthor = if (originalMessage.isOutgoing) { fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!) } else originalMessage.individualRecipient.address // Send it - reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, true) + reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.toString(), emoji, true) if (recipient.isCommunityRecipient) { - val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: + val messageServerId = lokiMessageDb.getServerID(originalMessage.messageId) ?: return Log.w(TAG, "Failed to find message server ID when adding emoji reaction") viewModel.openGroup?.let { @@ -1614,16 +1760,20 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe Log.w(TAG, "Unable to locate local number when removing emoji reaction - aborting.") return } else { - reactionDb.deleteReaction(emoji, MessageId(originalMessage.id, originalMessage.isMms), author, false) + reactionDb.deleteReaction( + emoji, + MessageId(originalMessage.id, originalMessage.isMms), + author + ) val originalAuthor = if (originalMessage.isOutgoing) { fromSerialized(viewModel.blindedPublicKey ?: textSecurePreferences.getLocalNumber()!!) } else originalMessage.individualRecipient.address - message.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, false) + message.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.toString(), emoji, false) if (recipient.isCommunityRecipient) { - val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: + val messageServerId = lokiMessageDb.getServerID(originalMessage.messageId) ?: return Log.w(TAG, "Failed to find message server ID when removing emoji reaction") viewModel.openGroup?.let { @@ -1651,9 +1801,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - override fun onReactWithAnyEmojiDialogDismissed() { - reactionDelegate.hide() - } + override fun onReactWithAnyEmojiDialogDismissed() = reactionDelegate.hide() override fun onReactWithAnyEmojiSelected(emoji: String, messageId: MessageId) { reactionDelegate.hide() @@ -1679,13 +1827,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe sendEmojiRemoval(emoji, message) } - /** - * Called when the user is attempting to clear all instance of a specific emoji. - */ - override fun onClearAll(emoji: String, messageId: MessageId) { - viewModel.onEmojiClear(emoji, messageId) + override fun onEmojiReactionUserTapped(recipient: Recipient) { + showUserProfileModal(recipient) } + // Called when the user is attempting to clear all instance of a specific emoji + override fun onClearAll(emoji: String, messageId: MessageId) = viewModel.onEmojiClear(emoji, messageId) + override fun onMicrophoneButtonMove(event: MotionEvent) { val rawX = event.rawX val chevronImageView = binding.inputBarRecordingView.chevronImageView @@ -1716,66 +1864,54 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - override fun onMicrophoneButtonCancel(event: MotionEvent) { - hideVoiceMessageUI() - } + override fun onMicrophoneButtonCancel(event: MotionEvent) = hideVoiceMessageUI() override fun onMicrophoneButtonUp(event: MotionEvent) { + if(binding.inputBar.voiceRecorderState != VoiceRecorderState.Recording){ + cancelVoiceMessage() + return + } + val x = event.rawX.roundToInt() val y = event.rawY.roundToInt() - val inputBar = binding.inputBar - // Lock voice recording on if the button is released over the lock area AND the + // Lock voice recording on if the button is released over the lock area. AND the // voice recording has currently lasted for at least the time it takes to animate // the lock area into position. Without this time check we can accidentally lock // to recording audio on a quick tap as the lock area animates out from the record // audio message button and the pointer-up event catches it mid-animation. - // - // Further, by limiting this to AnimateLockDurationMS rather than our minimum voice - // message length we get a fast, responsive UI that can lock 'straight away' - BUT - // we then have to artificially bump the voice message duration because if you press - // and slide to lock then release in one quick motion the pointer up event may be - // less than our minimum voice message duration - so we'll bump our recorded duration - // slightly to make sure we don't see the "Tap and hold to record..." toast when we - // finish recording the message. - if (isValidLockViewLocation(x, y) && inputBar.voiceMessageDurationMS >= ANIMATE_LOCK_DURATION_MS) { + val currentVoiceMessageDurationMS = System.currentTimeMillis() - voiceMessageStartTimestamp + if (isValidLockViewLocation(x, y) && currentVoiceMessageDurationMS >= VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS) { binding.inputBarRecordingView.lock() - // Artificially bump message duration on lock if required - if (inputBar.voiceMessageDurationMS < MINIMUM_VOICE_MESSAGE_DURATION_MS) { - inputBar.voiceMessageDurationMS = MINIMUM_VOICE_MESSAGE_DURATION_MS - } - // If the user put the record audio button into the lock state then we are still recording audio binding.inputBar.voiceRecorderState = VoiceRecorderState.Recording } - else // If the user didn't attempt to lock voice recording on.. + else { - // Regardless of where the button up event occurred we're now shutting down the recording (whether we send it or not) + // If the user didn't lock voice recording on then we're stopping voice recording binding.inputBar.voiceRecorderState = VoiceRecorderState.ShuttingDownAfterRecord - val rba = binding.inputBarRecordingView?.recordButtonOverlay - if (rba != null) { - val location = IntArray(2) { 0 } - rba.getLocationOnScreen(location) - val hitRect = Rect(location[0], location[1], location[0] + rba.width, location[1] + rba.height) + val recordButtonOverlay = binding.inputBarRecordingView.recordButtonOverlay - // If the up event occurred over the record button overlay we send the voice message.. - if (hitRect.contains(x, y)) { - sendVoiceMessage() - } else { - // ..otherwise if they've released off the button we'll cancel sending. - cancelVoiceMessage() - } - } - else - { - // Just to cover all our bases, if for whatever reason the record button overlay was null we'll also cancel recording + val location = IntArray(2) { 0 } + recordButtonOverlay.getLocationOnScreen(location) + val hitRect = Rect(location[0], location[1], location[0] + recordButtonOverlay.width, location[1] + recordButtonOverlay.height) + + // If the up event occurred over the record button overlay we send the voice message.. + if (hitRect.contains(x, y)) { + sendVoiceMessage() + } else { + // ..otherwise if they've released off the button we'll cancel sending. cancelVoiceMessage() } } } + override fun onCharLimitTapped() { + viewModel.onCharLimitTapped() + } + private fun isValidLockViewLocation(x: Int, y: Int): Boolean { // We can be anywhere above the lock view and a bit to the side of it (at most `lockViewHitMargin` // to the side) @@ -1786,9 +1922,27 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe return hitRect.contains(x, y) } - override fun scrollToMessageIfPossible(timestamp: Long) { - val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return - binding.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) + override fun highlightMessageFromTimestamp(timestamp: Long) { + // Try to find the message with the given timestamp + adapter.getItemPositionForTimestamp(timestamp)?.let { targetMessagePosition -> + + // If the view is already visible then we don't have to scroll before highlighting it.. + binding.conversationRecyclerView.findViewHolderForLayoutPosition(targetMessagePosition)?.let { viewHolder -> + if (viewHolder.itemView is VisibleMessageView) { + (viewHolder.itemView as VisibleMessageView).playHighlight() + return + } + } + + // ..otherwise, set the pending highlight target and trigger a scroll. + // Note: If the targeted message isn't the very first one then we scroll slightly past it to give it some breathing room. + // Also: The offset must be negative to provide room above it. + pendingHighlightMessagePosition = targetMessagePosition + currentTargetedScrollOffsetPx = if (targetMessagePosition > 0) nonFirstMessageOffsetPx else 0 + linearSmoothScroller.targetPosition = targetMessagePosition + (binding.conversationRecyclerView.layoutManager as? LinearLayoutManager)?.startSmoothScroll(linearSmoothScroller) + + } ?: Log.i(TAG, "Could not find message with timestamp: $timestamp") } override fun onReactionClicked(emoji: String, messageId: MessageId, userWasSender: Boolean) { @@ -1808,13 +1962,22 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (viewModel.recipient?.isGroupOrCommunityRecipient == true) { val isUserModerator = viewModel.openGroup?.let { openGroup -> val userPublicKey = textSecurePreferences.getLocalNumber() ?: return@let false - OpenGroupManager.isUserModerator(this, openGroup.id, userPublicKey, viewModel.blindedPublicKey) + openGroupManager.isUserModerator( + openGroup.id, + userPublicKey, + viewModel.blindedPublicKey + ) } ?: false val fragment = ReactionsDialogFragment.create(messageId, isUserModerator, emoji, viewModel.canRemoveReaction) - fragment.show(supportFragmentManager, null) + fragment.show(supportFragmentManager, TAG_REACTION_FRAGMENT) } } + private fun dismissReactionsDialog() { + val fragment = supportFragmentManager.findFragmentByTag(TAG_REACTION_FRAGMENT) as? ReactionsDialogFragment + fragment?.dismissAllowingStateLoss() + } + override fun playVoiceMessageAtIndexIfPossible(indexInAdapter: Int) { if (!textSecurePreferences.autoplayAudioMessages()) return @@ -1823,12 +1986,22 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe viewHolder.view.playVoiceMessage() } + override fun showUserProfileModal(recipient: Recipient){ + viewModel.showUserProfileModal(recipient) + } + override fun sendMessage() { val recipient = viewModel.recipient ?: return + + // show the unblock dialog when trying to send a message to a blocked contact if (recipient.isContactRecipient && recipient.isBlocked) { - BlockedDialog(recipient, this).show(supportFragmentManager, "Blocked Dialog") + BlockedDialog(recipient, viewModel.getUsername(recipient.address.toString())).show(supportFragmentManager, "Blocked Dialog") return } + + // validate message length before sending + if(!viewModel.validateMessageLength()) return + val sentMessageInfo = if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) { sendAttachments(listOf(), getMessageBody(), binding.inputBar.quote, binding.inputBar.linkPreview) } else { @@ -1844,14 +2017,25 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun commitInputContent(contentUri: Uri) { val recipient = viewModel.recipient ?: return - val media = Media(contentUri, MediaUtil.getMimeType(this, contentUri)!!, 0, 0, 0, 0, Optional.absent(), Optional.absent()) - startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), recipient, getMessageBody()), PICK_FROM_LIBRARY) + val mimeType = MediaUtil.getMimeType(this, contentUri)!! + val filename = FilenameUtils.getFilenameFromUri(this, contentUri, mimeType) + val media = Media(contentUri, filename, mimeType, 0, 0, 0, 0, null, null) + startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), recipient, threadId, getMessageBody()), PICK_FROM_LIBRARY) + } + + // If we previously approve this recipient, either implicitly or explicitly, we need to wait for + // that submission to complete first. + private suspend fun waitForApprovalJobToBeSubmitted() { + withContext(Dispatchers.Main) { + conversationApprovalJob?.join() + conversationApprovalJob = null + } } private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair? { val recipient = viewModel.recipient ?: return null val sentTimestamp = SnodeAPI.nowWithOffset - viewModel.beforeSendingTextOnlyMessage() + viewModel.implicitlyApproveRecipient()?.let { conversationApprovalJob = it } val text = getMessageBody() val userPublicKey = textSecurePreferences.getLocalNumber() val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey) @@ -1865,46 +2049,51 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe return null } + // Create the message val message = VisibleMessage().applyExpiryMode(viewModel.threadId) message.sentTimestamp = sentTimestamp message.text = text 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!!) + val outgoingTextMessage = OutgoingTextMessage.from(message, recipient, expiresInMillis, 0) + // Clear the input bar binding.inputBar.text = "" binding.inputBar.cancelQuoteDraft() binding.inputBar.cancelLinkPreviewDraft() lifecycleScope.launch(Dispatchers.Default) { - // Put the message in the database - message.id = smsDb.insertMessageOutbox( + // Put the message in the database and send it + message.id = MessageId(smsDb.insertMessageOutbox( viewModel.threadId, outgoingTextMessage, false, message.sentTimestamp!!, - null, true - ) - // Send it + ), false) + + waitForApprovalJobToBeSubmitted() MessageSender.send(message, recipient.address) } // Send a typing stopped message - ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) + typingStatusSender.onTypingStopped(viewModel.threadId) return Pair(recipient.address, sentTimestamp) } private fun sendAttachments( attachments: List, body: String?, - quotedMessage: MessageRecord? = binding.inputBar?.quote, - linkPreview: LinkPreview? = null + quotedMessage: MessageRecord? = binding.inputBar.quote, + linkPreview: LinkPreview? = null, + deleteAttachmentFilesAfterSave: Boolean = false, ): Pair? { - val recipient = viewModel.recipient ?: return null + if (viewModel.recipient == null) { + Log.w(TAG, "Cannot send attachments to a null recipient") + return null + } + val recipient = viewModel.recipient!! val sentTimestamp = SnodeAPI.nowWithOffset - viewModel.beforeSendingAttachments() + viewModel.implicitlyApproveRecipient()?.let { conversationApprovalJob = it } + // Create the message val message = VisibleMessage().applyExpiryMode(viewModel.threadId) message.sentTimestamp = sentTimestamp @@ -1927,31 +2116,54 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe sentTimestamp } else 0 val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, localQuote, linkPreview, expiresInMs, expireStartedAtMs) + // Clear the input bar binding.inputBar.text = "" binding.inputBar.cancelQuoteDraft() binding.inputBar.cancelLinkPreviewDraft() + // Reset the attachment manager attachmentManager.clear() + // Reset attachments button if needed if (isShowingAttachmentOptions) { toggleAttachmentOptions() } // do the heavy work in the bg - lifecycleScope.launch(Dispatchers.IO) { - // Put the message in the database - message.id = mmsDb.insertMessageOutbox( - outgoingTextMessage, - viewModel.threadId, - false, - null, - runThreadUpdate = true - ) - // Send it - MessageSender.send(message, recipient.address, attachments, quote, linkPreview) + lifecycleScope.launch(Dispatchers.Default) { + runCatching { + // Put the message in the database and send it + message.id = MessageId( + mmsDb.insertMessageOutbox( + outgoingTextMessage, + viewModel.threadId, + false, + runThreadUpdate = true + ), mms = true + ) + + if (deleteAttachmentFilesAfterSave) { + attachments + .asSequence() + .mapNotNull { a -> a.dataUri?.takeIf { it.scheme == "file" }?.path?.let(::File) } + .filter { it.exists() } + .forEach { it.delete() } + } + + waitForApprovalJobToBeSubmitted() + + MessageSender.send(message, recipient.address, quote, linkPreview) + }.onFailure { + withContext(Dispatchers.Main){ + when (it) { + is MmsException -> Toast.makeText(this@ConversationActivityV2, R.string.attachmentsErrorSending, Toast.LENGTH_LONG).show() + else -> Toast.makeText(this@ConversationActivityV2, R.string.errorGeneric, Toast.LENGTH_LONG).show() + } + } + } } // Send a typing stopped message - ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) + typingStatusSender.onTypingStopped(viewModel.threadId) return Pair(recipient.address, sentTimestamp) } @@ -1974,24 +2186,25 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun selectGif() = AttachmentManager.selectGif(this, PICK_GIF) - private fun showDocumentPicker() { - AttachmentManager.selectDocument(this, PICK_DOCUMENT) - } + private fun showDocumentPicker() = AttachmentManager.selectDocument(this, PICK_DOCUMENT) private fun pickFromLibrary() { val recipient = viewModel.recipient ?: return - binding.inputBar.text?.trim()?.let { text -> - AttachmentManager.selectGallery(this, PICK_FROM_LIBRARY, recipient, text) - } + AttachmentManager.selectGallery(this, PICK_FROM_LIBRARY, recipient, threadId, + getMessageBody()) } private fun showCamera() { - attachmentManager.capturePhoto(this, TAKE_PHOTO, viewModel.recipient); + attachmentManager.capturePhoto( + this, + TAKE_PHOTO, + viewModel.recipient, + threadId, + getMessageBody() + ) } - override fun onAttachmentChanged() { - // Do nothing - } + override fun onAttachmentChanged() { /* Do nothing */ } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) @@ -2001,6 +2214,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { super.onActivityResult(requestCode, resultCode, intent) + val mediaPreppedListener = object : ListenableFuture.Listener { override fun onSuccess(result: Boolean?) { @@ -2021,22 +2235,29 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // Note: The only multi-attachment message type is when sending images - all others // attempt send the attachment immediately upon file selection. sendAttachments(attachmentManager.buildSlideDeck().asAttachments(), null) + //todo: The current system sends the document the moment it has been selected, without text (body is set to null above) - We will want to fix this and allow the user to add text with a document AND be able to confirm before sending + //todo: Simply setting body to getMessageBody() above isn't good enough as it doesn't give the user a chance to confirm their message before sending it. } override fun onFailure(e: ExecutionException?) { Toast.makeText(this@ConversationActivityV2, R.string.attachmentsErrorLoad, Toast.LENGTH_LONG).show() } } + + // Note: In the case of documents or GIFs, filename provision is performed as part of the + // `prepMediaForSending` operations, while for images it occurs when Media is created in + // this class' `commitInputContent` method. when (requestCode) { PICK_DOCUMENT -> { - val uri = intent?.data ?: return + intent ?: return Log.w(TAG, "Failed to get document Intent") + val uri = intent.data ?: return Log.w(TAG, "Failed to get document Uri") prepMediaForSending(uri, AttachmentManager.MediaType.DOCUMENT).addListener(mediaPreppedListener) } PICK_GIF -> { - intent ?: return - val uri = intent.data ?: return - val type = AttachmentManager.MediaType.GIF - val width = intent.getIntExtra(GiphyActivity.EXTRA_WIDTH, 0) + intent ?: return Log.w(TAG, "Failed to get GIF Intent") + val uri = intent.data ?: return Log.w(TAG, "Failed to get picked GIF Uri") + val type = AttachmentManager.MediaType.GIF + val width = intent.getIntExtra(GiphyActivity.EXTRA_WIDTH, 0) val height = intent.getIntExtra(GiphyActivity.EXTRA_HEIGHT, 0) prepMediaForSending(uri, type, width, height).addListener(mediaPreppedListener) } @@ -2044,67 +2265,65 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe TAKE_PHOTO -> { intent ?: return val body = intent.getStringExtra(MediaSendActivity.EXTRA_MESSAGE) - val media = intent.getParcelableArrayListExtra(MediaSendActivity.EXTRA_MEDIA) ?: return + val mediaList = intent.getParcelableArrayListExtra(MediaSendActivity.EXTRA_MEDIA) ?: return val slideDeck = SlideDeck() - for (item in media) { + for (media in mediaList) { + val mediaFilename: String? = media.filename when { - MediaUtil.isVideoType(item.mimeType) -> { - slideDeck.addSlide(VideoSlide(this, item.uri, 0, item.caption.orNull())) - } - MediaUtil.isGif(item.mimeType) -> { - slideDeck.addSlide(GifSlide(this, item.uri, 0, item.width, item.height, item.caption.orNull())) - } - MediaUtil.isImageType(item.mimeType) -> { - slideDeck.addSlide(ImageSlide(this, item.uri, 0, item.width, item.height, item.caption.orNull())) - } + MediaUtil.isVideoType(media.mimeType) -> { slideDeck.addSlide(VideoSlide(this, media.uri, mediaFilename, 0, media.caption)) } + MediaUtil.isGif(media.mimeType) -> { slideDeck.addSlide(GifSlide(this, media.uri, mediaFilename, 0, media.width, media.height, media.caption)) } + MediaUtil.isImageType(media.mimeType) -> { slideDeck.addSlide(ImageSlide(this, media.uri, mediaFilename, 0, media.width, media.height, media.caption)) } else -> { - Log.d("Loki", "Asked to send an unexpected media type: '" + item.mimeType + "'. Skipping.") + Log.d(TAG, "Asked to send an unexpected media type: '" + media.mimeType + "'. Skipping.") } } } sendAttachments(slideDeck.asAttachments(), body) } - INVITE_CONTACTS -> { - if (viewModel.recipient?.isCommunityRecipient != true) { return } - val extras = intent?.extras ?: return - if (!intent.hasExtra(selectedContactsKey)) { return } - val selectedContacts = extras.getStringArray(selectedContactsKey)!! - val recipients = selectedContacts.map { contact -> - Recipient.from(this, fromSerialized(contact), true) - } - viewModel.inviteContacts(recipients) - } } } - private fun prepMediaForSending(uri: Uri, type: AttachmentManager.MediaType): ListenableFuture { - return prepMediaForSending(uri, type, null, null) - } + private fun prepMediaForSending(uri: Uri, type: AttachmentManager.MediaType): ListenableFuture = prepMediaForSending(uri, type, null, null) private fun prepMediaForSending(uri: Uri, type: AttachmentManager.MediaType, width: Int?, height: Int?): ListenableFuture { return attachmentManager.setMedia(glide, uri, type, MediaConstraints.getPushMediaConstraints(), width ?: 0, height ?: 0) } override fun startRecordingVoiceMessage() { + Log.i(TAG, "Starting voice message recording at: ${System.currentTimeMillis()} --- ${binding.inputBar.voiceRecorderState}") + binding.inputBar.voiceRecorderState = VoiceRecorderState.SettingUpToRecord + if (Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO)) { - showVoiceMessageUI() - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - - // Allow the caller (us!) to define what should happen when the voice recording finishes. - // Specifically in this instance, if we just tap the record audio button then by the time - // we actually finish setting up and get here the recording has been cancelled and the voice - // recorder state is Idle! As such we'll only tick the recorder state over to Recording if - // we were still in the SettingUpToRecord state when we got here (i.e., the record voice - // message button is still held or is locked to keep recording audio without being held). - val callback: () -> Unit = { - if (binding.inputBar.voiceRecorderState == VoiceRecorderState.SettingUpToRecord) { - binding.inputBar.voiceRecorderState = VoiceRecorderState.Recording + // Cancel any previous recording attempt + audioRecorderHandle?.cancel() + audioRecorderHandle = null + + audioRecorderHandle = recordAudio(lifecycleScope, this@ConversationActivityV2).also { + it.addOnStartedListener { result -> + if (result.isSuccess) { + showVoiceMessageUI() + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + voiceMessageStartTimestamp = System.currentTimeMillis() + binding.inputBar.voiceRecorderState = VoiceRecorderState.Recording + + // Limit voice messages to 5 minute each + stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 5.minutes.inWholeMilliseconds) + } else { + Log.e(TAG, "Error while starting voice message recording", result.exceptionOrNull()) + hideVoiceMessageUI() + binding.inputBar.voiceRecorderState = VoiceRecorderState.Idle + Toast.makeText( + this@ConversationActivityV2, + R.string.audioUnableToRecord, + Toast.LENGTH_LONG + ).show() + } } } - audioRecorder.startRecording(callback) - - stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 300000) // Limit voice messages to 5 minute each } else { + binding.inputBar.voiceRecorderState = VoiceRecorderState.Idle + Permissions.with(this) .request(Manifest.permission.RECORD_AUDIO) .withPermanentDenialDialog(Phrase.from(applicationContext, R.string.permissionsMicrophoneAccessRequired) @@ -2114,75 +2333,84 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - override fun sendVoiceMessage() { + private fun stopRecording(send: Boolean) { // When the record voice message button is released we always need to reset the UI and cancel - // any further recording operation.. + // any further recording operation. hideVoiceMessageUI() window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - val future = audioRecorder.stopRecording() + + binding.inputBar.voiceRecorderState = VoiceRecorderState.Idle + + // Clear the audio session immediately for the next recording attempt + val handle = audioRecorderHandle + audioRecorderHandle = null stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask) - // ..but we'll bail without sending the voice message & inform the user that they need to press and HOLD - // the record voice message button if their message was less than 1 second long. - val inputBar = binding.inputBar - val voiceMessageDurationMS = inputBar.voiceMessageDurationMS - - // Now tear-down is complete we can move back into the idle state ready to record another voice message. - // CAREFUL: This state must be set BEFORE we show any warning toast about short messages because it early - // exits before transmitting the audio! - inputBar.voiceRecorderState = VoiceRecorderState.Idle - - // Voice message too short? Warn with toast instead of sending. - // Note: The 0L check prevents the warning toast being shown when leaving the conversation activity. - if (voiceMessageDurationMS != 0L && voiceMessageDurationMS < MINIMUM_VOICE_MESSAGE_DURATION_MS) { - Toast.makeText(this@ConversationActivityV2, R.string.messageVoiceErrorShort, Toast.LENGTH_SHORT).show() - inputBar.voiceMessageDurationMS = 0L + if (handle == null) { + Log.w(TAG, "Audio recorder handle is null - cannot stop recording") return } - // Note: We could return here if there was a network or node path issue, but instead we'll try - // our best to send the voice message even if it might fail - because in that case it'll get put - // into the draft database and can be retried when we regain network connectivity and a working - // node path. + if (!MediaUtil.voiceMessageMeetsMinimumDuration(System.currentTimeMillis() - voiceMessageStartTimestamp)) { + handle.cancel() + // If the voice message is too short, we show a toast and return early + Log.w(TAG, "Voice message is too short: ${System.currentTimeMillis() - voiceMessageStartTimestamp}ms") + voiceNoteTooShortToast.setText(applicationContext.getString(R.string.messageVoiceErrorShort)) + showVoiceMessageToastIfNotAlreadyVisible() + return + } - // Attempt to send it the voice message - future.addListener(object : ListenableFuture.Listener> { + // If we don't send, we'll cancel the audio recording + if (!send) { + handle.cancel() + return + } + + // If we do send, we will stop the audio recording, wait for it to complete successfully, + // then send the audio message as an attachment. + lifecycleScope.launch { + try { + val result = handle.stop() + + // Generate a filename from the current time such as: "Session-VoiceMessage_2025-01-08-152733.aac" + val voiceMessageFilename = FilenameUtils.constructNewVoiceMessageFilename(applicationContext) + + val audioSlide = AudioSlide(this@ConversationActivityV2, + Uri.fromFile(result.file), + voiceMessageFilename, + result.length, + MediaTypes.AUDIO_MP4, + true, + result.duration.inWholeMilliseconds) - override fun onSuccess(result: Pair) { - val audioSlide = AudioSlide(this@ConversationActivityV2, result.first, result.second, MediaTypes.AUDIO_AAC, true) val slideDeck = SlideDeck() slideDeck.addSlide(audioSlide) - sendAttachments(slideDeck.asAttachments(), null) - } + sendAttachments(slideDeck.asAttachments(), body = null, deleteAttachmentFilesAfterSave = true) - override fun onFailure(e: ExecutionException) { + } catch (ec: CancellationException) { + // If we get cancelled then do nothing + throw ec + } catch (ec: Exception) { + Log.e(TAG, "Error while recording", ec) Toast.makeText(this@ConversationActivityV2, R.string.audioUnableToRecord, Toast.LENGTH_LONG).show() } - }) + } } - override fun cancelVoiceMessage() { - val inputBar = binding.inputBar - - hideVoiceMessageUI() - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - audioRecorder.stopRecording() - stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask) + override fun sendVoiceMessage() { + Log.i(TAG, "Sending voice message at: ${System.currentTimeMillis()}") - // Note: The 0L check prevents the warning toast being shown when leaving the conversation activity - val voiceMessageDuration = inputBar.voiceMessageDurationMS - if (voiceMessageDuration != 0L && voiceMessageDuration < MINIMUM_VOICE_MESSAGE_DURATION_MS) { - Toast.makeText(applicationContext, applicationContext.getString(R.string.messageVoiceErrorShort), Toast.LENGTH_SHORT).show() - inputBar.voiceMessageDurationMS = 0L - } + stopRecording(true) + } - // When tear-down is complete (via cancelling) we can move back into the idle state ready to record - // another voice message. - inputBar.voiceRecorderState = VoiceRecorderState.Idle + // Cancel voice message is called when the user is press-and-hold recording a voice message and then + // slides the microphone icon left, or when they lock voice recording on but then later click Cancel. + override fun cancelVoiceMessage() { + stopRecording(false) } override fun selectMessages(messages: Set) { - selectMessage(messages.first(), 0) //TODO: begin selection mode + selectMessage(messages.first()) } // Note: The messages in the provided set may be a single message, or multiple if there are a @@ -2226,7 +2454,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (TextUtils.isEmpty(body)) { continue } if (messageSize > 1) { - val formattedTimestamp = DateUtils.getDisplayFormattedTimeSpanString(this, Locale.getDefault(), message.timestamp) + val formattedTimestamp = dateUtils.getDisplayFormattedTimeSpanString( + message.timestamp + ) builder.append("$formattedTimestamp: ") } builder.append(body) @@ -2245,7 +2475,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe endActionMode() } - override fun copyAccountID(messages: Set) { + private fun copyAccountID(messages: Set) { val accountID = messages.first().individualRecipient.address.toString() val clip = ClipData.newPlainText("Account ID", accountID) val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager @@ -2269,8 +2499,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private val handleMessageDetail = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> - val message = result.data?.extras?.getLong(MESSAGE_TIMESTAMP) - ?.let(mmsSmsDb::getMessageForTimestamp) + val message = result.data?.let { IntentCompat.getParcelableExtra(it, MessageDetailActivity.MESSAGE_ID, MessageId::class.java) } + ?.let(mmsSmsDb::getMessageById) val set = setOfNotNull(message) @@ -2287,7 +2517,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun showMessageDetail(messages: Set) { Intent(this, MessageDetailActivity::class.java) - .apply { putExtra(MESSAGE_TIMESTAMP, messages.first().timestamp) } + .apply { putExtra(MessageDetailActivity.MESSAGE_ID, messages.first().let { + MessageId(it.id, it.isMms) + }) } .let { handleMessageDetail.launch(it) overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) @@ -2299,7 +2531,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun saveAttachments(message: MmsMessageRecord) { val attachments: List = Stream.of(message.slideDeck.slides) .filter { s: Slide -> s.uri != null && (s.hasImage() || s.hasVideo() || s.hasAudio() || s.hasDocument()) } - .map { s: Slide -> SaveAttachmentTask.Attachment(s.uri!!, s.contentType, message.dateReceived, s.fileName.orNull()) } + .map { s: Slide -> SaveAttachmentTask.Attachment(s.uri!!, s.contentType, message.dateReceived, s.filename) } .toList() if (attachments.isNotEmpty()) { val saveTask = SaveAttachmentTask(this) @@ -2392,6 +2624,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun reply(messages: Set) { val recipient = viewModel.recipient ?: return + + // hide search if open + if(binding.searchBottomBar.isVisible) onSearchClosed() + messages.firstOrNull()?.let { binding.inputBar.draftQuote(recipient, it, glide) } endActionMode() } @@ -2436,34 +2672,39 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (result.getResults().isNotEmpty()) { result.getResults()[result.position]?.let { jumpToMessage(it.messageRecipient.address, it.sentTimestampMs, true) { - searchViewModel.onMissingResult() } + searchViewModel.onMissingResult() + } } } - binding.searchBottomBar.setData(result.position, result.getResults().size) + binding.searchBottomBar.setData(result.position, result.getResults().size, searchViewModel.searchQuery.value) }) } fun onSearchOpened() { + viewModel.onSearchOpened() searchViewModel.onSearchOpened() binding.searchBottomBar.visibility = View.VISIBLE - binding.searchBottomBar.setData(0, 0) - binding.inputBar.visibility = View.INVISIBLE + binding.searchBottomBar.setData(0, 0, searchViewModel.searchQuery.value) + binding.inputBar.visibility = View.GONE + binding.root.requestApplyInsets() } fun onSearchClosed() { + viewModel.onSearchClosed() searchViewModel.onSearchClosed() binding.searchBottomBar.visibility = View.GONE binding.inputBar.visibility = View.VISIBLE + binding.root.requestApplyInsets() adapter.onSearchQueryUpdated(null) invalidateOptionsMenu() } fun onSearchQueryUpdated(query: String) { - searchViewModel.onQueryUpdated(query, viewModel.threadId) binding.searchBottomBar.showLoading() - adapter.onSearchQueryUpdated(query) + searchViewModel.onQueryUpdated(query, viewModel.threadId) + adapter.onSearchQueryUpdated(query.takeUnless { it.length < 2 }) } override fun onSearchMoveUpPressed() { @@ -2474,13 +2715,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe this.searchViewModel.onMoveDown() } - override fun onNicknameSaved() { - adapter.notifyDataSetChanged() - } - private fun jumpToMessage(author: Address, timestamp: Long, highlight: Boolean, onMessageNotFound: Runnable?) { SimpleTask.run(lifecycle, { - mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, timestamp, author, reverseMessageList) + mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, timestamp, author, false) }) { p: Int -> moveToMessagePosition(p, highlight, onMessageNotFound) } } @@ -2518,19 +2755,4 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } } - - // AdapterDataObserver implementation to scroll us to the bottom of the ConversationRecyclerView - // when we're already near the bottom and we send or receive a message. - inner class ConversationAdapterDataObserver(val recyclerView: ConversationRecyclerView, val adapter: ConversationAdapter) : RecyclerView.AdapterDataObserver() { - override fun onChanged() { - super.onChanged() - if (recyclerView.isScrolledToWithin30dpOfBottom) { - // Note: The adapter itemCount is zero based - so calling this with the itemCount in - // a non-zero based manner scrolls us to the bottom of the last message (including - // to the bottom of long messages as required by Jira SES-789 / GitHub 1364). - recyclerView.smoothScrollToPosition(adapter.itemCount) - } - } - } - } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt index 9bbc28e67d..2f40d76ed8 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 @@ -2,17 +2,15 @@ package org.thoughtcrime.securesms.conversation.v2 import android.content.Context import android.database.Cursor -import android.util.SparseArray -import android.util.SparseBooleanArray import android.view.MotionEvent import android.view.View import android.view.ViewGroup import androidx.annotation.WorkerThread -import androidx.core.util.getOrDefault -import androidx.core.util.set import androidx.lifecycle.LifecycleCoroutineScope import androidx.recyclerview.widget.RecyclerView.ViewHolder import com.bumptech.glide.RequestManager +import java.util.concurrent.atomic.AtomicLong +import kotlin.math.min import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel @@ -26,22 +24,24 @@ import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import java.util.concurrent.atomic.AtomicLong -import kotlin.math.min +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.max class ConversationAdapter( context: Context, - cursor: Cursor, + cursor: Cursor?, conversation: Recipient?, 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, View) -> Unit, - private val onDeselect: (MessageRecord, Int) -> Unit, - private val onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit, + private val onDeselect: (MessageRecord) -> Unit, + private val downloadPendingAttachment: (DatabaseAttachment) -> Unit, + private val retryFailedAttachments: (List) -> Unit, private val glide: RequestManager, lifecycleCoroutineScope: LifecycleCoroutineScope ) : CursorRecyclerViewAdapter(context, cursor) { @@ -53,29 +53,37 @@ class ConversationAdapter( var visibleMessageViewDelegate: VisibleMessageViewDelegate? = null private val updateQueue = Channel(1024, onBufferOverflow = BufferOverflow.DROP_OLDEST) - private val contactCache = SparseArray(100) - private val contactLoadedCache = SparseBooleanArray(100) + private val contactCache = ConcurrentHashMap(100) + private val contactLoadedCache = ConcurrentHashMap(100) private val lastSeen = AtomicLong(originalLastSeen) + var lastSentMessageId: MessageId? = null + set(value) { + if (field != value) { + field = value + notifyDataSetChanged() + } + } + private val groupId = if(conversation?.isGroupV2Recipient == true) - AccountId(conversation.address.serialize()) + AccountId(conversation.address.toString()) else null + private val expandedMessageIds = mutableSetOf() + init { lifecycleCoroutineScope.launch(IO) { while (isActive) { val item = updateQueue.receive() val contact = getSenderInfo(item) ?: continue - contactCache[item.hashCode()] = contact - contactLoadedCache[item.hashCode()] = true + contactCache[item] = contact + contactLoadedCache[item] = true } } } @WorkerThread - private fun getSenderInfo(sender: String): Contact? { - return contactDB.getContactWithAccountID(sender) - } + private fun getSenderInfo(sender: String): Contact? = contactDB.getContactWithAccountID(sender) sealed class ViewType(val rawValue: Int) { object Visible : ViewType(0) @@ -117,35 +125,41 @@ class ConversationAdapter( is VisibleMessageViewHolder -> { val visibleMessageView = viewHolder.view val isSelected = selectedItems.contains(message) - visibleMessageView.snIsSelected = isSelected + visibleMessageView.isMessageSelected = isSelected visibleMessageView.indexInAdapter = position - val senderId = message.individualRecipient.address.serialize() - val senderIdHash = senderId.hashCode() + val senderId = message.individualRecipient.address.toString() updateQueue.trySend(senderId) - if (contactCache[senderIdHash] == null && !contactLoadedCache.getOrDefault( - senderIdHash, + if (contactCache[senderId] == null && !contactLoadedCache.getOrDefault( + senderId, false ) ) { getSenderInfo(senderId)?.let { contact -> - contactCache[senderIdHash] = contact + contactCache[senderId] = contact } } - val contact = contactCache[senderIdHash] + val contact = contactCache[senderId] + val isExpanded = expandedMessageIds.contains(message.messageId) visibleMessageView.bind( - message, - messageBefore, - getMessageAfter(position, cursor), - glide, - searchQuery, - contact, + message = message, + previous = messageBefore, + next = getMessageAfter(position, cursor), + glide = glide, + searchQuery = searchQuery, + contact = contact, // we pass in the groupId for groupV2 to use for determining the name of the members - groupId, - senderId, - lastSeen.get(), - visibleMessageViewDelegate, - onAttachmentNeedsDownload + groupId = groupId, + senderAccountID = senderId, + lastSeen = lastSeen.get(), + lastSentMessageId = lastSentMessageId, + delegate = visibleMessageViewDelegate, + downloadPendingAttachment = downloadPendingAttachment, + retryFailedAttachments = retryFailedAttachments, + isTextExpanded = isExpanded, + onTextExpanded = { messageId -> + expandedMessageIds.add(messageId) + } ) if (!message.isDeleted) { @@ -180,9 +194,19 @@ class ConversationAdapter( } } - fun toggleSelection(message: MessageRecord, position: Int) { + private fun getItemPositionForId(target: MessageId): Int? { + val c = cursor ?: return null + for (i in 0 until itemCount) { + if (!c.moveToPosition(i)) break + val rec = messageDB.readerFor(c).current ?: continue + if (rec.messageId == target) return i + } + return null + } + + fun toggleSelection(message: MessageRecord) { if (selectedItems.contains(message)) selectedItems.remove(message) else selectedItems.add(message) - notifyItemChanged(position) + getItemPositionForId(message.messageId)?.let { notifyItemChanged(it) } } override fun onItemViewRecycled(viewHolder: ViewHolder?) { @@ -193,9 +217,7 @@ class ConversationAdapter( super.onItemViewRecycled(viewHolder) } - private fun getMessage(cursor: Cursor): MessageRecord? { - return messageDB.readerFor(cursor).current - } + private fun getMessage(cursor: Cursor): MessageRecord? = messageDB.readerFor(cursor).current private fun getMessageBefore(position: Int, cursor: Cursor): MessageRecord? { // The message that's visually before the current one is actually after the current @@ -219,21 +241,21 @@ class ConversationAdapter( super.changeCursor(cursor) val toRemove = mutableSetOf() - val toDeselect = mutableSetOf>() + val toDeselect = mutableSetOf() for (selected in selectedItems) { - val position = getItemPositionForTimestamp(selected.timestamp) + val position = getItemPositionForId(selected.messageId) if (position == null || position == -1) { toRemove += selected } else { val item = getMessage(getCursorAtPositionOrThrow(position)) if (item == null || item.isDeleted) { - toDeselect += position to selected + toDeselect += selected } } } selectedItems -= toRemove - toDeselect.iterator().forEach { (pos, record) -> - onDeselect(record, pos) + toDeselect.iterator().forEach { record -> + onDeselect(record) } } @@ -287,6 +309,13 @@ class ConversationAdapter( val cursor = this.cursor ?: return null if (!cursor.moveToPosition(firstVisiblePosition)) return null val message = messageDB.readerFor(cursor).current ?: return null - return message.timestamp + if (message.reactions.isEmpty()) { + // If the message has no reactions, we can use the timestamp directly + return message.timestamp + } + + // Otherwise, we will need to take the reaction timestamp into account + val maxReactionTimestamp = message.reactions.maxOf { it.dateReceived } + return max(message.timestamp, maxReactionTimestamp) } } \ 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 2ac613bf66..4692bf7862 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,7 +2,6 @@ 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 @@ -13,7 +12,6 @@ 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.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index 82ed3efc2b..335d34080c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.conversation.v2 import android.animation.Animator import android.animation.AnimatorSet import android.animation.ObjectAnimator -import android.animation.ValueAnimator +import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.graphics.PointF @@ -20,43 +20,47 @@ import android.widget.LinearLayout import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat +import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import androidx.core.view.doOnLayout import androidx.core.view.isVisible import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CoroutineScope +import java.util.Locale +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.LocalisedTimeUtil.toShortTwoPartString import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager +import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber import org.session.libsession.utilities.ThemeUtil +import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.Log 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.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase 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.groups.OpenGroupManager 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.Companion.milliseconds +import org.thoughtcrime.securesms.util.applySafeInsetsPaddings @AndroidEntryPoint class ConversationReactionOverlay : FrameLayout { @@ -75,8 +79,6 @@ class ConversationReactionOverlay : FrameLayout { 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 @@ -100,14 +102,23 @@ class ConversationReactionOverlay : FrameLayout { @Inject lateinit var mmsSmsDatabase: MmsSmsDatabase @Inject lateinit var repository: ConversationRepository + @Inject lateinit var dateUtils: DateUtils @Inject lateinit var lokiThreadDatabase: LokiThreadDatabase @Inject lateinit var threadDatabase: ThreadDatabase @Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var deprecationManager: LegacyGroupDeprecationManager + @Inject lateinit var openGroupManager: OpenGroupManager - private val scope = CoroutineScope(Dispatchers.Default) private var job: Job? = null + private val iconMore by lazy { + val d = ContextCompat.getDrawable(context, R.drawable.ic_plus) + d?.setTint(context.getColorFromAttr(android.R.attr.textColor)) + d + } + + private var systemInsets: Insets = Insets.NONE + constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) @@ -128,6 +139,27 @@ class ConversationReactionOverlay : FrameLayout { scrubberHorizontalMargin = resources.getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin) animationEmojiStartDelayFactor = resources.getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor) initAnimators() + + // Use your existing utility to handle insets + applySafeInsetsPaddings( + typeMask = WindowInsetsCompat.Type.systemBars(), + consumeInsets = false, // Don't consume so children can also access them + applyTop = false, // Don't apply as padding, just capture the values + applyBottom = false + ) { insets -> + // Store the insets for our layout calculations + systemInsets = insets + } + } + + private fun getAvailableScreenHeight(): Int { + val displayMetrics = resources.displayMetrics + return displayMetrics.heightPixels - systemInsets.top - systemInsets.bottom + } + + private fun getAvailableScreenWidth(): Int { + val displayMetrics = resources.displayMetrics + return displayMetrics.widthPixels - systemInsets.left - systemInsets.right } fun show(activity: Activity, @@ -149,20 +181,23 @@ class ConversationReactionOverlay : FrameLayout { 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) + conversationTimestamp.text = dateUtils.getDisplayFormattedTimeSpanString(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) { + job = GlobalScope.launch { + // Wait for the message to be deleted repository.changes(messageRecord.threadId) - .filter { mmsSmsDatabase.getMessageForTimestamp(messageRecord.timestamp) == null } - .collect { withContext(Dispatchers.Main) { hide() } } + .first { mmsSmsDatabase.getMessageById(messageRecord.messageId) == null } + + withContext(Dispatchers.Main) { + hide() + } } } @@ -176,13 +211,18 @@ class ConversationReactionOverlay : FrameLayout { val recipient = threadDatabase.getRecipientForThreadId(messageRecord.threadId) val contextMenu = ConversationContextMenu(dropdownAnchor, recipient?.let { getMenuActionItems(messageRecord, it) }.orEmpty()) 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 + + // Use our own available height calculation + val availableHeight = getAvailableScreenHeight() + val bubbleWidth = selectedConversationModel.bubbleWidth var endApparentTop = endY var endScale = 1f @@ -190,8 +230,12 @@ class ConversationReactionOverlay : FrameLayout { val reactionBarTopPadding = DimensionUnit.DP.toPixels(32f) val reactionBarHeight = backgroundView.height var reactionBarBackgroundY: Float + + // Use actual content height from context menu + val actualMenuHeight = contextMenu.getMaxHeight() + if (isWideLayout) { - val everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + conversationItemSnapshot.height < overlayHeight + val everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + conversationItemSnapshot.height < availableHeight if (everythingFitsVertically) { val reactionBarFitsAboveItem = conversationItem.y > reactionBarHeight + menuPadding + reactionBarTopPadding if (reactionBarFitsAboveItem) { @@ -201,7 +245,7 @@ class ConversationReactionOverlay : FrameLayout { reactionBarBackgroundY = reactionBarTopPadding } } else { - val spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding + val spaceAvailableForItem = availableHeight - 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) @@ -210,62 +254,68 @@ class ConversationReactionOverlay : FrameLayout { } else { val reactionBarOffset = DimensionUnit.DP.toPixels(48f) val spaceForReactionBar = Math.max(reactionBarHeight + reactionBarOffset, 0f) - val everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.height + menuPadding + spaceForReactionBar < overlayHeight + val everythingFitsVertically = actualMenuHeight + conversationItemSnapshot.height + menuPadding + spaceForReactionBar < availableHeight + if (everythingFitsVertically) { val bubbleBottom = selectedConversationModel.bubbleY + conversationItemSnapshot.height - val menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight + val menuFitsBelowItem = bubbleBottom + menuPadding + actualMenuHeight <= availableHeight + statusBarHeight + if (menuFitsBelowItem) { - if (conversationItem.y < 0) { - endY = 0f + if (conversationItem.y < systemInsets.top) { + endY = systemInsets.top.toFloat() } val contextMenuTop = endY + conversationItemSnapshot.height - reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.bubbleY, contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY) + reactionBarBackgroundY = getReactionBarOffsetForTouch( + selectedConversationModel.bubbleY, + contextMenuTop, + menuPadding, + reactionBarOffset, + reactionBarHeight, + reactionBarTopPadding, + endY + ) if (reactionBarBackgroundY <= reactionBarTopPadding) { endY = backgroundView.height + menuPadding + reactionBarTopPadding } } else { - endY = overlayHeight - contextMenu.getMaxHeight() - 2*menuPadding - conversationItemSnapshot.height + endY = availableHeight - actualMenuHeight - 2*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 + } else if (reactionBarOffset + reactionBarHeight + actualMenuHeight + menuPadding < availableHeight) { + val spaceAvailableForItem = availableHeight.toFloat() - actualMenuHeight - menuPadding - spaceForReactionBar endScale = spaceAvailableForItem / conversationItemSnapshot.height endX += Util.halfOffsetFromScale(conversationItemSnapshot.width, endScale) * if (isMessageOnLeft) -1 else 1 endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) - reactionBarBackgroundY = reactionBarTopPadding //getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY); + reactionBarBackgroundY = reactionBarTopPadding 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 + // Calculate how much we need to scale the bubble to fit everything + val spaceAvailableForItem = availableHeight.toFloat() - actualMenuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding + + if (spaceAvailableForItem > 0) { 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 + } else { + // If we can't fit everything even with scaling, use a minimum scale + val minScale = 0.2f // Minimum readable scale + endScale = minScale + 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()) + + // Adjust for system insets + reactionBarBackgroundY = maxOf(reactionBarBackgroundY, systemInsets.top.toFloat() - statusBarHeight) hideAnimatorSet.end() visibility = VISIBLE + val scrubberX = if (isMessageOnLeft) { scrubberHorizontalMargin.toFloat() } else { @@ -276,17 +326,21 @@ class ConversationReactionOverlay : FrameLayout { foregroundView.y = reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.height / 2f backgroundView.x = scrubberX backgroundView.y = reactionBarBackgroundY + verticalScrubBoundary.update(reactionBarBackgroundY, - lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone) + lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone) updateBoundsOnLayoutChanged() revealAnimatorSet.start() + if (isWideLayout) { val scrubberRight = scrubberX + scrubberWidth val offsetX = when { isMessageOnLeft -> scrubberRight + menuPadding else -> scrubberX - contextMenu.getMaxWidth() - menuPadding } - contextMenu.show(offsetX.toInt(), Math.min(backgroundView.y, (overlayHeight - contextMenu.getMaxHeight()).toFloat()).toInt()) + // Adjust Y position to account for insets + val adjustedY = minOf(backgroundView.y, (availableHeight - actualMenuHeight).toFloat()).toInt() + contextMenu.show(offsetX.toInt(), adjustedY) } else { val contentX = if (isMessageOnLeft) scrubberHorizontalMargin.toFloat() else selectedConversationModel.bubbleX val offsetX = when { @@ -296,6 +350,7 @@ class ConversationReactionOverlay : FrameLayout { 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) @@ -324,18 +379,6 @@ class ConversationReactionOverlay : FrameLayout { 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) @@ -348,6 +391,11 @@ class ConversationReactionOverlay : FrameLayout { private fun hideInternal(onHideListener: OnHideListener?) { job?.cancel() overlayState = OverlayState.HIDDEN + contextMenu?.dismiss() + + // in case hide is called before show + if (!::selectedConversationModel.isInitialized) return + val animatorSet = newHideAnimatorSet() hideAnimatorSet = animatorSet revealAnimatorSet.end() @@ -360,7 +408,6 @@ class ConversationReactionOverlay : FrameLayout { onHideListener?.onHide() } }) - contextMenu?.dismiss() } val isShowing: Boolean @@ -373,7 +420,6 @@ class ConversationReactionOverlay : FrameLayout { override fun onDetachedFromWindow() { super.onDetachedFromWindow() - hide() } @@ -452,7 +498,7 @@ class ConversationReactionOverlay : FrameLayout { view.translationY = 0f val isAtCustomIndex = i == customEmojiIndex if (isAtCustomIndex) { - view.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_baseline_add_24)) + view.setImageDrawable(iconMore) view.tag = null } else { view.setImageEmoji(emojis[i]) @@ -593,7 +639,7 @@ class ConversationReactionOverlay : FrameLayout { // Ban user if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey) && !isDeleteOnly && !isDeprecatedLegacyGroup) { - items += ActionItem(R.attr.menu_block_icon, R.string.banUser, { handleActionItemClicked(Action.BAN_USER) }) + items += ActionItem(R.attr.menu_ban_icon, R.string.banUser, { handleActionItemClicked(Action.BAN_USER) }) } // Ban and delete all if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey) && !isDeleteOnly && !isDeprecatedLegacyGroup) { @@ -633,6 +679,12 @@ class ConversationReactionOverlay : FrameLayout { return items } + private fun userCanBanSelectedUsers(context: Context, message: MessageRecord, openGroup: OpenGroup?, userPublicKey: String, blindedPublicKey: String?): Boolean { + if (openGroup == null) return false + if (message.isOutgoing) return false // Users can't ban themselves + return openGroupManager.isUserModerator(openGroup.groupId, userPublicKey, blindedPublicKey) + } + private fun handleActionItemClicked(action: Action) { hideInternal(object : OnHideListener { override fun startHide() { @@ -646,6 +698,7 @@ class ConversationReactionOverlay : FrameLayout { }) } + @SuppressLint("RestrictedApi") 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) @@ -697,12 +750,6 @@ class ConversationReactionOverlay : FrameLayout { } + 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) } } } @@ -763,10 +810,10 @@ class ConversationReactionOverlay : FrameLayout { } private val MessageRecord.subtitle: ((Context) -> CharSequence?)? - get() = if (expiresIn <= 0) { + get() = if (expiresIn <= 0 || expireStarted <= 0) { null } else { context -> - (expiresIn - (SnodeAPI.nowWithOffset - (expireStarted.takeIf { it > 0 } ?: timestamp))) + (expiresIn - (SnodeAPI.nowWithOffset - expireStarted)) .coerceAtLeast(0L) .milliseconds .toShortTwoPartString() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt index ea57192cff..80930dfa7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.conversation.v2 -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet @@ -16,10 +15,11 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.squareup.phrase.Phrase import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY +import org.thoughtcrime.securesms.InputBarDialogs +import org.thoughtcrime.securesms.InputbarViewModel import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.ClearEmoji import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.ConfirmRecreateGroup import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.HideClearEmoji @@ -28,13 +28,14 @@ import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.MarkAsDeletedForEveryone import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.MarkAsDeletedLocally import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.ShowOpenUrlDialog -import org.thoughtcrime.securesms.groups.compose.CreateGroupScreen +import org.thoughtcrime.securesms.home.startconversation.group.CreateGroupScreen import org.thoughtcrime.securesms.ui.AlertDialog -import org.thoughtcrime.securesms.ui.DialogButtonModel +import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.OpenURLAlertDialog import org.thoughtcrime.securesms.ui.RadioOption -import org.thoughtcrime.securesms.ui.components.TitledRadioButton +import org.thoughtcrime.securesms.ui.UserProfileModal +import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -45,9 +46,18 @@ import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme @Composable fun ConversationV2Dialogs( dialogsState: ConversationViewModel.DialogsState, - sendCommand: (ConversationViewModel.Commands) -> Unit + inputBarDialogsState: InputbarViewModel.InputBarDialogsState, + sendCommand: (ConversationViewModel.Commands) -> Unit, + sendInputBarCommand: (InputbarViewModel.Commands) -> Unit, + onPostUserProfileModalAction: () -> Unit // a function called in the User Profile Modal once an action has been taken ){ SessionMaterialTheme { + // inputbar dialogs + InputBarDialogs( + inputBarDialogsState = inputBarDialogsState, + sendCommand = sendInputBarCommand + ) + // open link confirmation if(!dialogsState.openLinkDialogUrl.isNullOrEmpty()){ OpenURLAlertDialog( @@ -94,28 +104,22 @@ fun ConversationV2Dialogs( ) } - TitledRadioButton( - contentPadding = PaddingValues( - horizontal = LocalDimensions.current.xxsSpacing, - vertical = 0.dp - ), + DialogTitledRadioButton( option = RadioOption( value = Unit, title = GetString(stringResource(R.string.deleteMessageDeviceOnly)), + qaTag = GetString(stringResource(R.string.qa_delete_message_device_only)), selected = !deleteForEveryone ) ) { deleteForEveryone = false } - TitledRadioButton( - contentPadding = PaddingValues( - horizontal = LocalDimensions.current.xxsSpacing, - vertical = 0.dp - ), + DialogTitledRadioButton( option = RadioOption( value = Unit, title = GetString(data.deleteForEveryoneLabel), + qaTag = GetString(stringResource(R.string.qa_delete_message_everyone)), selected = deleteForEveryone, enabled = data.everyoneEnabled ) @@ -124,7 +128,7 @@ fun ConversationV2Dialogs( } }, buttons = listOf( - DialogButtonModel( + DialogButtonData( text = GetString(stringResource(id = R.string.delete)), color = LocalColors.current.danger, onClick = { @@ -137,7 +141,7 @@ fun ConversationV2Dialogs( ) } ), - DialogButtonModel( + DialogButtonData( GetString(stringResource(R.string.cancel)) ) ) @@ -155,7 +159,7 @@ fun ConversationV2Dialogs( Phrase.from(txt).put(EMOJI_KEY, dialogsState.clearAllEmoji.emoji).format().toString() }, buttons = listOf( - DialogButtonModel( + DialogButtonData( text = GetString(stringResource(id = R.string.clear)), color = LocalColors.current.danger, onClick = { @@ -165,7 +169,7 @@ fun ConversationV2Dialogs( ) } ), - DialogButtonModel( + DialogButtonData( GetString(stringResource(R.string.cancel)) ) ) @@ -181,14 +185,14 @@ fun ConversationV2Dialogs( title = stringResource(R.string.recreateGroup), text = stringResource(R.string.legacyGroupChatHistory), buttons = listOf( - DialogButtonModel( + DialogButtonData( text = GetString(stringResource(id = R.string.theContinue)), color = LocalColors.current.danger, onClick = { sendCommand(ConfirmRecreateGroup) } ), - DialogButtonModel( + DialogButtonData( GetString(stringResource(R.string.cancel)) ) ) @@ -219,6 +223,20 @@ fun ConversationV2Dialogs( ) } } + + // user profile modal + if(dialogsState.userProfileModal != null){ + UserProfileModal( + data = dialogsState.userProfileModal, + onDismissRequest = { + sendCommand(ConversationViewModel.Commands.HideUserProfileModal) + }, + sendCommand = { + sendCommand(ConversationViewModel.Commands.HandleUserProfileCommand(it)) + }, + onPostAction = onPostUserProfileModalAction + ) + } } } @@ -230,7 +248,10 @@ fun PreviewURLDialog(){ dialogsState = ConversationViewModel.DialogsState( openLinkDialogUrl = "https://google.com" ), - sendCommand = {} + inputBarDialogsState = InputbarViewModel.InputBarDialogsState(), + sendCommand = {}, + sendInputBarCommand = {}, + onPostUserProfileModalAction = {} ) } } 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 0850fbb797..aae6542403 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 @@ -2,20 +2,19 @@ package org.thoughtcrime.securesms.conversation.v2 import android.app.Application import android.content.Context -import android.view.MenuItem import android.widget.Toast import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.goterl.lazysodium.utils.KeyPair +import com.bumptech.glide.Glide import com.squareup.phrase.Phrase import dagger.assisted.Assisted import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -26,11 +25,16 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.libsession_util.util.KeyPair import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 @@ -39,34 +43,53 @@ 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.sending_receiving.attachments.DatabaseAttachment -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.ExpirationUtil import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.UsernameUtils import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.recipients.MessageType import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.getType import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.InputbarViewModel import org.thoughtcrime.securesms.audio.AudioSlidePlayer -import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.ReactionDatabase +import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.GroupThreadStatus import org.thoughtcrime.securesms.database.model.MessageId 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.ConfigFactory import org.thoughtcrime.securesms.groups.ExpiredGroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.mms.AudioSlide +import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.repository.ConversationRepository +import org.thoughtcrime.securesms.ui.components.ConversationAppBarData +import org.thoughtcrime.securesms.ui.components.ConversationAppBarPagerData +import org.thoughtcrime.securesms.ui.getSubbedString +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUtils import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.RecipientChangeSource +import org.thoughtcrime.securesms.util.UserProfileModalCommands +import org.thoughtcrime.securesms.util.UserProfileModalData +import org.thoughtcrime.securesms.util.UserProfileUtils +import org.thoughtcrime.securesms.util.avatarOptions +import org.thoughtcrime.securesms.webrtc.CallManager +import org.thoughtcrime.securesms.webrtc.data.State import java.time.ZoneId import java.util.UUID @@ -76,20 +99,35 @@ class ConversationViewModel( private val application: Application, private val repository: ConversationRepository, private val storage: StorageProtocol, - private val messageDataProvider: MessageDataProvider, private val groupDb: GroupDatabase, private val threadDb: ThreadDatabase, private val reactionDb: ReactionDatabase, private val lokiMessageDb: LokiMessageDatabase, + private val lokiAPIDb: LokiAPIDatabase, private val textSecurePreferences: TextSecurePreferences, private val configFactory: ConfigFactory, private val groupManagerV2: GroupManagerV2, + private val callManager: CallManager, val legacyGroupDeprecationManager: LegacyGroupDeprecationManager, + val dateUtils: DateUtils, private val expiredGroupManager: ExpiredGroupManager, -) : ViewModel() { + private val usernameUtils: UsernameUtils, + private val avatarUtils: AvatarUtils, + private val recipientChangeSource: RecipientChangeSource, + private val openGroupManager: OpenGroupManager, + private val proStatusManager: ProStatusManager, + private val upmFactory: UserProfileUtils.UserProfileUtilsFactory, + attachmentDownloadHandlerFactory: AttachmentDownloadHandler.Factory, +) : InputbarViewModel( + application = application, + proStatusManager = proStatusManager +) { val showSendAfterApprovalText: Boolean - get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false + get() = recipient?.run { + // if the contact is a 1on1 or a blinded 1on1 that doesn't block requests - and is not the current user - and has not yet approved us + (getBlindedRecipient(recipient)?.blocksCommunityMessageRequests == false || isContactRecipient) && !isLocalNumber && !hasApprovedMe() + } ?: false private val _uiState = MutableStateFlow(ConversationUiState()) val uiState: StateFlow get() = _uiState @@ -103,6 +141,21 @@ class ConversationViewModel( private val _isAdmin = MutableStateFlow(false) val isAdmin: StateFlow = _isAdmin + // all the data we need for the conversation app bar + private val _appBarData = MutableStateFlow(ConversationAppBarData( + title = "", + pagerData = emptyList(), + showCall = false, + showAvatar = false, + avatarUIData = AvatarUIData( + elements = emptyList() + ) + )) + val appBarData: StateFlow = _appBarData + + private var userProfileModalJob: Job? = null + private var userProfileModalUtils: UserProfileUtils? = null + private var _recipient: RetrieveOnce = RetrieveOnce { val conversation = repository.maybeGetRecipientForThreadId(threadId) @@ -112,7 +165,7 @@ class ConversationViewModel( _isAdmin.value = when(conversationType) { // for Groups V2 MessageType.GROUPS_V2 -> { - configFactory.getGroup(AccountId(conversation.address.serialize()))?.hasAdminKey() == true + configFactory.getGroup(AccountId(conversation.address.toString()))?.hasAdminKey() == true } // for legacy groups, check if the user created the group @@ -133,6 +186,8 @@ class ConversationViewModel( else -> false } + updateAppBarData(conversation) + conversation } @@ -144,11 +199,16 @@ class ConversationViewModel( val blindedRecipient: Recipient? get() = _recipient.value?.let { recipient -> - when { - recipient.isCommunityOutboxRecipient -> recipient - recipient.isCommunityInboxRecipient -> repository.maybeGetBlindedRecipient(recipient) - else -> null - } + getBlindedRecipient(recipient) + } + + private var currentAppBarNotificationState: String? = null + + private fun getBlindedRecipient(recipient: Recipient?): Recipient? = + when { + recipient?.isCommunityOutboxRecipient == true -> recipient + recipient?.isCommunityInboxRecipient == true -> repository.maybeGetBlindedRecipient(recipient) + else -> null } /** @@ -169,7 +229,7 @@ class ConversationViewModel( val recipient = recipient ?: return GroupThreadStatus.None if (!recipient.isGroupV2Recipient) return GroupThreadStatus.None - return configFactory.getGroup(AccountId(recipient.address.serialize())).let { group -> + return configFactory.getGroup(AccountId(recipient.address.toString())).let { group -> when { group?.destroyed == true -> GroupThreadStatus.Destroyed group?.kicked == true -> GroupThreadStatus.Kicked @@ -190,7 +250,10 @@ class ConversationViewModel( val blindedPublicKey: String? get() = if (openGroup == null || edKeyPair == null || !serverCapabilities.contains(OpenGroupApi.Capability.BLIND.name.lowercase())) null else { - SodiumUtilities.blindedKeyPair(openGroup!!.publicKey, edKeyPair)?.publicKey?.asBytes + BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = edKeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(openGroup!!.publicKey), + )?.pubKey?.data ?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString } @@ -200,12 +263,17 @@ class ConversationViewModel( return !recipient.isLocalNumber && !recipient.isLegacyGroupRecipient && !recipient.isCommunityRecipient && !recipient.isApproved } - val showOptionsMenu: Boolean - get() = !isMessageRequestThread && !isDeprecatedLegacyGroup && !isInactiveGroupV2Thread + /** + * returns true for outgoing message request, whether they are for 1 on 1 conversations or community outgoing MR + */ + val isOutgoingMessageRequest: Boolean + get() { + val recipient = recipient ?: return false + return (recipient.is1on1 || recipient.isCommunityInboxRecipient) && !recipient.hasApprovedMe() + } - private val isInactiveGroupV2Thread: Boolean - get() = recipient?.isGroupV2Recipient == true && - configFactory.getGroup(AccountId(recipient!!.address.toString()))?.shouldPoll == false + val showOptionsMenu: Boolean + get() = !isMessageRequestThread && !isDeprecatedLegacyGroup && !isOutgoingMessageRequest private val isDeprecatedLegacyGroup: Boolean get() = recipient?.isLegacyGroupRecipient == true && legacyGroupDeprecationManager.isDeprecated @@ -233,7 +301,7 @@ class ConversationViewModel( Phrase.from(application, if (admin) R.string.legacyGroupBeforeDeprecationAdmin else R.string.legacyGroupBeforeDeprecationMember) .put(DATE_KEY, time.withZoneSameInstant(ZoneId.systemDefault()) - .format(DateUtils.getMediumDateTimeFormatter()) + .format(dateUtils.getMediumDateTimeFormatter()) ) .format() @@ -254,11 +322,19 @@ class ConversationViewModel( expiredGroupManager.expiredGroups.map { groupId in it } } - private val attachmentDownloadHandler = AttachmentDownloadHandler( - storage = storage, - messageDataProvider = messageDataProvider, - scope = viewModelScope, - ) + private val attachmentDownloadHandler = attachmentDownloadHandlerFactory.create(viewModelScope) + + val callBanner: StateFlow = callManager.currentConnectionStateFlow.map { + // a call is in progress if it isn't idle nor disconnected and the recipient is the person on the call + if(it !is State.Idle && it !is State.Disconnected && callManager.recipient?.address == recipient?.address){ + // call is started, we need to differentiate between in progress vs incoming + if(it is State.Connected) application.getString(R.string.callsInProgress) + else application.getString(R.string.callsIncomingUnknown) + } else null // null when the call isn't in progress / incoming + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + + val lastSeenMessageId: Flow + get() = repository.getLastSentMessageID(threadId) init { viewModelScope.launch(Dispatchers.Default) { @@ -271,17 +347,35 @@ class ConversationViewModel( _uiState.update { it.copy( shouldExit = recipient == null, - showInput = shouldShowInput(recipient, community, deprecationState), - enableInputMediaControls = shouldEnableInputMediaControls(recipient), messageRequestState = buildMessageRequestState(recipient), ) } + + _inputBarState.update { + getInputBarState(recipient, community, deprecationState) + } + } + } + + // update state on recipient changes + viewModelScope.launch(Dispatchers.Default) { + recipientChangeSource.changes().collect { + updateAppBarData(recipient) + _uiState.update { + it.copy( + shouldExit = recipient == null, + ) + } + + _inputBarState.update { + getInputBarState(recipient, _openGroup.value, legacyGroupDeprecationManager.deprecationState.value) + } } } // Listen for changes in the open group's write access viewModelScope.launch { - OpenGroupManager.getCommunitiesWriteAccessFlow() + openGroupManager.getCommunitiesWriteAccessFlow() .map { withContext(Dispatchers.Default) { if (openGroup?.groupId != null) @@ -297,6 +391,146 @@ class ConversationViewModel( } } + private fun getInputBarState( + recipient: Recipient?, + community: OpenGroup?, + deprecationState: LegacyGroupDeprecationManager.DeprecationState + ): InputBarState { + val currentCharLimitState = _inputBarState.value.charLimitState + return when { + // prioritise cases that demand the input to be hidden + !shouldShowInput(recipient, community, deprecationState) -> InputBarState( + contentState = InputBarContentState.Hidden, + enableAttachMediaControls = false, + charLimitState = currentCharLimitState + ) + + // next are cases where the input is visible but disabled + // when the recipient is blocked + recipient?.isBlocked == true -> InputBarState( + contentState = InputBarContentState.Disabled( + text = application.getString(R.string.blockBlockedDescription), + onClick = { + _uiEvents.tryEmit(ConversationUiEvent.ShowUnblockConfirmation) + } + ), + enableAttachMediaControls = false, + charLimitState = currentCharLimitState + ) + + // the user does not have write access in the community + openGroup?.canWrite == false -> InputBarState( + contentState = InputBarContentState.Disabled( + text = application.getString(R.string.permissionsWriteCommunity), + ), + enableAttachMediaControls = false, + charLimitState = currentCharLimitState + ) + + // other cases the input is visible, and the buttons might be disabled based on some criteria + else -> InputBarState( + contentState = InputBarContentState.Visible, + enableAttachMediaControls = shouldEnableInputMediaControls(recipient), + charLimitState = currentCharLimitState + ) + } + } + + private fun updateAppBarData(conversation: Recipient?) { + viewModelScope.launch { + // sort out the pager data, if any + val pagerData: MutableList = mutableListOf() + if (conversation != null) { + // Specify the disappearing messages subtitle if we should + val config = expirationConfiguration + if (config?.isEnabled == true) { + // Get the type of disappearing message and the abbreviated duration.. + val dmTypeString = when (config.expiryMode) { + is ExpiryMode.AfterRead -> R.string.disappearingMessagesDisappearAfterReadState + else -> R.string.disappearingMessagesDisappearAfterSendState + } + val durationAbbreviated = ExpirationUtil.getExpirationAbbreviatedDisplayValue(config.expiryMode.expirySeconds) + + // ..then substitute into the string.. + val subtitleTxt = application.getSubbedString(dmTypeString, + TIME_KEY to durationAbbreviated + ) + + // .. and apply to the subtitle. + pagerData += ConversationAppBarPagerData( + title = subtitleTxt, + action = { + showDisappearingMessages() + }, + icon = R.drawable.ic_clock_11, + qaTag = application.resources.getString(R.string.AccessibilityId_disappearingMessagesDisappear) + ) + } + + currentAppBarNotificationState = null + if (conversation.isMuted || conversation.notifyType == RecipientDatabase.NOTIFY_TYPE_MENTIONS) { + currentAppBarNotificationState = getNotificationStatusTitle(conversation) + pagerData += ConversationAppBarPagerData( + title = currentAppBarNotificationState!!, + action = { + showNotificationSettings() + } + ) + } + + if (conversation.isGroupOrCommunityRecipient && conversation.isApproved) { + val title = if (conversation.isCommunityRecipient) { + val userCount = openGroup?.let { lokiAPIDb.getUserCount(it.room, it.server) } ?: 0 + application.resources.getQuantityString(R.plurals.membersActive, userCount, userCount) + } else { + val userCount = if (conversation.isGroupV2Recipient) { + storage.getMembers(conversation.address.toString()).size + } else { // legacy closed groups + groupDb.getGroupMemberAddresses(conversation.address.toGroupString(), true).size + } + application.resources.getQuantityString(R.plurals.members, userCount, userCount) + } + pagerData += ConversationAppBarPagerData( + title = title, + action = { + if(conversation.isCommunityRecipient) showConversationSettings() + else showGroupMembers() + }, + ) + } + } + + // calculate the main app bar data + val avatarData = avatarUtils.getUIDataFromRecipient(conversation) + _appBarData.value = ConversationAppBarData( + title = conversation.takeUnless { it?.isLocalNumber == true }?.name ?: application.getString(R.string.noteToSelf), + pagerData = pagerData, + showCall = conversation?.showCallMenu() ?: false, + showAvatar = showOptionsMenu, + showSearch = _appBarData.value.showSearch, + avatarUIData = avatarData, + // show the pro badge when a conversation/user is pro, except for communities + showProBadge = proStatusManager.shouldShowProBadge(conversation?.address) + && conversation?.isLocalNumber == false // do not show for note to self + ) + // also preload the larger version of the avatar in case the user goes to the settings + avatarData.elements.mapNotNull { it.contactPhoto }.forEach { + val loadSize = application.resources.getDimensionPixelSize(R.dimen.large_profile_picture_size) + Glide.with(application).load(it) + .avatarOptions( + sizePx = loadSize, + freezeFrame = proStatusManager.freezeFrameForUser(recipient?.address) + ) + .preload(loadSize, loadSize) + } + } + } + + private fun getNotificationStatusTitle(conversation: Recipient): String{ + return if(conversation.isMuted) application.getString(R.string.notificationsHeaderMute) + else application.getString(R.string.notificationsHeaderMentionsOnly) + } + /** * Determines if the input media controls should be enabled. * @@ -305,13 +539,37 @@ class ConversationViewModel( * Since we haven't been approved by them, we can't send them any media, only text */ private fun shouldEnableInputMediaControls(recipient: Recipient?): Boolean { - if (recipient != null && - (recipient.is1on1 && !recipient.isLocalNumber) && - !recipient.hasApprovedMe()) { + + // Specifically disallow multimedia if we don't have a recipient to send anything to + if (recipient == null) { + Log.i("ConversationViewModel", "Will not enable media controls for a null recipient.") return false } - return true + // disable for blocked users + if (recipient.isBlocked) return false + + // Specifically allow multimedia in our note-to-self + if (recipient.isLocalNumber) return true + + // To send multimedia content to other people: + // - For 1-on-1 conversations they must have approved us as a contact. + val allowedFor1on1 = recipient.is1on1 && recipient.hasApprovedMe() + + // - For groups you just have to be a member of the group. Note: `isGroupRecipient` convers both legacy and V2 groups. + val allowedForGroup = recipient.isGroupRecipient + + // - For communities you must have write access to the community + val allowedForCommunity = (recipient.isCommunityRecipient && openGroup?.canWrite == true) + + // - For blinded recipients you must be a contact of the recipient - without which you CAN + // send them SMS messages - but they will not get through if the recipient does not have + // community message requests enabled. Being a "contact recipient" implies + // `!recipient.blocksCommunityMessageRequests` in this case. + val allowedForBlindedCommunityRecipient = recipient.isCommunityInboxRecipient && recipient.isContactRecipient + + // If any of the above are true we allow sending multimedia files - otherwise we don't + return allowedFor1on1 || allowedForGroup || allowedForCommunity || allowedForBlindedCommunityRecipient } /** @@ -321,7 +579,7 @@ class ConversationViewModel( * 1. The user has been kicked from a group(v2), OR * 2. The legacy group is inactive, OR * 3. The legacy group is deprecated, OR - * 4. The community chat is read only + * 4. Blinded recipient who have disabled message request from community members */ private fun shouldShowInput(recipient: Recipient?, community: OpenGroup?, @@ -333,7 +591,7 @@ class ConversationViewModel( groupDb.getGroup(recipient.address.toGroupString()).orNull()?.isActive == true && deprecationState != LegacyGroupDeprecationManager.DeprecationState.DEPRECATED } - community != null -> community.canWrite + getBlindedRecipient(recipient)?.blocksCommunityMessageRequests == true -> false else -> true } } @@ -402,26 +660,28 @@ class ConversationViewModel( return draft } - fun inviteContacts(contacts: List) { - repository.inviteContacts(threadId, contacts) - } - fun block() { // inviting admin will be non-null if this request is a closed group message request val recipient = invitingAdmin ?: recipient ?: return Log.w("Loki", "Recipient was null for block action") if (recipient.isContactRecipient || recipient.isGroupV2Recipient) { - repository.setBlocked(threadId, recipient, true) + viewModelScope.launch { + repository.setBlocked(recipient, true) + } } - if (this.recipient?.isGroupV2Recipient == true) { - groupManagerV2.onBlocked(AccountId(this.recipient!!.address.serialize())) + if (recipient.isGroupV2Recipient) { + viewModelScope.launch { + groupManagerV2.onBlocked(AccountId(recipient.address.toString())) + } } } fun unblock() { val recipient = recipient ?: return Log.w("Loki", "Recipient was null for unblock action") if (recipient.isContactRecipient) { - repository.setBlocked(threadId, recipient, false) + viewModelScope.launch { + repository.setBlocked(recipient, false) + } } } @@ -447,7 +707,7 @@ class ConversationViewModel( val canDeleteForEveryone = messages.all{ !it.isDeleted && !it.isControlMessage } && ( messages.all { it.isOutgoing } || conversationType == MessageType.COMMUNITY || - messages.all { lokiMessageDb.getMessageServerHash(it.id, it.isMms) != null } + messages.all { lokiMessageDb.getMessageServerHash(it.messageId) != null } ) // There are three types of dialogs for deletion: @@ -552,8 +812,6 @@ class ConversationViewModel( private fun markAsDeletedForEveryone( data: DeleteForEveryoneDialogData ) = viewModelScope.launch { - val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.") - // make sure to stop audio messages, if any data.messages.filterIsInstance() .mapNotNull { it.slideDeck.audioSlide } @@ -808,26 +1066,16 @@ class ConversationViewModel( private fun isUserCommunityManager() = openGroup?.let { openGroup -> val userPublicKey = textSecurePreferences.getLocalNumber() ?: return@let false - OpenGroupManager.isUserModerator(application, openGroup.id, userPublicKey, blindedPublicKey) + openGroupManager.isUserModerator(openGroup.id, userPublicKey, blindedPublicKey) } ?: false /** * Stops audio player if its current playing is the one given in the message. */ - private fun stopMessageAudio(message: MessageRecord) { - val mmsMessage = message as? MmsMessageRecord ?: return - val audioSlide = mmsMessage.slideDeck.audioSlide ?: return - stopMessageAudio(audioSlide) - } private fun stopMessageAudio(audioSlide: AudioSlide) { 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 banUser(recipient: Recipient) = viewModelScope.launch { repository.banUser(threadId, recipient) .onSuccess { @@ -853,7 +1101,7 @@ class ConversationViewModel( } } - fun acceptMessageRequest() = viewModelScope.launch { + fun acceptMessageRequest(): Job = viewModelScope.launch { val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for accept message request action") val currentState = _uiState.value.messageRequestState as? MessageRequestUiState.Visible ?: return@launch Log.w("Loki", "Current state was not visible for accept message request action") @@ -904,48 +1152,42 @@ class ConversationViewModel( } } - fun hasReceived(): Boolean { - return repository.hasReceived(threadId) - } - fun updateRecipient() { _recipient.updateTo(repository.maybeGetRecipientForThreadId(threadId)) + updateAppBarData(recipient) } - /** - * The input should be hidden when: - * - We are in a community without write access - * - We are dealing with a contact from a community (blinded recipient) that does not allow - * requests form community members - */ - fun shouldHideInputBar(): Boolean = openGroup?.canWrite == false || - blindedRecipient?.blocksCommunityMessageRequests == true - fun legacyBannerRecipient(context: Context): Recipient? = recipient?.run { - storage.getLastLegacyRecipient(address.serialize())?.let { Recipient.from(context, Address.fromSerialized(it), false) } - } - - fun onAttachmentDownloadRequest(attachment: DatabaseAttachment) { - attachmentDownloadHandler.onAttachmentDownloadRequest(attachment) + storage.getLastLegacyRecipient(address.toString())?.let { Recipient.from(context, Address.fromSerialized(it), false) } } - fun beforeSendingTextOnlyMessage() { - implicitlyApproveRecipient() + fun downloadPendingAttachment(attachment: DatabaseAttachment) { + attachmentDownloadHandler.downloadPendingAttachment(attachment) } - fun beforeSendingAttachments() { - implicitlyApproveRecipient() + fun retryFailedAttachments(attachments: List){ + attachmentDownloadHandler.retryFailedAttachments(attachments) } - private fun implicitlyApproveRecipient() { + /** + * Implicitly approve the recipient. + * + * @return The (kotlin coroutine) job of sending job message request, if one should be sent. The job + * instance is normally just for observing purpose. Note that the completion of this job + * does not mean the message is sent, it only means the the successful submission to the message + * send queue and they will be sent later. You will not be able to observe the completion + * of message sending through this method. + */ + fun implicitlyApproveRecipient(): Job? { val recipient = recipient if (uiState.value.messageRequestState is MessageRequestUiState.Visible) { - acceptMessageRequest() + return acceptMessageRequest() } else if (recipient?.isApproved == false) { // edge case for new outgoing thread on new recipient without sending approval messages repository.setApproved(recipient, true) } + return null } fun onCommand(command: Commands) { @@ -1001,7 +1243,7 @@ class ConversationViewModel( _dialogsState.update { it.copy( recreateGroupConfirm = false, - recreateGroupData = recipient?.address?.serialize()?.let { addr -> RecreateGroupDialogData(legacyGroupId = addr) } + recreateGroupData = recipient?.address?.toString()?.let { addr -> RecreateGroupDialogData(legacyGroupId = addr) } ) } } @@ -1015,6 +1257,14 @@ class ConversationViewModel( is Commands.NavigateToConversation -> { _uiEvents.tryEmit(ConversationUiEvent.NavigateToConversation(command.threadId)) } + + is Commands.HideUserProfileModal -> { + _dialogsState.update { it.copy(userProfileModal = null) } + } + + is Commands.HandleUserProfileCommand -> { + userProfileModalUtils?.onCommand(command.upmCommand) + } } } @@ -1022,7 +1272,7 @@ class ConversationViewModel( viewModelScope.launch(Dispatchers.Default) { reactionDb.deleteEmojiReactions(emoji, messageId) openGroup?.let { openGroup -> - lokiMessageDb.getServerID(messageId.id, !messageId.mms)?.let { serverId -> + lokiMessageDb.getServerID(messageId)?.let { serverId -> OpenGroupApi.deleteAllReactions( openGroup.room, openGroup.server, @@ -1042,37 +1292,74 @@ class ConversationViewModel( } } - fun onOptionItemSelected( - // This must be the context of the activity as requirement from ConversationMenuHelper - context: Context, - item: MenuItem - ): Boolean { - val recipient = recipient ?: return false - - val inProgress = ConversationMenuHelper.onOptionItemSelected( - context = context, - item = item, - thread = recipient, - threadID = threadId, - factory = configFactory, - storage = storage, - groupManager = groupManagerV2, - deprecationManager = legacyGroupDeprecationManager, - ) + fun getUsername(accountId: String) = usernameUtils.getContactNameWithAccountID(accountId) - if (inProgress != null) { - viewModelScope.launch { - inProgress.consumeEach { status -> - when (status) { - ConversationMenuHelper.GroupLeavingStatus.Left, - ConversationMenuHelper.GroupLeavingStatus.Error -> _uiState.update { it.copy(showLoader = false) } - else -> _uiState.update { it.copy(showLoader = true) } - } + fun onSearchOpened(){ + _appBarData.update { _appBarData.value.copy(showSearch = true) } + } + + fun onSearchClosed(){ + _appBarData.update { _appBarData.value.copy(showSearch = false) } + } + + private fun showDisappearingMessages() { + recipient?.let { convo -> + if (convo.isLegacyGroupRecipient) { + groupDb.getGroup(convo.address.toGroupString()).orNull()?.run { + if (!isActive) return } } + + _uiEvents.tryEmit(ConversationUiEvent.ShowDisappearingMessages(threadId)) } + } + + private fun showGroupMembers() { + recipient?.let { convo -> + val groupId = recipient?.address?.toString() ?: return - return true + _uiEvents.tryEmit(ConversationUiEvent.ShowGroupMembers(groupId)) + } + } + + private fun showConversationSettings() { + recipient?.let { convo -> + _uiEvents.tryEmit(ConversationUiEvent.ShowConversationSettings( + threadId = threadId, + threadAddress = convo.address + )) + } + } + + private fun showNotificationSettings() { + _uiEvents.tryEmit(ConversationUiEvent.ShowNotificationSettings(threadId)) + } + + fun onResume() { + // when resuming we want to check if the app bar has notification status data, if so update it if it has changed + if(currentAppBarNotificationState != null && recipient!= null){ + val newAppBarNotificationState = getNotificationStatusTitle(recipient!!) + if(currentAppBarNotificationState != newAppBarNotificationState){ + updateAppBarData(recipient) + } + } + } + + fun showUserProfileModal(recipient: Recipient) { + // get the helper class for the selected user + userProfileModalUtils = upmFactory.create( + recipient = recipient, + threadId = threadId, + scope = viewModelScope + ) + + // cancel previous job if any then listen in on the changes + userProfileModalJob?.cancel() + userProfileModalJob = viewModelScope.launch { + userProfileModalUtils?.userProfileModalData?.collect { upmData -> + _dialogsState.update { it.copy(userProfileModal = upmData) } + } + } } @dagger.assisted.AssistedFactory @@ -1087,18 +1374,25 @@ class ConversationViewModel( private val application: Application, private val repository: ConversationRepository, private val storage: StorageProtocol, - private val messageDataProvider: MessageDataProvider, private val groupDb: GroupDatabase, private val threadDb: ThreadDatabase, private val reactionDb: ReactionDatabase, - @ApplicationContext - private val context: Context, private val lokiMessageDb: LokiMessageDatabase, + private val lokiAPIDb: LokiAPIDatabase, private val textSecurePreferences: TextSecurePreferences, private val configFactory: ConfigFactory, private val groupManagerV2: GroupManagerV2, + private val callManager: CallManager, private val legacyGroupDeprecationManager: LegacyGroupDeprecationManager, + private val dateUtils: DateUtils, private val expiredGroupManager: ExpiredGroupManager, + private val usernameUtils: UsernameUtils, + private val avatarUtils: AvatarUtils, + private val recipientChangeSource: RecipientChangeSource, + private val openGroupManager: OpenGroupManager, + private val proStatusManager: ProStatusManager, + private val upmFactory: UserProfileUtils.UserProfileUtilsFactory, + private val attachmentDownloadHandlerFactory: AttachmentDownloadHandler.Factory ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @@ -1108,16 +1402,25 @@ class ConversationViewModel( application = application, repository = repository, storage = storage, - messageDataProvider = messageDataProvider, groupDb = groupDb, threadDb = threadDb, reactionDb = reactionDb, lokiMessageDb = lokiMessageDb, + lokiAPIDb = lokiAPIDb, textSecurePreferences = textSecurePreferences, configFactory = configFactory, groupManagerV2 = groupManagerV2, + callManager = callManager, legacyGroupDeprecationManager = legacyGroupDeprecationManager, + dateUtils = dateUtils, expiredGroupManager = expiredGroupManager, + usernameUtils = usernameUtils, + avatarUtils = avatarUtils, + recipientChangeSource = recipientChangeSource, + openGroupManager = openGroupManager, + proStatusManager = proStatusManager, + upmFactory = upmFactory, + attachmentDownloadHandlerFactory = attachmentDownloadHandlerFactory, ) as T } } @@ -1128,6 +1431,7 @@ class ConversationViewModel( val deleteEveryone: DeleteForEveryoneDialogData? = null, val recreateGroupConfirm: Boolean = false, val recreateGroupData: RecreateGroupDialogData? = null, + val userProfileModal: UserProfileModalData? = null, ) data class RecreateGroupDialogData( @@ -1164,6 +1468,11 @@ class ConversationViewModel( data object HideRecreateGroupConfirm : Commands data object HideRecreateGroup : Commands data class NavigateToConversation(val threadId: Long) : Commands + + data object HideUserProfileModal: Commands + data class HandleUserProfileCommand( + val upmCommand: UserProfileModalCommands + ): Commands } } @@ -1173,13 +1482,16 @@ data class ConversationUiState( val uiMessages: List = emptyList(), val messageRequestState: MessageRequestUiState = MessageRequestUiState.Invisible, val shouldExit: Boolean = false, - val showInput: Boolean = true, - val enableInputMediaControls: Boolean = true, val showLoader: Boolean = false, ) sealed interface ConversationUiEvent { data class NavigateToConversation(val threadId: Long) : ConversationUiEvent + data class ShowDisappearingMessages(val threadId: Long) : ConversationUiEvent + data class ShowNotificationSettings(val threadId: Long) : ConversationUiEvent + data class ShowGroupMembers(val groupId: String) : ConversationUiEvent + data class ShowConversationSettings(val threadId: Long, val threadAddress: Address) : ConversationUiEvent + data object ShowUnblockConfirmation : ConversationUiEvent } sealed interface MessageRequestUiState { 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 e44a11135d..a8b5b9f116 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,13 +1,14 @@ package org.thoughtcrime.securesms.conversation.v2 import android.annotation.SuppressLint +import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.MotionEvent.ACTION_UP import androidx.activity.viewModels -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -16,10 +17,12 @@ 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -41,7 +44,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -49,26 +54,43 @@ 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.core.content.IntentCompat +import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage +import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.withCreationCallback import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageContentBinding import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment -import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.ui.Avatar +import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.thoughtcrime.securesms.MediaPreviewActivity +import org.thoughtcrime.securesms.ScreenLockActionBarActivity +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.ui.AnimatedProfilePicProCTA +import org.thoughtcrime.securesms.ui.CTAFeature import org.thoughtcrime.securesms.ui.CarouselNextButton import org.thoughtcrime.securesms.ui.CarouselPrevButton import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.Divider +import org.thoughtcrime.securesms.ui.GenericProCTA import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.HorizontalPagerIndicator import org.thoughtcrime.securesms.ui.LargeItemButton +import org.thoughtcrime.securesms.ui.LongMessageProCTA +import org.thoughtcrime.securesms.ui.ProBadgeText +import org.thoughtcrime.securesms.ui.ProCTAFeature import org.thoughtcrime.securesms.ui.TitledText +import org.thoughtcrime.securesms.ui.UserProfileModal +import org.thoughtcrime.securesms.ui.components.Avatar import org.thoughtcrime.securesms.ui.setComposeContent import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -80,19 +102,26 @@ import org.thoughtcrime.securesms.ui.theme.blackAlpha40 import org.thoughtcrime.securesms.ui.theme.bold import org.thoughtcrime.securesms.ui.theme.dangerButtonColors import org.thoughtcrime.securesms.ui.theme.monospace +import org.thoughtcrime.securesms.util.ActivityDispatcher +import org.thoughtcrime.securesms.util.AvatarBadge +import org.thoughtcrime.securesms.util.push import javax.inject.Inject @AndroidEntryPoint -class MessageDetailActivity : PassphraseRequiredActionBarActivity() { +class MessageDetailActivity : ScreenLockActionBarActivity(), ActivityDispatcher { @Inject lateinit var storage: StorageProtocol - private val viewModel: MessageDetailsViewModel by viewModels() + private val viewModel: MessageDetailsViewModel by viewModels(extrasProducer = { + defaultViewModelCreationExtras.withCreationCallback { + it.create(IntentCompat.getParcelableExtra(intent, MESSAGE_ID, MessageId::class.java)!!) + } + }) companion object { // Extras - const val MESSAGE_TIMESTAMP = "message_timestamp" + const val MESSAGE_ID = "message_id" const val ON_REPLY = 1 const val ON_RESEND = 2 @@ -106,8 +135,6 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() { title = resources.getString(R.string.messageInfo) - viewModel.timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L) - setComposeContent { MessageDetailsScreen() } lifecycleScope.launch { @@ -115,16 +142,25 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() { when (it) { Event.Finish -> finish() is Event.StartMediaPreview -> startActivity( - getPreviewIntent(this@MessageDetailActivity, it.args) + MediaPreviewActivity.getPreviewIntent(this@MessageDetailActivity, it.args) ) } } } } + override fun dispatchIntent(body: (Context) -> Intent?) { + body(this)?.let { push(it, false) } + } + + override fun showDialog(dialogFragment: DialogFragment, tag: String?) { + dialogFragment.show(supportFragmentManager, tag) + } + @Composable private fun MessageDetailsScreen() { val state by viewModel.stateFlow.collectAsState() + val dialogState by viewModel.dialogState.collectAsState() // can only save if the there is a media attachment which has finished downloading. val canSave = state.mmsRecord?.containsMediaSlide() == true @@ -137,16 +173,18 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() { onSave = if(canSave) { { setResultAndFinish(ON_SAVE) } } else null, onDelete = if (state.canDelete) { { setResultAndFinish(ON_DELETE) } } else null, onCopy = { setResultAndFinish(ON_COPY) }, - onClickImage = { viewModel.onClickImage(it) }, - onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload, + sendCommand = { viewModel.onCommand(it) }, + retryFailedAttachments = viewModel::retryFailedAttachments + ) + + MessageDetailDialogs( + state = dialogState, + sendCommand = { viewModel.onCommand(it) } ) } private fun setResultAndFinish(code: Int) { - Bundle().apply { putLong(MESSAGE_TIMESTAMP, viewModel.timestamp) } - .let(Intent()::putExtras) - .let { setResult(code, it) } - + setResult(code, Intent().putExtra(MESSAGE_ID, viewModel.messageId)) finish() } } @@ -160,8 +198,8 @@ fun MessageDetails( onSave: (() -> Unit)? = null, onDelete: (() -> Unit)? = null, onCopy: () -> Unit = {}, - onClickImage: (Int) -> Unit = {}, - onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit = { _ -> } + sendCommand: (Commands) -> Unit, + retryFailedAttachments: (List) -> Unit ) { Column( modifier = Modifier @@ -170,28 +208,47 @@ fun MessageDetails( verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) ) { state.record?.let { message -> - AndroidView( - modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing), - 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 + Column( + modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing) + ) { + AndroidView( + factory = { context -> + // Inflate the view once + ViewVisibleMessageContentBinding.inflate(LayoutInflater.from(context)).root + }, + update = { view -> + // Rebind the view whenever state changes. + // Retrieve the binding from the view + val binding = ViewVisibleMessageContentBinding.bind(view) + binding.mainContainerConstraint.apply { + bind( + message, + thread = state.thread!!, + downloadPendingAttachment = {}, // the view shouldn't handle this from the details activity + retryFailedAttachments = retryFailedAttachments, + suppressThumbnails = true + ) + + setOnTouchListener { _, event -> + if (event.actionMasked == ACTION_UP) onContentClick(event) + true + } } } + ) + + state.status?.let { + Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) + MessageStatus( + modifier = Modifier.padding(horizontal = 2.dp), + status = it + ) } - ) + } } - Carousel(state.imageAttachments) { onClickImage(it) } + Carousel(state.imageAttachments) { sendCommand(Commands.OpenImage(it)) } state.nonImageAttachmentFileDetails?.let { FileDetails(it) } - CellMetadata(state) + CellMetadata(state, sendCommand = sendCommand) CellButtons( onReply = onReply, onResend = onResend, @@ -202,9 +259,47 @@ fun MessageDetails( } } +@Composable +fun MessageStatus( + status: MessageStatus, + modifier: Modifier = Modifier +){ + val color = if(status.errorStatus) LocalColors.current.danger else LocalColors.current.text + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing) + ) { + Image( + modifier = Modifier.size(LocalDimensions.current.iconXSmall), + painter = painterResource(id = status.icon), + colorFilter = ColorFilter.tint(color), + contentDescription = null, + ) + + Text( + text = status.title, + style = LocalType.current.extraSmall.copy(color = color) + ) + } +} + +@Preview +@Composable +fun PreviewStatus(){ + PreviewTheme { + MessageStatus( + "Failed to send", + R.drawable.ic_triangle_alert, + errorStatus = true + ) + } +} + @Composable fun CellMetadata( state: MessageDetailsState, + sendCommand: (Commands) -> Unit ) { state.apply { if (listOfNotNull(sent, received, error, senderInfo).isEmpty()) return @@ -213,22 +308,59 @@ fun CellMetadata( modifier = Modifier.padding(LocalDimensions.current.spacing), verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) ) { - TitledText(sent) - TitledText(received) + // Message Pro features + if(proFeatures.isNotEmpty()) { + MessageProFeatures( + features = proFeatures, + badgeClickable = proBadgeClickable, + sendCommand = sendCommand + ) + } + + // Show the sent details if we're the sender of the message, otherwise show the received details + if (sent != null) { TitledText(sent) } + if (received != null) { TitledText(received) } + TitledErrorText(error) - senderInfo?.let { + senderInfo?.let { sender -> TitledView(state.fromTitle) { - Row { - sender?.let { + Row( + modifier = Modifier.clickable{ + sendCommand(Commands.ShowUserProfileModal) + } + ) { + senderAvatarData?.let { Avatar( - recipient = it, modifier = Modifier - .align(Alignment.CenterVertically) - .size(46.dp) + .align(Alignment.CenterVertically), + size = LocalDimensions.current.iconLarge, + data = senderAvatarData, + badge = if (state.senderIsAdmin) { AvatarBadge.Admin } else AvatarBadge.None ) Spacer(modifier = Modifier.width(LocalDimensions.current.smallSpacing)) } - TitledMonospaceText(it) + + Column(verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsSpacing)) { + // author + ProBadgeText( + text = sender.title.string(), + textStyle = LocalType.current.xl.bold(), + showBadge = state.senderShowProBadge, + onBadgeClick = if(state.proBadgeClickable){{ + sendCommand(Commands.ShowProBadgeCTA) + }} else null + ) + + sender.text?.let { + val addressColor = if(state.senderIsBlinded) LocalColors.current.textSecondary else LocalColors.current.text + Text( + text = it, + style = LocalType.current.base.monospace().copy( + color = addressColor + ) + ) + } + } } } } @@ -237,6 +369,68 @@ fun CellMetadata( } } +@Composable +fun MessageProFeatures( + features: Set, + badgeClickable: Boolean, + sendCommand: (Commands) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + ProBadgeText( + text = stringResource(id = R.string.message), + textStyle = LocalType.current.xl.bold(), + badgeAtStart = true, + onBadgeClick = if(badgeClickable){{ + sendCommand(Commands.ShowProBadgeCTA) + }} else null + ) + + Text( + text = Phrase.from(LocalContext.current,R.string.proMessageInfoFeatures) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString(), + style = LocalType.current.large + ) + + features.forEach { + ProCTAFeature( + textStyle = LocalType.current.large, + padding = PaddingValues(), + data = CTAFeature.Icon( + text = when(it){ + ProStatusManager.MessageProFeature.ProBadge -> Phrase.from(LocalContext.current, R.string.proBadge) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + .toString() + ProStatusManager.MessageProFeature.LongMessage -> stringResource(id = R.string.proIncreasedMessageLengthFeature) + ProStatusManager.MessageProFeature.AnimatedAvatar -> stringResource(id = R.string.proAnimatedDisplayPictureFeature) + } + ) + ) + } + } +} + +@Preview +@Composable +fun PreviewMessageProFeatures(){ + PreviewTheme { + MessageProFeatures( + features = setOf( + ProStatusManager.MessageProFeature.ProBadge, + ProStatusManager.MessageProFeature.LongMessage, + ProStatusManager.MessageProFeature.AnimatedAvatar + ), + badgeClickable = false, + sendCommand = {} + ) + } +} + @Composable fun CellButtons( onReply: (() -> Unit)? = null, @@ -250,14 +444,23 @@ fun CellButtons( onReply?.let { LargeItemButton( R.string.reply, - R.drawable.ic_message_details__reply, + R.drawable.ic_reply, + onClick = it + ) + Divider() + } + + onResend?.let { + LargeItemButton( + R.string.resend, + R.drawable.ic_repeat_2, onClick = it ) Divider() } LargeItemButton( - R.string.copy, + R.string.messageCopy, R.drawable.ic_copy, onClick = onCopy ) @@ -266,16 +469,7 @@ fun CellButtons( onSave?.let { LargeItemButton( R.string.save, - R.drawable.ic_baseline_save_24, - onClick = it - ) - Divider() - } - - onResend?.let { - LargeItemButton( - R.string.resend, - R.drawable.ic_message_details__refresh, + R.drawable.ic_arrow_down_to_line, onClick = it ) Divider() @@ -284,7 +478,7 @@ fun CellButtons( onDelete?.let { LargeItemButton( R.string.delete, - R.drawable.ic_delete, + R.drawable.ic_trash_2, colors = dangerButtonColors(), onClick = it ) @@ -293,7 +487,6 @@ fun CellButtons( } } -@OptIn(ExperimentalFoundationApi::class) @Composable fun Carousel(attachments: List, onClick: (Int) -> Unit) { if (attachments.isEmpty()) return @@ -322,7 +515,6 @@ fun Carousel(attachments: List, onClick: (Int) -> Unit) { } @OptIn( - ExperimentalFoundationApi::class, ExperimentalGlideComposeApi::class ) @Composable @@ -341,7 +533,8 @@ private fun CarouselPager( modifier = Modifier .aspectRatio(1f) .clickable { onClick(i) }, - model = attachments[i].uri, + model = if(attachments[i].uri != null) DecryptableStreamUriLoader.DecryptableUri(attachments[i].uri!!) + else null, contentDescription = attachments[i].fileName ?: stringResource(id = R.string.image) ) } @@ -353,13 +546,16 @@ fun ExpandButton(modifier: Modifier = Modifier, onClick: () -> Unit) { Surface( shape = CircleShape, color = blackAlpha40, - modifier = modifier, + modifier = modifier + .clickable { onClick() }, contentColor = Color.White, ) { Icon( - painter = painterResource(id = R.drawable.ic_expand), + painter = painterResource(id = R.drawable.ic_maximize_2), contentDescription = stringResource(id = R.string.AccessibilityId_expand), - modifier = Modifier.clickable { onClick() }, + modifier = Modifier + .padding(LocalDimensions.current.xxsSpacing) + .size(LocalDimensions.current.xsSpacing), ) } } @@ -395,7 +591,8 @@ fun PreviewMessageDetails( ), fileName = "Screen Shot 2023-07-06 at 11.35.50 am.png", uri = Uri.parse(""), - hasImage = true + hasImage = true, + isDownloaded = true ), Attachment( fileDetails = listOf( @@ -403,7 +600,8 @@ fun PreviewMessageDetails( ), fileName = "Screen Shot 2023-07-06 at 11.35.50 am.png", uri = Uri.parse(""), - hasImage = true + hasImage = true, + isDownloaded = true ), Attachment( fileDetails = listOf( @@ -411,7 +609,8 @@ fun PreviewMessageDetails( ), fileName = "Screen Shot 2023-07-06 at 11.35.50 am.png", uri = Uri.parse(""), - hasImage = true + hasImage = true, + isDownloaded = true ) ), @@ -423,9 +622,13 @@ fun PreviewMessageDetails( ), sent = TitledText(R.string.sent, "6:12 AM Tue, 09/08/2022"), received = TitledText(R.string.received, "6:12 AM Tue, 09/08/2022"), - error = TitledText(R.string.error, "Message failed to send"), + error = TitledText(R.string.errorUnknown, "Message failed to send"), senderInfo = TitledText("Connor", "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54"), - ) + senderShowProBadge = true + + ), + sendCommand = {}, + retryFailedAttachments = {} ) } } @@ -445,7 +648,7 @@ fun FileDetails(fileDetails: List) { TitledText( it, modifier = Modifier - .widthIn(min = maxWidth.div(2)) + .widthIn(min = this.maxWidth.div(2)) .padding(horizontal = LocalDimensions.current.xsSpacing) .width(IntrinsicSize.Max) ) @@ -459,34 +662,28 @@ fun FileDetails(fileDetails: List) { fun TitledErrorText(titledText: TitledText?) { TitledText( titledText, - style = LocalType.current.base, + style = LocalType.current.large, color = LocalColors.current.danger ) } -@Composable -fun TitledMonospaceText(titledText: TitledText?) { - TitledText( - titledText, - style = LocalType.current.base.monospace() - ) -} - @Composable fun TitledText( titledText: TitledText?, modifier: Modifier = Modifier, - style: TextStyle = LocalType.current.base, + style: TextStyle = LocalType.current.large, color: Color = Color.Unspecified ) { titledText?.apply { TitledView(title, modifier) { - Text( - text, - style = style, - color = color, - modifier = Modifier.fillMaxWidth() - ) + if (text != null) { + Text( + text, + style = style, + color = color, + modifier = Modifier.fillMaxWidth() + ) + } } } } @@ -494,7 +691,40 @@ fun TitledText( @Composable fun TitledView(title: GetString, modifier: Modifier = Modifier, content: @Composable () -> Unit) { Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsSpacing)) { - Text(title.string(), style = LocalType.current.base.bold()) + Text(title.string(), style = LocalType.current.xl.bold()) content() } } + +@Composable +fun MessageDetailDialogs( + state: DialogsState, + sendCommand: (Commands) -> Unit +){ + // Pro badge CTAs + if(state.proBadgeCTA != null){ + when(state.proBadgeCTA){ + is ProBadgeCTA.Generic -> + GenericProCTA(onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)}) + + is ProBadgeCTA.LongMessage -> + LongMessageProCTA(onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)}) + + is ProBadgeCTA.AnimatedProfile -> + AnimatedProfilePicProCTA(onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)}) + } + } + + // user profile modal + if(state.userProfileModal != null){ + UserProfileModal( + data = state.userProfileModal, + onDismissRequest = { + sendCommand(Commands.HideUserProfileModal) + }, + sendCommand = { + sendCommand(Commands.HandleUserProfileCommand(it)) + }, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt index 932384a7be..d89c485c58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt @@ -1,105 +1,259 @@ package org.thoughtcrime.securesms.conversation.v2 import android.net.Uri +import android.text.format.Formatter +import androidx.annotation.DrawableRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R +import network.loki.messenger.libsession_util.getOrNull import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager -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.Address +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.IdPrefix +import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.MediaPreviewArgs import org.thoughtcrime.securesms.database.AttachmentDatabase +import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.LokiMessageDatabase +import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.mms.ImageSlide import org.thoughtcrime.securesms.mms.Slide -import org.thoughtcrime.securesms.repository.ConversationRepository +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.pro.ProStatusManager.MessageProFeature.* import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.TitledText -import java.util.Date +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUtils +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.UserProfileModalCommands +import org.thoughtcrime.securesms.util.UserProfileModalData +import org.thoughtcrime.securesms.util.UserProfileUtils +import org.thoughtcrime.securesms.util.observeChanges +import java.util.Locale import java.util.concurrent.TimeUnit -import javax.inject.Inject +import kotlin.text.Typography.ellipsis -@HiltViewModel -class MessageDetailsViewModel @Inject constructor( +@HiltViewModel(assistedFactory = MessageDetailsViewModel.Factory::class) +class MessageDetailsViewModel @AssistedInject constructor( + @Assisted val messageId: MessageId, + private val prefs: TextSecurePreferences, private val attachmentDb: AttachmentDatabase, private val lokiMessageDatabase: LokiMessageDatabase, private val mmsSmsDatabase: MmsSmsDatabase, private val threadDb: ThreadDatabase, - private val repository: ConversationRepository, + private val lokiThreadDb: LokiThreadDatabase, private val deprecationManager: LegacyGroupDeprecationManager, + private val context: ApplicationContext, + private val avatarUtils: AvatarUtils, + private val dateUtils: DateUtils, + private val proStatusManager: ProStatusManager, + private val openGroupManager: OpenGroupManager, + private val configFactory: ConfigFactoryProtocol, + private val upmFactory: UserProfileUtils.UserProfileUtilsFactory, + attachmentDownloadHandlerFactory: AttachmentDownloadHandler.Factory ) : ViewModel() { - - private var job: Job? = null - private val state = MutableStateFlow(MessageDetailsState()) val stateFlow = state.asStateFlow() private val event = Channel() val eventFlow = event.receiveAsFlow() - var timestamp: Long = 0L - set(value) { - job?.cancel() + private val _dialogState: MutableStateFlow = MutableStateFlow(DialogsState()) + val dialogState: StateFlow = _dialogState - field = value - val record = mmsSmsDatabase.getMessageForTimestamp(timestamp) + private val attachmentDownloadHandler = attachmentDownloadHandlerFactory.create(viewModelScope) - if (record == null) { - viewModelScope.launch { event.send(Event.Finish) } - return - } + private var userProfileModalJob: Job? = null + private var userProfileModalUtils: UserProfileUtils? = null - val mmsRecord = record as? MmsMessageRecord + init { + viewModelScope.launch { + val messageRecord = withContext(Dispatchers.Default) { + mmsSmsDatabase.getMessageById(messageId) + } - job = viewModelScope.launch { - repository.changes(record.threadId) - .filter { mmsSmsDatabase.getMessageForTimestamp(value) == null } - .collect { event.send(Event.Finish) } + if (messageRecord == null) { + event.send(Event.Finish) + return@launch } - state.value = record.run { + // listen to conversation and attachments changes + (context.contentResolver.observeChanges(DatabaseContentProviders.Conversation.getUriForThread(messageRecord.threadId)) as Flow<*>) + .debounce(200L) + .map { + withContext(Dispatchers.Default) { + mmsSmsDatabase.getMessageById(messageId) + } + } + .onStart { emit(messageRecord) } + .collect { updatedRecord -> + if(updatedRecord == null) event.send(Event.Finish) + else { + createStateFromRecord(updatedRecord) + } + } + } + } + + private suspend fun createStateFromRecord(messageRecord: MessageRecord){ + val mmsRecord = messageRecord as? MmsMessageRecord + + withContext(Dispatchers.Default){ + state.value = messageRecord.run { val slides = mmsRecord?.slideDeck?.slides ?: emptyList() - val recipient = threadDb.getRecipientForThreadId(threadId)!! - val isDeprecatedLegacyGroup = recipient.isLegacyGroupRecipient && + val conversation = threadDb.getRecipientForThreadId(threadId)!! + val isDeprecatedLegacyGroup = conversation.isLegacyGroupRecipient && deprecationManager.isDeprecated + + val errorString = lokiMessageDatabase.getErrorMessage(messageId) + + var status: MessageStatus? = null + // create a 'failed to send' status if appropriate + if(messageRecord.isFailed){ + status = MessageStatus( + title = context.getString(R.string.messageStatusFailedToSend), + icon = R.drawable.ic_triangle_alert, + errorStatus = true + ) + } + + val sender = if(messageRecord.isOutgoing){ + Recipient.from(context, Address.fromSerialized(prefs.getLocalNumber() ?: ""), false) + } else individualRecipient + + val attachments = slides.map(::Attachment) + + val isAdmin: Boolean = when { + // for Groups V2 + conversation.isGroupV2Recipient -> configFactory.withGroupConfigs(AccountId(conversation.address.toString())) { + it.groupMembers.getOrNull(sender.address.toString())?.admin == true + } + + // for communities the the `isUserModerator` field + conversation.isCommunityRecipient -> checkCommunityAdmin(sender, threadId) + + // false in other cases + else -> false + } + + // we don't want to display image attachments in the carousel if their state isn't done + val imageAttachments = attachments.filter { it.isDownloaded && it.hasImage } + + // get the helper class for the selected user + userProfileModalUtils = upmFactory.create( + recipient = sender, + threadId = threadId, + scope = viewModelScope + ) + MessageDetailsState( - attachments = slides.map(::Attachment), - record = record, - sent = dateSent.let(::Date).toString().let { TitledText(R.string.sent, it) }, - received = dateReceived.let(::Date).toString().let { TitledText(R.string.received, it) }, - error = lokiMessageDatabase.getErrorMessage(id)?.let { TitledText(R.string.theError, it) }, - senderInfo = individualRecipient.run { TitledText(name, address.serialize()) }, - sender = individualRecipient, - thread = recipient, + //todo: ATTACHMENT We should sort out the equals in DatabaseAttachment which is the reason the StateFlow think the objects are the same in spite of the transferState of an attachment being different. That way we could remove the timestamp below + timestamp = System.currentTimeMillis(), // used as a trick to force the state as being marked aas different each time + attachments = attachments, + imageAttachments = imageAttachments, + record = messageRecord, + + // Set the "Sent" message info TitledText appropriately + sent = if (messageRecord.isSending && errorString == null) { + val sendingWithEllipsisString = context.getString(R.string.sending) + ellipsis // e.g., "Sending…" + TitledText(sendingWithEllipsisString, null) + } else if (dateSent > 0L && errorString == null) { + TitledText(R.string.sent, dateUtils.getMessageDateTimeFormattedString(dateSent)) + } else { + null // Not sending or sent? Don't display anything for the "Sent" element. + }, + + // Set the "Received" message info TitledText appropriately + received = if (dateReceived > 0L && messageRecord.isIncoming && errorString == null) { + TitledText(R.string.received, dateUtils.getMessageDateTimeFormattedString(dateReceived)) + } else { + null // Not incoming? Then don't display anything for the "Received" element. + }, + + error = errorString?.let { TitledText(context.getString(R.string.theError) + ":", it) }, + status = status, + senderInfo = sender.run { + TitledText( + if(messageRecord.isOutgoing) context.getString(R.string.you) else name, + address.toString() + ) + }, + senderAvatarData = avatarUtils.getUIDataFromRecipient(sender), + senderShowProBadge = proStatusManager.shouldShowProBadge(sender.address), + senderIsAdmin = isAdmin, + senderIsBlinded = IdPrefix.fromValue(sender.address.toString())?.isBlinded() ?: false, + thread = conversation, readOnly = isDeprecatedLegacyGroup, + proFeatures = proStatusManager.getMessageProFeatures(messageRecord.messageId), + proBadgeClickable = !proStatusManager.isCurrentUserPro() // no badge click if the current user is pro ) } } + } + + private fun checkCommunityAdmin(sender: Recipient, threadId: Long): Boolean { + val senderAccountID = sender.address.toString() + val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return false + var standardPublicKey = "" + var blindedPublicKey: String? = null + if (IdPrefix.fromValue(senderAccountID)?.isBlinded() == true) { + blindedPublicKey = senderAccountID + } else { + standardPublicKey = senderAccountID + } + return openGroupManager.isUserModerator( + openGroup.groupId, + standardPublicKey, + blindedPublicKey + ) + } + + fun showUserProfileModal() { + // cancel previous job if any then listen in on the changes + userProfileModalJob?.cancel() + userProfileModalJob = viewModelScope.launch { + userProfileModalUtils?.userProfileModalData?.collect { upmData -> + _dialogState.update { it.copy(userProfileModal = upmData) } + } + } + } private val Slide.details: List get() = listOfNotNull( - fileName.orNull()?.let { TitledText(R.string.attachmentsFileId, it) }, + TitledText(R.string.attachmentsFileId, filename), TitledText(R.string.attachmentsFileType, asAttachment().contentType), - TitledText(R.string.attachmentsFileSize, Util.getPrettyFileSize(fileSize)), + TitledText(R.string.attachmentsFileSize, Formatter.formatFileSize(context, fileSize)), takeIf { it is ImageSlide } ?.let(Slide::asAttachment) ?.run { "${width}x$height" } @@ -114,28 +268,28 @@ class MessageDetailsViewModel @Inject constructor( ?.takeIf { it > 0 } ?.let { String.format( + Locale.getDefault(), "%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 Attachment(slide: Slide): Attachment = Attachment( + fileDetails = slide.details, + fileName = slide.filename, + uri = slide.thumbnailUri, + hasImage = slide.hasImage(), + isDownloaded = slide.isDone + ) - fun onClickImage(index: Int) { + private fun openImage(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) - } - } - - if (slide.isInProgress) return + if (slide.isInProgress || slide.isFailed) return + if(state.thread == null) return viewModelScope.launch { MediaPreviewArgs(slide, state.mmsRecord, state.thread) @@ -144,37 +298,119 @@ class MessageDetailsViewModel @Inject constructor( } } - fun onAttachmentNeedsDownload(attachment: DatabaseAttachment) { - viewModelScope.launch(Dispatchers.IO) { - JobQueue.shared.add(AttachmentDownloadJob(attachment.attachmentId.rowId, attachment.mmsId)) + fun retryFailedAttachments(attachments: List){ + attachmentDownloadHandler.retryFailedAttachments(attachments) + } + + fun onCommand(command: Commands) { + when (command) { + is Commands.OpenImage -> { + openImage(command.imageIndex) + } + + is Commands.ShowProBadgeCTA -> { + val features = state.value.proFeatures + _dialogState.update { + it.copy( + proBadgeCTA = when{ + features.size > 1 -> ProBadgeCTA.Generic // always show the generic cta when there are more than 1 feature + + features.contains(LongMessage) -> ProBadgeCTA.LongMessage + features.contains(AnimatedAvatar) -> ProBadgeCTA.AnimatedProfile + else -> ProBadgeCTA.Generic + } + ) + } + } + + is Commands.HideProBadgeCTA -> { + _dialogState.update { it.copy(proBadgeCTA = null) } + } + + is Commands.ShowUserProfileModal -> { + showUserProfileModal() + } + + is Commands.HideUserProfileModal -> { + _dialogState.update { it.copy(userProfileModal = null) } + } + + is Commands.HandleUserProfileCommand -> { + userProfileModalUtils?.onCommand(command.upmCommand) + } } } + + @AssistedFactory + interface Factory { + fun create(id: MessageId) : MessageDetailsViewModel + } +} + +sealed interface Commands { + data class OpenImage( + val imageIndex: Int + ): Commands + + object ShowProBadgeCTA: Commands + object HideProBadgeCTA: Commands + + object ShowUserProfileModal: Commands + data object HideUserProfileModal: Commands + data class HandleUserProfileCommand( + val upmCommand: UserProfileModalCommands + ): Commands } data class MessageDetailsState( + val timestamp: Long = 0L, val attachments: List = emptyList(), - val imageAttachments: List = attachments.filter { it.hasImage }, + val imageAttachments: List = emptyList(), val nonImageAttachmentFileDetails: List? = 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 status: MessageStatus? = null, val senderInfo: TitledText? = null, - val sender: Recipient? = null, + val senderAvatarData: AvatarUIData? = null, + val senderIsAdmin: Boolean = false, + val senderShowProBadge: Boolean = false, + val senderIsBlinded: Boolean = false, val thread: Recipient? = null, val readOnly: Boolean = false, + val proFeatures: Set = emptySet(), + val proBadgeClickable: Boolean = false, ) { val fromTitle = GetString(R.string.from) val canReply: Boolean get() = !readOnly && record?.isOpenGroupInvitation != true val canDelete: Boolean get() = !readOnly } +sealed interface ProBadgeCTA { + data object Generic: ProBadgeCTA + data object LongMessage: ProBadgeCTA + data object AnimatedProfile: ProBadgeCTA +} + +data class DialogsState( + val proBadgeCTA: ProBadgeCTA? = null, + val userProfileModal: UserProfileModalData? = null, +) + data class Attachment( val fileDetails: List, val fileName: String?, val uri: Uri?, - val hasImage: Boolean + val hasImage: Boolean, + val isDownloaded: Boolean +) + +data class MessageStatus( + val title: String, + @DrawableRes val icon: Int, + val errorStatus: Boolean ) sealed class Event { 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 deleted file mode 100644 index b31c298f26..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ModalUrlBottomSheet.kt +++ /dev/null @@ -1,77 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2 - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context.CLIPBOARD_SERVICE -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.style.StyleSpan -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.squareup.phrase.Phrase -import network.loki.messenger.R -import network.loki.messenger.databinding.FragmentModalUrlBottomSheetBinding -import org.session.libsession.utilities.StringSubstitutionConstants.URL_KEY -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.ui.getSubbedString - -class ModalUrlBottomSheet(private val url: String): BottomSheetDialogFragment(), View.OnClickListener { - private lateinit var binding: FragmentModalUrlBottomSheetBinding - - override fun onCreateView(inflater: LayoutInflater,container: ViewGroup?,savedInstanceState: Bundle?): View { - binding = FragmentModalUrlBottomSheetBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - if (context == null) { return Log.w("MUBS", "Context is null") } - val explanation = requireContext().getSubbedString(R.string.urlOpenDescription, URL_KEY to url) - val spannable = SpannableStringBuilder(explanation) - val startIndex = explanation.indexOf(url) - spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + url.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - binding.openURLExplanationTextView.text = spannable - binding.cancelButton.setOnClickListener(this) - binding.copyButton.setOnClickListener(this) - binding.openURLButton.setOnClickListener(this) - } - - private fun open() { - try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - requireContext().startActivity(intent) - } catch (e: Exception) { - Toast.makeText(context, R.string.communityEnterUrlErrorInvalid, Toast.LENGTH_SHORT).show() - } - dismiss() - } - - private fun copy() { - val clip = ClipData.newPlainText("URL", url) - val manager = requireContext().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager - manager.setPrimaryClip(clip) - Toast.makeText(requireContext(), R.string.copied, Toast.LENGTH_SHORT).show() - dismiss() - } - - override fun onStart() { - super.onStart() - val window = dialog?.window ?: return - window.setDimAmount(0.6f) - } - - override fun onClick(v: View?) { - when (v) { - binding.openURLButton -> open() - binding.copyButton -> copy() - binding.cancelButton -> dismiss() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ViewUtil.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ViewUtil.java index 814090b036..a105852ed4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ViewUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ViewUtil.java @@ -180,6 +180,14 @@ public static void mirrorIfRtl(View view, Context context) { } } + public static String safeRTLString(@NonNull Context context, @NonNull String text) { + return isLtr(context) ? + "\u200E" + text + "\u200E" : + "\u200F" + text + "\u200F"; + } + + + public static boolean isLtr(@NonNull View view) { return isLtr(view.getContext()); } 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 deleted file mode 100644 index 6083bb267c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/WindowUtil.java +++ /dev/null @@ -1,88 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2; - -import android.app.Activity; -import android.graphics.Rect; -import android.os.Build; -import android.view.View; -import android.view.Window; - -import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; - -import org.session.libsession.utilities.ThemeUtil; - -public final class WindowUtil { - - private WindowUtil() { - } - - public static void setLightNavigationBarFromTheme(@NonNull Activity activity) { - if (Build.VERSION.SDK_INT < 27) return; - - final boolean isLightNavigationBar = ThemeUtil.getThemedBoolean(activity, android.R.attr.windowLightNavigationBar); - - if (isLightNavigationBar) setLightNavigationBar(activity.getWindow()); - else clearLightNavigationBar(activity.getWindow()); - } - - public static void clearLightNavigationBar(@NonNull Window window) { - if (Build.VERSION.SDK_INT < 27) return; - - clearSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR); - } - - public static void setLightNavigationBar(@NonNull Window window) { - if (Build.VERSION.SDK_INT < 27) return; - - setSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR); - } - - public static void setNavigationBarColor(@NonNull Window window, @ColorInt int color) { - window.setNavigationBarColor(color); - } - - public static void setLightStatusBarFromTheme(@NonNull Activity activity) { - final boolean isLightStatusBar = ThemeUtil.getThemedBoolean(activity, android.R.attr.windowLightStatusBar); - - if (isLightStatusBar) setLightStatusBar(activity.getWindow()); - else clearLightStatusBar(activity.getWindow()); - } - - public static void clearLightStatusBar(@NonNull Window window) { - clearSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); - } - - public static void setLightStatusBar(@NonNull Window window) { - setSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); - } - - public static void setStatusBarColor(@NonNull Window window, @ColorInt int color) { - window.setStatusBarColor(color); - } - - /** - * A sort of roundabout way of determining if the status bar is present by seeing if there's a - * vertical window offset. - */ - public static boolean isStatusBarPresent(@NonNull Window window) { - Rect rectangle = new Rect(); - window.getDecorView().getWindowVisibleDisplayFrame(rectangle); - return rectangle.top > 0; - } - - private static void clearSystemUiFlags(@NonNull Window window, int flags) { - View view = window.getDecorView(); - int uiFlags = view.getSystemUiVisibility(); - - uiFlags &= ~flags; - view.setSystemUiVisibility(uiFlags); - } - - private static void setSystemUiFlags(@NonNull Window window, int flags) { - View view = window.getDecorView(); - int uiFlags = view.getSystemUiVisibility(); - - uiFlags |= flags; - view.setSystemUiVisibility(uiFlags); - } -} 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 0b61dec9d4..b85f0f6d9c 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 @@ -11,10 +11,10 @@ import android.widget.RelativeLayout import android.widget.TextView import androidx.core.view.children import androidx.core.view.isVisible +import com.bumptech.glide.RequestManager import com.squareup.phrase.Phrase import network.loki.messenger.R import network.loki.messenger.databinding.AlbumThumbnailViewBinding -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY import org.session.libsession.utilities.recipients.Recipient @@ -22,7 +22,6 @@ import org.thoughtcrime.securesms.MediaPreviewActivity import org.thoughtcrime.securesms.components.CornerMask import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView import org.thoughtcrime.securesms.database.model.MmsMessageRecord -import com.bumptech.glide.RequestManager import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.util.ActivityDispatcher @@ -50,7 +49,7 @@ class AlbumThumbnailView : RelativeLayout { // region Interaction - fun calculateHitObject(event: MotionEvent, mms: MmsMessageRecord, threadRecipient: Recipient, onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit) { + fun calculateHitObject(event: MotionEvent, mms: MmsMessageRecord, threadRecipient: Recipient, downloadPendingAttachment: (DatabaseAttachment) -> Unit) { val rawXInt = event.rawX.toInt() val rawYInt = event.rawY.toInt() val eventRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt) @@ -62,10 +61,10 @@ class AlbumThumbnailView : RelativeLayout { // hit intersects with this particular child val slide = slides.getOrNull(index) ?: return@forEach // only open to downloaded images - if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) { + if (slide.isFailed) { // Restart download here (on IO thread) (slide.asAttachment() as? DatabaseAttachment)?.let { attachment -> - onAttachmentNeedsDownload(attachment) + downloadPendingAttachment(attachment) } } if (slide.isInProgress) return@forEach @@ -84,7 +83,9 @@ class AlbumThumbnailView : RelativeLayout { fun bind(glideRequests: RequestManager, message: MmsMessageRecord, isStart: Boolean, isEnd: Boolean) { - slides = message.slideDeck.thumbnailSlides + slides = message.slideDeck.thumbnailSlides.filter { + it.isDone || (message.isOutgoing && it.uri != null) + } if (slides.isEmpty()) { // this should never be encountered because it's checked by parent return 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 index d173dacfef..c17a4bc114 100644 --- 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 @@ -15,19 +15,19 @@ class ExpirationTimerView @JvmOverloads constructor( 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 + R.drawable.ic_clock_0, + R.drawable.ic_clock_1, + R.drawable.ic_clock_2, + R.drawable.ic_clock_3, + R.drawable.ic_clock_4, + R.drawable.ic_clock_5, + R.drawable.ic_clock_6, + R.drawable.ic_clock_7, + R.drawable.ic_clock_8, + R.drawable.ic_clock_9, + R.drawable.ic_clock_10, + R.drawable.ic_clock_11, + R.drawable.ic_clock_12 ) fun setTimerIcon() { @@ -36,13 +36,13 @@ class ExpirationTimerView @JvmOverloads constructor( fun setExpirationTime(startedAt: Long, expiresIn: Long) { if (expiresIn == 0L) { - setImageResource(R.drawable.timer55) + setImageResource(R.drawable.ic_clock_11) return } if (startedAt == 0L) { // timer has not started - setImageResource(R.drawable.timer60) + setImageResource(R.drawable.ic_clock_12) return } 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 894b28a2f1..2554431547 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 @@ -16,16 +16,13 @@ import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.ui.getSubbedCharSequence /** Shown upon sending a message to a user that's blocked. */ -class BlockedDialog(private val recipient: Recipient, private val context: Context) : DialogFragment() { +class BlockedDialog(private val recipient: Recipient, private val contactName: String) : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { - val accountID = recipient.address.toString() - val name = MessagingModuleConfiguration.shared.storage.getContactNameWithAccountID(accountID) - - val explanationCS = context.getSubbedCharSequence(R.string.blockUnblockName, NAME_KEY to name) + val explanationCS = context.getSubbedCharSequence(R.string.blockUnblockName, NAME_KEY to contactName) val spannable = SpannableStringBuilder(explanationCS) - val startIndex = explanationCS.indexOf(name) - spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + val startIndex = explanationCS.indexOf(contactName) + spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + contactName.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) title(resources.getString(R.string.blockUnblock)) text(spannable) 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 4dd0b9a89d..848c04b9fa 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 @@ -7,13 +7,15 @@ import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import org.session.libsession.database.StorageProtocol +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.AttachmentState import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.StringSubstitutionConstants.CONVERSATION_NAME_KEY import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.database.SessionContactDatabase -import org.thoughtcrime.securesms.util.createAndStartAttachmentDownload import javax.inject.Inject /** Shown when receiving media from a contact for the first time, to confirm that @@ -25,12 +27,13 @@ class AutoDownloadDialog(private val threadRecipient: Recipient, @Inject lateinit var storage: StorageProtocol @Inject lateinit var contactDB: SessionContactDatabase + @Inject lateinit var attachmentDownloadJobFactory: AttachmentDownloadJob.Factory override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { title(getString(R.string.attachmentsAutoDownloadModalTitle)) val explanation = Phrase.from(context, R.string.attachmentsAutoDownloadModalDescription) - .put(CONVERSATION_NAME_KEY, threadRecipient.toShortString()) + .put(CONVERSATION_NAME_KEY, threadRecipient.name) .format() text(explanation) @@ -43,6 +46,12 @@ class AutoDownloadDialog(private val threadRecipient: Recipient, private fun setAutoDownload() { storage.setAutoDownloadAttachments(threadRecipient, true) - JobQueue.shared.createAndStartAttachmentDownload(databaseAttachment) + + val attachmentId = databaseAttachment.attachmentId.rowId + if (databaseAttachment.transferState == AttachmentState.PENDING.value + && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { + // start download + JobQueue.shared.add(attachmentDownloadJobFactory.create(attachmentId, databaseAttachment.mmsId)) + } } } 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 9f1e571bc4..ac1b624b16 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 @@ -30,6 +30,9 @@ class JoinOpenGroupDialog(private val name: String, private val url: String) : D @Inject lateinit var storage: StorageProtocol + @Inject + lateinit var openGroupManager: OpenGroupManager + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { title(resources.getString(R.string.communityJoin)) val explanation = Phrase.from(context, R.string.communityJoinDescription).put(COMMUNITY_NAME_KEY, name).format() @@ -53,7 +56,7 @@ class JoinOpenGroupDialog(private val name: String, private val url: String) : D GlobalScope.launch(Dispatchers.Main) { try { withContext(Dispatchers.Default) { - OpenGroupManager.add( + openGroupManager.add( server = openGroup.server, room = openGroup.room, publicKey = openGroup.serverPublicKey, 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 fe86f8d382..dbdc1156e0 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 @@ -5,7 +5,6 @@ import android.content.Context import android.graphics.PointF import android.net.Uri import android.text.Editable -import android.text.InputType import android.util.AttributeSet import android.view.KeyEvent import android.view.LayoutInflater @@ -16,21 +15,29 @@ import android.widget.RelativeLayout import android.widget.TextView import androidx.core.view.isGone import androidx.core.view.isVisible +import com.bumptech.glide.RequestManager import network.loki.messenger.R import network.loki.messenger.databinding.ViewInputBarBinding import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview +import org.session.libsession.utilities.Address import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.InputbarViewModel +import org.thoughtcrime.securesms.InputbarViewModel.InputBarContentState +import org.thoughtcrime.securesms.conversation.v2.ViewUtil import org.thoughtcrime.securesms.conversation.v2.components.LinkPreviewDraftView import org.thoughtcrime.securesms.conversation.v2.components.LinkPreviewDraftViewDelegate import org.thoughtcrime.securesms.conversation.v2.messages.QuoteView import org.thoughtcrime.securesms.conversation.v2.messages.QuoteViewDelegate import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord -import com.bumptech.glide.RequestManager import org.thoughtcrime.securesms.util.addTextChangedListener import org.thoughtcrime.securesms.util.contains +// TODO: A lot of the logic regarding voice messages is currently performed in the ConversationActivity +// TODO: and here - it would likely be best to move this into the CA's ViewModel. + // Enums to keep track of the state of our voice recording mechanism as the user can // manipulate the UI faster than we can setup & teardown. enum class VoiceRecorderState { @@ -67,87 +74,60 @@ class InputBar @JvmOverloads constructor( showOrHideInputIfNeeded() } } - var showMediaControls: Boolean = true + private var allowAttachMultimediaButtons: Boolean = true set(value) { field = value - showOrHideMediaControlsIfNeeded() - binding.inputBarEditText.showMediaControls = value + updateMultimediaButtonsState() + + binding.inputBarEditText.allowMultimediaInput = value } var text: String get() = binding.inputBarEditText.text?.toString() ?: "" set(value) { binding.inputBarEditText.setText(value) } - // Keep track of when the user pressed the record voice message button, the duration that - // they held record, and the current audio recording mechanism state. - private var voiceMessageStartMS = 0L - var voiceMessageDurationMS = 0L + fun setText(text: CharSequence, type: TextView.BufferType){ + binding.inputBarEditText.setText(text, type) + } + var voiceRecorderState = VoiceRecorderState.Idle - private val attachmentsButton = InputBarButton(context, R.drawable.ic_plus_24).apply { contentDescription = context.getString(R.string.AccessibilityId_attachmentsButton)} - val microphoneButton = InputBarButton(context, R.drawable.ic_microphone).apply { contentDescription = context.getString(R.string.AccessibilityId_voiceMessageNew)} - private val sendButton = InputBarButton(context, R.drawable.ic_arrow_up, true).apply { contentDescription = context.getString(R.string.AccessibilityId_send)} + private val attachmentsButton = InputBarButton(context, R.drawable.ic_plus).apply { + contentDescription = context.getString(R.string.AccessibilityId_attachmentsButton) + } + + val microphoneButton = InputBarButton(context, R.drawable.ic_mic).apply { + contentDescription = context.getString(R.string.AccessibilityId_voiceMessageNew) + } - init { - // Attachments button - binding.attachmentsButtonContainer.addView(attachmentsButton) - attachmentsButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) - attachmentsButton.onPress = { toggleAttachmentOptions() } - - // Microphone button - binding.microphoneOrSendButtonContainer.addView(microphoneButton) - microphoneButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) - - microphoneButton.onMove = { delegate?.onMicrophoneButtonMove(it) } - microphoneButton.onCancel = { delegate?.onMicrophoneButtonCancel(it) } - - // Use a separate 'raw' OnTouchListener to record the microphone button down/up timestamps because - // they don't get delayed by any multi-threading or delegates which throw off the timestamp accuracy. - // For example: If we bind something to `microphoneButton.onPress` and also log something in - // `microphoneButton.onUp` and tap the button then the logged output order is onUp and THEN onPress! - microphoneButton.setOnTouchListener(object : OnTouchListener { - override fun onTouch(v: View, event: MotionEvent): Boolean { - if (!microphoneButton.snIsEnabled) return true - - // We only handle single finger touch events so just consume the event and bail if there are more - if (event.pointerCount > 1) return true - - when (event.action) { - MotionEvent.ACTION_DOWN -> { - // Only start spinning up the voice recorder if we're not already recording, setting up, or tearing down - if (voiceRecorderState == VoiceRecorderState.Idle) { - // Take note of when we start recording so we can figure out how long the record button was held for - voiceMessageStartMS = System.currentTimeMillis() - - // We are now setting up to record, and when we actually start recording then - // AudioRecorder.startRecording will move us into the Recording state. - voiceRecorderState = VoiceRecorderState.SettingUpToRecord - startRecordingVoiceMessage() - } - } - MotionEvent.ACTION_UP -> { - // Work out how long the record audio button was held for - voiceMessageDurationMS = System.currentTimeMillis() - voiceMessageStartMS; - - // Regardless of our current recording state we'll always call the onMicrophoneButtonUp method - // and let the logic in that take the appropriate action as we cannot guarantee that letting - // go of the record button should always stop recording audio because the user may have moved - // the button into the 'locked' state so they don't have to keep it held down to record a voice - // message. - // Also: We need to tear down the voice recorder if it has been recording and is now stopping. - delegate?.onMicrophoneButtonUp(event) - } - } + private val sendButton = InputBarButton(context, R.drawable.ic_arrow_up, isSendButton = true).apply { + contentDescription = context.getString(R.string.AccessibilityId_send) + } + + private val textColor: Int by lazy { + context.getColorFromAttr(android.R.attr.textColorPrimary) + } - // Return false to propagate the event rather than consuming it - return false + private val dangerColor: Int by lazy { + context.getColorFromAttr(R.attr.danger) + } + + var sendOnly: Boolean = false + + init { + // Parse custom attributes + attrs?.let { attributeSet -> + val typedArray = context.obtainStyledAttributes(attributeSet, R.styleable.InputBar) + try { + sendOnly = typedArray.getBoolean(R.styleable.InputBar_sendOnly, false) + } finally { + typedArray.recycle() } - }) + } // Send button binding.microphoneOrSendButtonContainer.addView(sendButton) sendButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) - sendButton.isVisible = false sendButton.onUp = { e -> if (sendButton.contains(PointF(e.x, e.y))) { delegate?.sendMessage() @@ -158,16 +138,66 @@ class InputBar @JvmOverloads constructor( binding.inputBarEditText.setOnEditorActionListener(this) if (TextSecurePreferences.isEnterSendsEnabled(context)) { binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_SEND - binding.inputBarEditText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES } else { binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_NONE - binding.inputBarEditText.inputType = - binding.inputBarEditText.inputType - 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 binding.inputBarEditText.delegate = this + + if(sendOnly){ + sendButton.isVisible = true + binding.attachmentsButtonContainer.isVisible = false + microphoneButton.isVisible = false + } else { + sendButton.isVisible = false + + // Attachments button + binding.attachmentsButtonContainer.addView(attachmentsButton) + attachmentsButton.layoutParams = + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + attachmentsButton.onPress = { toggleAttachmentOptions() } + + // Microphone button + binding.microphoneOrSendButtonContainer.addView(microphoneButton) + microphoneButton.layoutParams = + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + + microphoneButton.onMove = { delegate?.onMicrophoneButtonMove(it) } + microphoneButton.onCancel = { delegate?.onMicrophoneButtonCancel(it) } + + // Use a separate 'raw' OnTouchListener to record the microphone button down/up timestamps because + // they don't get delayed by any multi-threading or delegates which throw off the timestamp accuracy. + // For example: If we bind something to `microphoneButton.onPress` and also log something in + // `microphoneButton.onUp` and tap the button then the logged output order is onUp and THEN onPress! + microphoneButton.setOnTouchListener(object : OnTouchListener { + override fun onTouch(v: View, event: MotionEvent): Boolean { + + // We only handle single finger touch events so just consume the event and bail if there are more + if (event.pointerCount > 1) return true + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + + // Only start spinning up the voice recorder if we're not already recording, setting up, or tearing down + if (voiceRecorderState == VoiceRecorderState.Idle) { + startRecordingVoiceMessage() + } + } + + MotionEvent.ACTION_UP -> { + + // Handle the pointer up event appropriately, whether that's to keep recording if recording was locked + // on, or finishing recording if just hold-to-record. + delegate?.onMicrophoneButtonUp(event) + } + } + + // Return false to propagate the event rather than consuming it + return false + } + }) + } } override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean { @@ -180,18 +210,19 @@ class InputBar @JvmOverloads constructor( } override fun inputBarEditTextContentChanged(text: CharSequence) { - microphoneButton.isVisible = text.trim().isEmpty() - sendButton.isVisible = microphoneButton.isGone + microphoneButton.isVisible = text.trim().isEmpty() && !sendOnly + sendButton.isVisible = microphoneButton.isGone || sendOnly delegate?.inputBarEditTextContentChanged(text) } - override fun inputBarEditTextHeightChanged(newValue: Int) { } - override fun commitInputContent(contentUri: Uri) { delegate?.commitInputContent(contentUri) } private fun toggleAttachmentOptions() { delegate?.toggleAttachmentOptions() } - private fun startRecordingVoiceMessage() { delegate?.startRecordingVoiceMessage() } + + private fun startRecordingVoiceMessage() { + delegate?.startRecordingVoiceMessage() + } fun draftQuote(thread: Recipient, message: MessageRecord, glide: RequestManager) { quoteView?.let(binding.inputBarAdditionalContentContainer::removeView) @@ -210,7 +241,9 @@ class InputBar @JvmOverloads constructor( 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() + val sender = + if (message.isOutgoing) Recipient.from(context, Address.fromSerialized(TextSecurePreferences.getLocalNumber(context)!!), false) + else message.individualRecipient it.bind(sender, message.body, attachments, thread, true, message.isOpenGroupInvitation, message.threadId, false, glide) } @@ -219,6 +252,10 @@ class InputBar @JvmOverloads constructor( if (linkPreview != null && linkPreviewDraftView != null) { binding.inputBarAdditionalContentContainer.addView(linkPreviewDraftView) } + + // focus the text and show keyboard + ViewUtil.focusAndShowKeyboard(binding.inputBarEditText) + requestLayout() } @@ -263,14 +300,15 @@ class InputBar @JvmOverloads constructor( } binding.inputBarEditText.isVisible = showInput - attachmentsButton.isVisible = showInput - microphoneButton.isVisible = showInput && text.isEmpty() - sendButton.isVisible = showInput && text.isNotEmpty() + attachmentsButton.isVisible = showInput && !sendOnly + microphoneButton.isVisible = showInput && text.isEmpty() && !sendOnly + sendButton.isVisible = showInput && text.isNotEmpty() || sendOnly } - private fun showOrHideMediaControlsIfNeeded() { - attachmentsButton.snIsEnabled = showMediaControls - microphoneButton.snIsEnabled = showMediaControls + private fun updateMultimediaButtonsState() { + attachmentsButton.isEnabled = allowAttachMultimediaButtons + + microphoneButton.isEnabled = allowAttachMultimediaButtons } fun addTextChangedListener(listener: (String) -> Unit) { @@ -280,6 +318,58 @@ class InputBar @JvmOverloads constructor( fun setInputBarEditableFactory(factory: Editable.Factory) { binding.inputBarEditText.setEditableFactory(factory) } + + fun setState(state: InputbarViewModel.InputBarState){ + // handle content state + when(state.contentState){ + is InputBarContentState.Hidden ->{ + isVisible = false + } + + is InputBarContentState.Disabled ->{ + isVisible = true + binding.inputBarEditText.isVisible = false + binding.inputBarAdditionalContentContainer.isVisible = false + binding.inputBarEditText.text?.clear() + inputBarEditTextContentChanged("") + binding.disabledBanner.isVisible = true + binding.disabledText.text = state.contentState.text + if(state.contentState.onClick == null){ + binding.disabledBanner.setOnClickListener(null) + } else { + binding.disabledBanner.setOnClickListener { + state.contentState.onClick() + } + } + } + + else -> { + isVisible = true + binding.inputBarEditText.isVisible = true + binding.inputBarAdditionalContentContainer.isVisible = true + binding.disabledBanner.isVisible = false + } + } + + // handle buttons state + allowAttachMultimediaButtons = state.enableAttachMediaControls + + // handle char limit + if(state.charLimitState != null){ + binding.characterLimitText.text = state.charLimitState.countFormatted + binding.characterLimitText.setTextColor(if(state.charLimitState.danger) dangerColor else textColor) + binding.characterLimitContainer.setOnClickListener { + delegate?.onCharLimitTapped() + } + + binding.badgePro.isVisible = state.charLimitState.showProBadge + + binding.characterLimitContainer.isVisible = true + } else { + binding.characterLimitContainer.setOnClickListener(null) + binding.characterLimitContainer.isVisible = false + } + } } interface InputBarDelegate { @@ -292,4 +382,5 @@ interface InputBarDelegate { fun onMicrophoneButtonUp(event: MotionEvent) fun sendMessage() fun commitInputContent(contentUri: Uri) + fun onCharLimitTapped() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarButton.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarButton.kt index c21de8021a..861ac91da9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarButton.kt @@ -5,7 +5,6 @@ import android.animation.ValueAnimator import android.content.Context import android.content.res.ColorStateList import android.graphics.PointF -import android.os.Build import android.os.Handler import android.os.Looper import android.util.AttributeSet @@ -28,11 +27,9 @@ class InputBarButton : RelativeLayout { private val gestureHandler = Handler(Looper.getMainLooper()) private var isSendButton = false private var hasOpaqueBackground = false - private var isGIFButton = false @DrawableRes private var iconID = 0 private var longPressCallback: Runnable? = null private var onDownTimestamp = 0L - var snIsEnabled = true var onPress: (() -> Unit)? = null var onMove: ((MotionEvent) -> Unit)? = null var onCancel: ((MotionEvent) -> Unit)? = null @@ -46,25 +43,31 @@ class InputBarButton : RelativeLayout { private val expandedImageViewPosition by lazy { PointF(0.0f, 0.0f) } private val collapsedImageViewPosition by lazy { PointF((expandedSize - collapsedSize) / 2, (expandedSize - collapsedSize) / 2) } - private val colorID by lazy { - if (hasOpaqueBackground) { - R.attr.input_bar_button_background_opaque - } else if (isSendButton) { - R.attr.colorAccent - } else { - R.attr.input_bar_button_background - } + private val backgroundColourId by lazy { + if (hasOpaqueBackground) { + R.attr.input_bar_button_background_opaque + } else if (isSendButton) { + R.attr.colorAccent + } else { + R.attr.input_bar_button_background + } } val expandedSize by lazy { resources.getDimension(R.dimen.input_bar_button_expanded_size) } val collapsedSize by lazy { resources.getDimension(R.dimen.input_bar_button_collapsed_size) } + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + + setIconTintColour() + } + private val imageViewContainer by lazy { val result = InputBarButtonImageViewContainer(context) val size = collapsedSize.toInt() result.layoutParams = LayoutParams(size, size) result.setBackgroundResource(R.drawable.input_bar_button_background) - result.mainColor = context.getColorFromAttr(colorID) + result.mainColor = context.getColorFromAttr(backgroundColourId) if (hasOpaqueBackground) { result.strokeColor = context.getColorFromAttr(R.attr.input_bar_button_background_opaque_border) } @@ -73,13 +76,10 @@ class InputBarButton : RelativeLayout { private val imageView by lazy { val result = ImageView(context) - val size = if (isGIFButton) toPx(24, resources) else toPx(16, resources) + val size = toPx(20, resources) result.layoutParams = LayoutParams(size, size) result.scaleType = ImageView.ScaleType.CENTER_INSIDE result.setImageResource(iconID) - result.imageTintList = if(isSendButton) - ColorStateList.valueOf(context.getColorFromAttr(R.attr.message_sent_text_color)) - else ColorStateList.valueOf(context.getColorFromAttr(R.attr.input_bar_button_text_color)) result } @@ -87,12 +87,14 @@ class InputBarButton : RelativeLayout { constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { throw IllegalAccessException("Use InputBarButton(context:iconID:) instead.") } constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { throw IllegalAccessException("Use InputBarButton(context:iconID:) instead.") } - constructor(context: Context, @DrawableRes iconID: Int, isSendButton: Boolean = false, - hasOpaqueBackground: Boolean = false, isGIFButton: Boolean = false) : super(context) { + constructor(context: Context, + @DrawableRes iconID: Int, + isSendButton: Boolean = false, + hasOpaqueBackground: Boolean = false + ) : super(context) { this.isSendButton = isSendButton this.iconID = iconID this.hasOpaqueBackground = hasOpaqueBackground - this.isGIFButton = isGIFButton val size = resources.getDimension(R.dimen.input_bar_button_expanded_size).toInt() val layoutParams = LayoutParams(size, size) this.layoutParams = layoutParams @@ -105,23 +107,23 @@ class InputBarButton : RelativeLayout { imageView.layoutParams = imageViewLayoutParams gravity = Gravity.TOP or Gravity.LEFT // Intentionally not Gravity.START isHapticFeedbackEnabled = true + this.isEnabled = isSendButton // Only enable the send button by default } fun getIconID() = iconID fun expand() { - val fromColor = context.getColorFromAttr(colorID) - val toColor = context.getAccentColor() - GlowViewUtilities.animateColorChange(imageViewContainer, fromColor, toColor) + val backgroundFromColor = context.getColorFromAttr(backgroundColourId) + val backgroundToColor = context.getAccentColor() + GlowViewUtilities.animateColorChange(imageViewContainer, backgroundFromColor, backgroundToColor) imageViewContainer.animateSizeChange(R.dimen.input_bar_button_collapsed_size, R.dimen.input_bar_button_expanded_size, animationDuration) animateImageViewContainerPositionChange(collapsedImageViewPosition, expandedImageViewPosition) } fun collapse() { - val fromColor = context.getAccentColor() - val toColor = context.getColorFromAttr(colorID) - - GlowViewUtilities.animateColorChange(imageViewContainer, fromColor, toColor) + val backgroundFromColor = context.getAccentColor() + val backgroundToColor = context.getColorFromAttr(backgroundColourId) + GlowViewUtilities.animateColorChange(imageViewContainer, backgroundFromColor, backgroundToColor) imageViewContainer.animateSizeChange(R.dimen.input_bar_button_expanded_size, R.dimen.input_bar_button_collapsed_size, animationDuration) animateImageViewContainerPositionChange(expandedImageViewPosition, collapsedImageViewPosition) } @@ -137,8 +139,27 @@ class InputBarButton : RelativeLayout { animation.start() } + // Tint the button icon the appropriate colour for the user's theme + private fun setIconTintColour() { + if (isEnabled) { + imageView.imageTintList = if (isSendButton) { + ColorStateList.valueOf(context.getColorFromAttr(R.attr.message_sent_text_color)) + } else { + ColorStateList.valueOf(context.getColorFromAttr(R.attr.input_bar_button_text_color)) + } + } else { + // Use the greyed out colour from the user theme + imageView.imageTintList = ColorStateList.valueOf(context.getColorFromAttr(R.attr.disabled)) + } + } + + override fun onTouchEvent(event: MotionEvent): Boolean { - if (!snIsEnabled) { return false } + // Ensure disabled buttons don't respond to events. + // Caution: We MUST return false here to propagate the event through to any other + // clickable elements such as avatar icons or media elements we might want to click on. + if (!this.isEnabled) return false + when (event.action) { MotionEvent.ACTION_DOWN -> onDown(event) MotionEvent.ACTION_MOVE -> onMove(event) @@ -155,7 +176,7 @@ class InputBarButton : RelativeLayout { longPressCallback?.let { gestureHandler.removeCallbacks(it) } val newLongPressCallback = Runnable { onLongPress?.invoke() } this.longPressCallback = newLongPressCallback - gestureHandler.postDelayed(newLongPressCallback, InputBarButton.longPressDurationThreshold) + gestureHandler.postDelayed(newLongPressCallback, longPressDurationThreshold) onDownTimestamp = Date().time } @@ -172,7 +193,7 @@ class InputBarButton : RelativeLayout { private fun onUp(event: MotionEvent) { onUp?.invoke(event) collapse() - if ((Date().time - onDownTimestamp) < InputBarButton.longPressDurationThreshold) { + if ((Date().time - onDownTimestamp) < longPressDurationThreshold) { longPressCallback?.let { gestureHandler.removeCallbacks(it) } onPress?.invoke() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt index 449fe8cfd4..f70dab9f55 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt @@ -20,10 +20,8 @@ class InputBarEditText : AppCompatEditText { private val screenWidth get() = Resources.getSystem().displayMetrics.widthPixels var delegate: InputBarEditTextDelegate? = null - var showMediaControls: Boolean = true + var allowMultimediaInput: Boolean = true - private val snMinHeight = toPx(40.0f, resources) - private val snMaxHeight = toPx(80.0f, resources) constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet) : super(context, attrs) @@ -37,19 +35,12 @@ class InputBarEditText : AppCompatEditText { // edit text. val width = (screenWidth - 2 * toPx(64.0f, resources)).roundToInt() if (width < 0) { return } // screenWidth initially evaluates to 0 - val height = TextUtilities.getIntrinsicHeight(text, paint, width).toFloat() - val constrainedHeight = min(max(height, snMinHeight), snMaxHeight) - if (constrainedHeight.roundToInt() == this.height) { return } - val layoutParams = this.layoutParams as? RelativeLayout.LayoutParams ?: return - layoutParams.height = constrainedHeight.roundToInt() - this.layoutParams = layoutParams - delegate?.inputBarEditTextHeightChanged(constrainedHeight.roundToInt()) } override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection? { val ic = super.onCreateInputConnection(editorInfo) ?: return null EditorInfoCompat.setContentMimeTypes(editorInfo, - if (showMediaControls) arrayOf("image/png", "image/gif", "image/jpg") else null + if (allowMultimediaInput) arrayOf("image/png", "image/gif", "image/jpg") else null ) val callback = @@ -69,7 +60,7 @@ class InputBarEditText : AppCompatEditText { // read and display inputContentInfo asynchronously. delegate?.commitInputContent(inputContentInfo.contentUri) - true // return true if succeeded + true // return true if succeeded } return InputConnectionCompat.createWrapper(ic, editorInfo, callback) } @@ -77,8 +68,6 @@ class InputBarEditText : AppCompatEditText { } interface InputBarEditTextDelegate { - fun inputBarEditTextContentChanged(text: CharSequence) - fun inputBarEditTextHeightChanged(newValue: Int) fun commitInputContent(contentUri: Uri) } \ No newline at end of file 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 f245dcadf4..f44390e8d9 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 @@ -12,6 +12,7 @@ import android.widget.RelativeLayout import android.widget.TextView import androidx.core.content.res.ResourcesCompat import androidx.core.view.isVisible +import java.util.Date import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -19,10 +20,10 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ViewInputBarRecordingBinding +import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.animateSizeChange import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.toPx -import java.util.Date // Constants for animation durations in milliseconds object VoiceRecorderConstants { @@ -60,23 +61,23 @@ class InputBarRecordingView : RelativeLayout { binding = ViewInputBarRecordingBinding.inflate(LayoutInflater.from(context), this, true) binding.inputBarMiddleContentContainer.disableClipping() binding.inputBarCancelButton.setOnClickListener { hide() } - } fun show(scope: CoroutineScope) { startTimestamp = Date().time - binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_microphone, context.theme)) + binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_mic, context.theme)) binding.inputBarCancelButton.alpha = 0.0f binding.inputBarMiddleContentContainer.alpha = 1.0f binding.lockView.alpha = 1.0f isVisible = true - alpha = 0.0f - val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f) - animation.duration = 250L - animation.addUpdateListener { animator -> - alpha = animator.animatedValue as Float - } - animation.start() + + animate().cancel() + animate() + .alpha(1f) + .setDuration(VoiceRecorderConstants.SHOW_HIDE_VOICE_UI_DURATION_MS) + .withEndAction(null) + .start() + animateDotView() pulse() animateLockViewUp() @@ -84,18 +85,17 @@ class InputBarRecordingView : RelativeLayout { } fun hide() { - alpha = 1.0f - val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) - animation.duration = VoiceRecorderConstants.SHOW_HIDE_VOICE_UI_DURATION_MS - animation.addUpdateListener { animator -> - alpha = animator.animatedValue as Float - if (animator.animatedFraction == 1.0f) { + animate().cancel() + animate() + .alpha(0f) + .setDuration(VoiceRecorderConstants.SHOW_HIDE_VOICE_UI_DURATION_MS) + .withEndAction { isVisible = false dotViewAnimation?.repeatCount = 0 pulseAnimation?.removeAllUpdateListeners() } - } - animation.start() + .start() + delegate?.handleVoiceMessageUIHidden() stopTimer() } @@ -104,9 +104,13 @@ class InputBarRecordingView : RelativeLayout { timerJob?.cancel() timerJob = scope.launch { while (isActive) { - val duration = (Date().time - startTimestamp) / 1000L - binding.recordingViewDurationTextView.text = android.text.format.DateUtils.formatElapsedTime(duration) - delay(500) + // Format the duration as minutes:seconds, using only the amount of digits for minutes as required + // (e.g., "3:21" rather than "03:21". Voice messages have a 5 minute maximum length so we never need + // more than a single digit to represent minutes. + val durationMS = (Date().time - startTimestamp) + binding.recordingViewDurationTextView.text = MediaUtil.getFormattedVoiceMessageDuration(durationMS) + + delay(500) // Update the voice message duration timer value every half a second } } } @@ -182,7 +186,6 @@ class InputBarRecordingView : RelativeLayout { } interface InputBarRecordingViewDelegate { - fun handleVoiceMessageUIHidden() fun sendVoiceMessage() fun cancelVoiceMessage() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt index 83f6b8af92..2501d08303 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt @@ -17,27 +17,34 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.allWithStatus import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.messaging.utilities.UpdateMessageBuilder.usernameUtils import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix +import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities import org.thoughtcrime.securesms.database.DatabaseContentProviders.Conversation import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.GroupMemberDatabase import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.ThreadDatabase @@ -57,12 +64,12 @@ class MentionViewModel( contentResolver: ContentResolver, threadDatabase: ThreadDatabase, groupDatabase: GroupDatabase, - mmsDatabase: MmsDatabase, contactDatabase: SessionContactDatabase, memberDatabase: GroupMemberDatabase, storage: Storage, configFactory: ConfigFactoryProtocol, dispatcher: CoroutineDispatcher = Dispatchers.IO, + mmsSmsDatabase: MmsSmsDatabase ) : ViewModel() { private val editable = MentionEditable() @@ -96,15 +103,17 @@ class MentionViewModel( val memberIDs = when { recipient.isLegacyGroupRecipient -> { - groupDatabase.getGroupMemberAddresses(recipient.address.toGroupString(), false) - .map { it.serialize() } - } - recipient.isGroupV2Recipient -> { - storage.getMembers(recipient.address.serialize()).map { it.accountIdString() } + groupDatabase.getGroupMemberAddresses( + recipient.address.toGroupString(), + false + ) + .map { it.toString() } } - - recipient.isCommunityRecipient -> mmsDatabase.getRecentChatMemberIDs(threadID, 20) - recipient.isContactRecipient -> listOf(recipient.address.serialize()) + recipient.isCommunityRecipient -> mmsSmsDatabase.getRecentChatMemberAddresses( + threadID, + 300 + ) + recipient.isContactRecipient -> listOf(recipient.address.toString()) else -> listOf() } @@ -120,15 +129,15 @@ class MentionViewModel( emptySet() } else { memberDatabase.getGroupMembersRoles(groupId, memberIDs) - .mapNotNullTo(hashSetOf()) { (memberId, roles) -> - memberId.takeIf { roles.any { it.isModerator } } + .mapNotNullTo(hashSetOf()) { (memberId, role) -> + memberId.takeIf { role.isModerator } } } } else if (recipient.isGroupV2Recipient) { - configFactory.withGroupConfigs(AccountId(recipient.address.serialize())) { + configFactory.withGroupConfigs(AccountId(recipient.address.toString())) { it.groupMembers.allWithStatus() .filter { (member, status) -> member.isAdminOrBeingPromoted(status) } - .mapTo(hashSetOf()) { (member, _) -> member.accountId.toString() } + .mapTo(hashSetOf()) { (member, _) -> member.accountId() } } } else { emptySet() @@ -141,38 +150,63 @@ class MentionViewModel( } val myId = if (openGroup != null) { - AccountId(IdPrefix.BLINDED, - SodiumUtilities.blindedKeyPair(openGroup.publicKey, - requireNotNull(storage.getUserED25519KeyPair()))!!.publicKey.asBytes) - .hexString + requireNotNull(storage.getUserBlindedAccountId(openGroup.publicKey)).hexString } else { requireNotNull(storage.getUserPublicKey()) } - (sequenceOf( - Member( - publicKey = myId, - name = application.getString(R.string.you), - isModerator = myId in moderatorIDs, - isMe = true - ) - ) + contactDatabase.getContacts(memberIDs) - .asSequence() - .filter { it.accountID != myId } - .map { contact -> - Member( - publicKey = contact.accountID, - name = contact.displayName(contactContext), - isModerator = contact.accountID in moderatorIDs, - isMe = false - ) - }) + //This is you in the tag list + val selfMember = buildMember( + myId, + application.getString(R.string.you), + myId in moderatorIDs, + true + ) + + // Get other members + val otherMembers = if (recipient.isGroupV2Recipient) { + val groupId = AccountId(recipient.address.toString()) + + // Get members of the group from the config + val rawMembers = configFactory.withGroupConfigs(groupId) { + it.groupMembers.allWithStatus() + } + + rawMembers + .filter { (member, _) -> member.accountId() != myId } + .map { (member) -> + val id = member.accountId() + val name = usernameUtils + .getContactNameWithAccountID( + id, + groupId + ) // returns contact name or blank + .takeIf { it.isNotBlank() } ?: id // fallback to id + buildMember(id, name, id in moderatorIDs, false) + }.sortedBy { it.name } + } else { + // For communities and one-on-one conversations + val contacts = contactDatabase.getContacts(memberIDs) // Get members from contacts based on memberIDs + val contactMap = contacts.associateBy { it.accountID } + + // Map using memberIDs to preserve the order of members + memberIDs.asSequence() + .filter { it != myId } + .mapNotNull { contactMap[it] } + .map { contact -> + val id = contact.accountID + val name = contact.displayName(contactContext) + .takeIf { it.isNotBlank() } ?: id + buildMember(id, name, id in moderatorIDs, false) + } + } + + (sequenceOf(selfMember) + otherMembers) .toList() } .flowOn(dispatcher) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(10_000L), null) - @OptIn(ExperimentalCoroutinesApi::class) val autoCompleteState: StateFlow = editable .observeMentionSearchQuery() @@ -190,7 +224,12 @@ class MentionViewModel( val filtered = if (query.query.isBlank()) { members.mapTo(mutableListOf()) { Candidate(it, it.name, 0) } } else { - members.mapNotNullTo(mutableListOf()) { searchAndHighlight(it, query.query) } + members.mapNotNullTo(mutableListOf()) { + searchAndHighlight( + it, + query.query + ) + } } filtered.sortWith(Candidate.MENTION_LIST_COMPARATOR) @@ -200,6 +239,13 @@ class MentionViewModel( } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), AutoCompleteState.Idle) + private fun buildMember( + id: String, + name: String, + isModerator: Boolean, + isMe: Boolean + ) = Member(publicKey = id, name = name, isModerator = isModerator, isMe = isMe) + private fun searchAndHighlight( haystack: Member, needle: String @@ -224,11 +270,12 @@ class MentionViewModel( fun onCandidateSelected(candidatePublicKey: String) { val query = editable.mentionSearchQuery ?: return val autoCompleteState = autoCompleteState.value as? AutoCompleteState.Result ?: return - val candidate = autoCompleteState.members.find { it.member.publicKey == candidatePublicKey } ?: return + val candidate = + autoCompleteState.members.find { it.member.publicKey == candidatePublicKey } ?: return editable.addMention( candidate.member, - query.mentionSymbolStartAt .. (query.mentionSymbolStartAt + query.query.length + 1) + query.mentionSymbolStartAt..(query.mentionSymbolStartAt + query.query.length + 1) ) } @@ -238,6 +285,10 @@ class MentionViewModel( * As "@123456" is the standard format for mentioning a user, this method will replace "@Alice" with "@123456" */ fun normalizeMessageBody(): String { + return deconstructMessageMentions().trim() + } + + fun deconstructMessageMentions(): String { val spansWithRanges = editable.getSpans() .mapTo(mutableListOf()) { span -> span to (editable.getSpanStart(span)..editable.getSpanEnd(span)) @@ -248,22 +299,40 @@ class MentionViewModel( val sb = StringBuilder() var offset = 0 for ((span, range) in spansWithRanges) { - // Add content before the mention span. There's a possibility of overlapping spans so we need to - // safe guard the start offset here to not go over our span's start. + // Add content before the mention span val thisMentionStart = range.first val lastMentionEnd = offset.coerceAtMost(thisMentionStart) sb.append(editable, lastMentionEnd, thisMentionStart) // Replace the mention span with "@public key" - sb.append('@').append(span.member.publicKey).append(' ') + sb.append('@').append(span.member.publicKey) + + // Check if the original mention span ended with a space + // The span includes the space, so we need to preserve it in the deconstructed version + if (range.last < editable.length && editable[range.last] == ' ') { + sb.append(' ') + } - // Safe guard offset to not go over the end of the editable. + // Move offset to after the mention span (including the space) offset = (range.last + 1).coerceAtMost(editable.length) } // Add the remaining content sb.append(editable, offset, editable.length) - return sb.toString().trim() + return sb.toString() + } + + suspend fun reconstructMentions(raw: String): Editable { + editable.replace(0, editable.length, raw) + + val memberList = members.filterNotNull().first() + + MentionUtilities.substituteIdsInPlace( + editable, + memberList.associateBy { it.publicKey } + ) + + return editable } data class Member( @@ -283,7 +352,6 @@ class MentionViewModel( companion object { val MENTION_LIST_COMPARATOR = compareBy { !it.member.isMe } .thenBy { it.matchScore } - .then(compareBy { it.member.name }) } } @@ -304,12 +372,12 @@ class MentionViewModel( private val contentResolver: ContentResolver, private val threadDatabase: ThreadDatabase, private val groupDatabase: GroupDatabase, - private val mmsDatabase: MmsDatabase, private val contactDatabase: SessionContactDatabase, private val storage: Storage, private val memberDatabase: GroupMemberDatabase, private val configFactory: ConfigFactoryProtocol, private val application: Application, + private val mmsSmsDatabase : MmsSmsDatabase ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { @@ -318,12 +386,12 @@ class MentionViewModel( contentResolver = contentResolver, threadDatabase = threadDatabase, groupDatabase = groupDatabase, - mmsDatabase = mmsDatabase, contactDatabase = contactDatabase, memberDatabase = memberDatabase, storage = storage, configFactory = configFactory, application = application, + mmsSmsDatabase = mmsSmsDatabase ) as T } } 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 2799eb634f..61017dbff7 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 @@ -2,13 +2,15 @@ package org.thoughtcrime.securesms.conversation.v2.menus import android.content.Context import android.view.ActionMode +import android.view.ContextThemeWrapper import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import network.loki.messenger.R import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.getColorFromAttr import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter @@ -16,19 +18,34 @@ import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.OpenGroupManager +import androidx.core.view.size +import androidx.core.view.get +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import org.session.libsignal.utilities.Hex class ConversationActionModeCallback( private val adapter: ConversationAdapter, private val threadID: Long, private val context: Context, private val deprecationManager: LegacyGroupDeprecationManager, + private val openGroupManager: OpenGroupManager, ) : ActionMode.Callback { var delegate: ConversationActionModeCallbackDelegate? = null override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - val inflater = mode.menuInflater + val themedContext = ContextThemeWrapper(context, context.theme) + val inflater = MenuInflater(themedContext) inflater.inflate(R.menu.menu_conversation_item_action, menu) updateActionModeMenu(menu) + + // tint icons manually as it seems the xml color is ignored, in spite of the context theme wrapper + val tintColor = context.getColorFromAttr(android.R.attr.textColorPrimary) + + for (i in 0 until menu.size) { + val menuItem = menu[i] + menuItem.icon?.setTint(tintColor) + } + return true } @@ -43,7 +60,11 @@ class ConversationActionModeCallback( val thread = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID)!! val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! val edKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!! - val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes } + val blindedPublicKey = openGroup?.publicKey?.let { + BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = edKeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(it), + )?.pubKey?.data } ?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString val isDeprecatedLegacyGroup = thread.isLegacyGroupRecipient && @@ -56,7 +77,11 @@ class ConversationActionModeCallback( if (anySentByCurrentUser) { return false } // Users can't ban themselves val selectedUsers = selectedItems.map { it.recipient.address.toString() }.toSet() if (selectedUsers.size > 1) { return false } - return OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey, blindedPublicKey) + return openGroupManager.isUserModerator( + openGroup.groupId, + userPublicKey, + blindedPublicKey + ) } @@ -96,7 +121,6 @@ class ConversationActionModeCallback( R.id.menu_context_ban_user -> delegate?.banUser(selectedItems) 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?.copyAccountID(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) @@ -120,7 +144,6 @@ interface ConversationActionModeCallbackDelegate { fun banUser(messages: Set) fun banAndDeleteAll(messages: Set) fun copyMessages(messages: Set) - fun copyAccountID(messages: Set) fun resyncMessage(messages: Set) fun resendMessage(messages: Set) fun showMessageDetail(messages: Set) 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 deleted file mode 100644 index fa23f72563..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ /dev/null @@ -1,546 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.menus - -import android.Manifest -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.graphics.BitmapFactory -import android.os.AsyncTask -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.view.ContextThemeWrapper -import androidx.appcompat.widget.SearchView -import androidx.appcompat.widget.SearchView.OnQueryTextListener -import androidx.core.content.pm.ShortcutInfoCompat -import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.graphics.drawable.IconCompat -import com.squareup.phrase.Phrase -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.IOException -import network.loki.messenger.R -import org.session.libsession.database.StorageProtocol -import org.session.libsession.messaging.groups.GroupManagerV2 -import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager -import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.messaging.sending_receiving.leave -import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID -import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.TextSecurePreferences.Companion.CALL_NOTIFICATIONS_ENABLED -import org.session.libsession.utilities.recipients.Recipient -import org.session.libsession.utilities.wasKickedFromGroupV2 -import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.guava.Optional -import org.session.libsignal.utilities.toHexString -import org.thoughtcrime.securesms.ShortcutLauncherActivity -import org.thoughtcrime.securesms.calls.WebRtcCallActivity -import org.thoughtcrime.securesms.contacts.SelectContactsActivity -import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils -import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.groups.EditGroupActivity -import org.thoughtcrime.securesms.groups.legacy.EditLegacyGroupActivity -import org.thoughtcrime.securesms.groups.legacy.EditLegacyGroupActivity.Companion.groupIDKey -import org.thoughtcrime.securesms.groups.GroupMembersActivity -import org.thoughtcrime.securesms.media.MediaOverviewActivity -import org.thoughtcrime.securesms.permissions.Permissions -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.ui.findActivity -import org.thoughtcrime.securesms.ui.getSubbedString -import org.thoughtcrime.securesms.util.BitmapUtil - -object ConversationMenuHelper { - - fun onPrepareOptionsMenu( - menu: Menu, - inflater: MenuInflater, - thread: Recipient, - context: Context, - configFactory: ConfigFactory, - deprecationManager: LegacyGroupDeprecationManager, - ) { - val isDeprecatedLegacyGroup = thread.isLegacyGroupRecipient && - deprecationManager.isDeprecated - - // Prepare - menu.clear() - val isCommunity = thread.isCommunityRecipient - // Base menu (options that should always be present) - inflater.inflate(R.menu.menu_conversation, menu) - menu.findItem(R.id.menu_add_shortcut).isVisible = !isDeprecatedLegacyGroup - - // Expiring messages - if (!isCommunity && (thread.hasApprovedMe() || thread.isLegacyGroupRecipient || thread.isLocalNumber) - && !isDeprecatedLegacyGroup) { - inflater.inflate(R.menu.menu_conversation_expiration, menu) - } - // One-on-one chat menu allows copying the account id - if (thread.isContactRecipient) { - inflater.inflate(R.menu.menu_conversation_copy_account_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 if (!thread.isLocalNumber) { - inflater.inflate(R.menu.menu_conversation_block, menu) - } - } - // (Legacy) Closed group menu (options that should only be present in closed groups) - if (thread.isLegacyGroupRecipient) { - inflater.inflate(R.menu.menu_conversation_legacy_group, menu) - - menu.findItem(R.id.menu_edit_group).isVisible = !isDeprecatedLegacyGroup - } - - // Groups v2 menu - if (thread.isGroupV2Recipient) { - val hasAdminKey = configFactory.withUserConfigs { it.userGroups.getClosedGroup(thread.address.serialize())?.hasAdminKey() } - if (hasAdminKey == true) { - inflater.inflate(R.menu.menu_conversation_groups_v2_admin, menu) - } else { - inflater.inflate(R.menu.menu_conversation_groups_v2, menu) - } - - // If the current user was kicked from the group - // the menu should say 'Delete' instead of 'Leave' - if (configFactory.wasKickedFromGroupV2(thread)) { - menu.findItem(R.id.menu_leave_group).title = context.getString(R.string.groupDelete) - } - } - - // Open group menu - if (isCommunity) { - inflater.inflate(R.menu.menu_conversation_open_group, menu) - } - // Muting - if (!isDeprecatedLegacyGroup) { - if (thread.isMuted) { - inflater.inflate(R.menu.menu_conversation_muted, menu) - } else { - inflater.inflate(R.menu.menu_conversation_unmuted, menu) - } - } - - if (thread.isGroupOrCommunityRecipient && !thread.isMuted && !isDeprecatedLegacyGroup) { - inflater.inflate(R.menu.menu_conversation_notification_settings, menu) - } - - if (thread.showCallMenu()) { - inflater.inflate(R.menu.menu_conversation_call, menu) - } - - // Search - val searchViewItem = menu.findItem(R.id.menu_search) - (context as ConversationActivityV2).searchViewItem = searchViewItem - val searchView = searchViewItem.actionView as SearchView - val queryListener = object : OnQueryTextListener { - override fun onQueryTextSubmit(query: String): Boolean { - return true - } - - override fun onQueryTextChange(query: String): Boolean { - context.onSearchQueryUpdated(query) - return true - } - } - searchViewItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - searchView.setOnQueryTextListener(queryListener) - context.onSearchOpened() - for (i in 0 until menu.size()) { - if (menu.getItem(i) != searchViewItem) { - menu.getItem(i).isVisible = false - } - } - return true - } - - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - searchView.setOnQueryTextListener(null) - context.onSearchClosed() - return true - } - }) - } - - /** - * Handle the selected option - * - * @return An asynchronous channel that can be used to wait for the action to complete. Null if - * the action does not require waiting. - */ - fun onOptionItemSelected( - context: Context, - item: MenuItem, - thread: Recipient, - threadID: Long, - factory: ConfigFactory, - storage: StorageProtocol, - groupManager: GroupManagerV2, - deprecationManager: LegacyGroupDeprecationManager, - ): ReceiveChannel? { - when (item.itemId) { - 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 -> { 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_account_id -> { copyAccountID(context, thread) } - R.id.menu_copy_open_group_url -> { copyOpenGroupUrl(context, thread) } - R.id.menu_edit_group -> { editGroup(context, thread) } - R.id.menu_group_members -> { showGroupMembers(context, thread) } - R.id.menu_leave_group -> { return leaveGroup( - context, thread, threadID, factory, storage, groupManager, deprecationManager - ) } - R.id.menu_invite_to_open_group -> { inviteContacts(context, thread) } - R.id.menu_unmute_notifications -> { unmute(context, thread) } - R.id.menu_mute_notifications -> { mute(context, thread) } - R.id.menu_notification_settings -> { setNotifyType(context, thread) } - R.id.menu_call -> { call(context, thread) } - } - - return null - } - - private fun showAllMedia(context: Context, thread: Recipient) { - val activity = context as AppCompatActivity - activity.startActivity(MediaOverviewActivity.createIntent(context, thread.address)) - } - - private fun search(context: Context) { - val searchViewModel = (context as ConversationActivityV2).searchViewModel - searchViewModel.onSearchOpened() - } - - private fun call(context: Context, thread: Recipient) { - - // if the user has not enabled voice/video calls - if (!TextSecurePreferences.isCallNotificationsEnabled(context)) { - context.showSessionDialog { - title(R.string.callsPermissionsRequired) - text(R.string.callsPermissionsRequiredDescription) - button(R.string.sessionSettings, R.string.AccessibilityId_sessionSettings) { - val intent = Intent(context, PrivacySettingsActivity::class.java) - // allow the screen to auto scroll to the appropriate toggle - intent.putExtra(PrivacySettingsActivity.SCROLL_KEY, CALL_NOTIFICATIONS_ENABLED) - context.startActivity(intent) - } - cancelButton() - } - return - } - // or if the user has not granted audio/microphone permissions - else if (!Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO)) { - Log.d("Loki", "Attempted to make a call without audio permissions") - - Permissions.with(context.findActivity()) - .request(Manifest.permission.RECORD_AUDIO) - .withPermanentDenialDialog( - context.getSubbedString(R.string.permissionsMicrophoneAccessRequired, - APP_NAME_KEY to context.getString(R.string.app_name)) - ) - .execute() - - return - } - - WebRtcCallService.createCall(context, thread) - .let(context::startService) - - Intent(context, WebRtcCallActivity::class.java) - .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } - .let(context::startActivity) - } - - @SuppressLint("StaticFieldLeak") - private fun addShortcut(context: Context, thread: Recipient) { - object : AsyncTask() { - - @Deprecated("Deprecated in Java") - override fun doInBackground(vararg params: Void?): IconCompat? { - var icon: IconCompat? = null - val contactPhoto = thread.contactPhoto - if (contactPhoto != null) { - try { - var bitmap = BitmapFactory.decodeStream(contactPhoto.openInputStream(context)) - bitmap = BitmapUtil.createScaledBitmap(bitmap, 300, 300) - icon = IconCompat.createWithAdaptiveBitmap(bitmap) - } catch (e: IOException) { - // Do nothing - } - } - if (icon == null) { - icon = IconCompat.createWithResource(context, if (thread.isGroupOrCommunityRecipient) R.mipmap.ic_group_shortcut else R.mipmap.ic_person_shortcut) - } - return icon - } - - @Deprecated("Deprecated in Java") - override fun onPostExecute(icon: IconCompat?) { - val name = Optional.fromNullable(thread.name) - .or(Optional.fromNullable(thread.profileName)) - .or(thread.toShortString()) - val shortcutInfo = ShortcutInfoCompat.Builder(context, thread.address.serialize() + '-' + System.currentTimeMillis()) - .setShortLabel(name) - .setIcon(icon) - .setIntent(ShortcutLauncherActivity.createIntent(context, thread.address)) - .build() - if (ShortcutManagerCompat.requestPinShortcut(context, shortcutInfo, null)) { - Toast.makeText(context, context.resources.getString(R.string.conversationsAddedToHome), Toast.LENGTH_LONG).show() - } - } - }.execute() - } - - private fun showDisappearingMessages(context: Context, thread: Recipient) { - val listener = context as? ConversationMenuListener ?: return - listener.showDisappearingMessages(thread) - } - - private fun unblock(context: Context, thread: Recipient) { - if (!thread.isContactRecipient) { return } - val listener = context as? ConversationMenuListener ?: return - listener.unblock() - } - - private fun block(context: Context, thread: Recipient, deleteThread: Boolean) { - if (!thread.isContactRecipient) { return } - val listener = context as? ConversationMenuListener ?: return - listener.block(deleteThread) - } - - private fun blockAndDelete(context: Context, thread: Recipient) { - if (!thread.isContactRecipient) { return } - val listener = context as? ConversationMenuListener ?: return - listener.block(deleteThread = true) - } - - private fun copyAccountID(context: Context, thread: Recipient) { - if (!thread.isContactRecipient) { return } - val listener = context as? ConversationMenuListener ?: return - listener.copyAccountID(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 editGroup(context: Context, thread: Recipient) { - when { - thread.isGroupV2Recipient -> { - context.startActivity(EditGroupActivity.createIntent(context, thread.address.serialize())) - } - - thread.isLegacyGroupRecipient -> { - val intent = Intent(context, EditLegacyGroupActivity::class.java) - val groupID: String = thread.address.toGroupString() - intent.putExtra(groupIDKey, groupID) - context.startActivity(intent) - } - } - } - - - private fun showGroupMembers(context: Context, thread: Recipient) { - context.startActivity(GroupMembersActivity.createIntent(context, thread.address.serialize())) - } - - enum class GroupLeavingStatus { - Leaving, - Left, - Error, - } - - fun leaveGroup( - context: Context, - thread: Recipient, - threadID: Long, - configFactory: ConfigFactory, - storage: StorageProtocol, - groupManager: GroupManagerV2, - deprecationManager: LegacyGroupDeprecationManager, - ): ReceiveChannel? { - val channel = Channel() - - when { - thread.isLegacyGroupRecipient -> { - val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull() - - // we do not want admin related messaging once legacy groups are deprecated - val isGroupAdmin = if(deprecationManager.isDeprecated){ - false - } else { // prior to the deprecated state, calculate admin rights properly - val admins = group.admins - val accountID = TextSecurePreferences.getLocalNumber(context) - admins.any { it.toString() == accountID } - } - - confirmAndLeaveGroup( - context = context, - groupName = group.title, - isAdmin = isGroupAdmin, - isKicked = configFactory.wasKickedFromGroupV2(thread), - threadID = threadID, - storage = storage, - doLeave = { - val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString() - - check(DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey)) { - "Invalid group public key" - } - try { - channel.trySend(GroupLeavingStatus.Leaving) - MessageSender.leave(groupPublicKey) - channel.trySend(GroupLeavingStatus.Left) - } catch (e: Exception) { - channel.trySend(GroupLeavingStatus.Error) - throw e - } - } - ) - } - - thread.isGroupV2Recipient -> { - val accountId = AccountId(thread.address.serialize()) - val group = configFactory.withUserConfigs { it.userGroups.getClosedGroup(accountId.hexString) } ?: return null - val name = configFactory.withGroupConfigs(accountId) { - it.groupInfo.getName() - } ?: group.name - - confirmAndLeaveGroup( - context = context, - groupName = name, - isAdmin = group.hasAdminKey(), - isKicked = configFactory.wasKickedFromGroupV2(thread), - threadID = threadID, - storage = storage, - doLeave = { - try { - channel.trySend(GroupLeavingStatus.Leaving) - groupManager.leaveGroup(accountId) - channel.trySend(GroupLeavingStatus.Left) - } catch (e: Exception) { - channel.trySend(GroupLeavingStatus.Error) - throw e - } - } - ) - - return channel - } - } - - return null - } - - private fun confirmAndLeaveGroup( - context: Context, - groupName: String, - isAdmin: Boolean, - isKicked: Boolean, - threadID: Long, - storage: StorageProtocol, - doLeave: suspend () -> Unit, - ) { - var title = R.string.groupLeave - var message: CharSequence = "" - var positiveButton = R.string.leave - - if(isKicked){ - message = Phrase.from(context, R.string.groupDeleteDescriptionMember) - .put(GROUP_NAME_KEY, groupName) - .format() - - title = R.string.groupDelete - positiveButton = R.string.delete - } else if (isAdmin) { - message = Phrase.from(context, R.string.groupLeaveDescriptionAdmin) - .put(GROUP_NAME_KEY, groupName) - .format() - } else { - message = Phrase.from(context, R.string.groupLeaveDescription) - .put(GROUP_NAME_KEY, groupName) - .format() - } - - fun onLeaveFailed() { - val txt = Phrase.from(context, R.string.groupLeaveErrorFailed) - .put(GROUP_NAME_KEY, groupName) - .format().toString() - Toast.makeText(context, txt, Toast.LENGTH_LONG).show() - } - - context.showSessionDialog { - title(title) - text(message) - dangerButton(positiveButton) { - GlobalScope.launch(Dispatchers.Default) { - try { - // Cancel any outstanding jobs - storage.cancelPendingMessageSendJobs(threadID) - - doLeave() - } catch (e: Exception) { - Log.e("Conversation", "Error leaving group", e) - withContext(Dispatchers.Main) { - onLeaveFailed() - } - } - } - - } - button(R.string.cancel) - } - } - - private fun inviteContacts(context: Context, thread: Recipient) { - if (!thread.isCommunityRecipient) { return } - val intent = Intent(context, SelectContactsActivity::class.java) - val activity = context as AppCompatActivity - activity.startActivityForResult(intent, ConversationActivityV2.INVITE_CONTACTS) - } - - private fun unmute(context: Context, thread: Recipient) { - DatabaseComponent.get(context).recipientDatabase().setMuted(thread, 0) - } - - private fun mute(context: Context, thread: Recipient) { - showMuteDialog(ContextThemeWrapper(context, context.theme)) { until: Long -> - DatabaseComponent.get(context).recipientDatabase().setMuted(thread, until) - } - } - - private fun setNotifyType(context: Context, thread: Recipient) { - NotificationUtils.showNotifyDialog(context, thread) { notifyType -> - DatabaseComponent.get(context).recipientDatabase().setNotifyType(thread, notifyType) - } - } - - interface ConversationMenuListener { - fun block(deleteThread: Boolean = false) - fun unblock() - fun copyAccountID(accountId: String) - 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/menus/ConversationMenuItemHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuItemHelper.kt deleted file mode 100644 index 3356453596..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuItemHelper.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.menus - -import android.content.Context -import org.session.libsession.messaging.open_groups.OpenGroup -import org.thoughtcrime.securesms.database.model.MessageRecord -import org.thoughtcrime.securesms.groups.OpenGroupManager - -object ConversationMenuItemHelper { - - @JvmStatic - fun userCanBanSelectedUsers(context: Context, message: MessageRecord, openGroup: OpenGroup?, userPublicKey: String, blindedPublicKey: String?): Boolean { - if (openGroup == null) return false - if (message.isOutgoing) return false // Users can't ban themselves - return OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey, blindedPublicKey) - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/AttachmentControlView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/AttachmentControlView.kt new file mode 100644 index 0000000000..e7958fa984 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/AttachmentControlView.kt @@ -0,0 +1,153 @@ +package org.thoughtcrime.securesms.conversation.v2.messages + +import android.content.Context +import android.graphics.Typeface +import android.text.format.Formatter +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.annotation.ColorInt +import androidx.core.graphics.ColorUtils +import androidx.core.view.isVisible +import com.squareup.phrase.Phrase +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R +import network.loki.messenger.databinding.ViewAttachmentControlBinding +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment +import org.session.libsession.utilities.StringSubstitutionConstants.FILE_TYPE_KEY +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.conversation.v2.ViewUtil +import org.thoughtcrime.securesms.conversation.v2.dialogs.AutoDownloadDialog +import org.thoughtcrime.securesms.mms.Slide +import org.thoughtcrime.securesms.ui.findActivity +import org.thoughtcrime.securesms.util.ActivityDispatcher +import java.util.Locale +import javax.inject.Inject + +@AndroidEntryPoint +class AttachmentControlView: LinearLayout { + private val binding by lazy { ViewAttachmentControlBinding.bind(this) } + enum class AttachmentType { + VOICE, + AUDIO, + DOCUMENT, + IMAGE, + VIDEO, + } + + // 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) + + // endregion + @Inject lateinit var storage: StorageProtocol + + val separator = " • " + + // region Updating + private fun getAttachmentData(attachmentType: AttachmentType, messageTotalAttachment: Int): Pair { + return when (attachmentType) { + AttachmentType.VOICE -> Pair(R.string.messageVoice, R.drawable.ic_mic) + AttachmentType.AUDIO -> Pair(R.string.audio, R.drawable.ic_volume_2) + AttachmentType.DOCUMENT -> Pair(R.string.document, R.drawable.ic_file) + AttachmentType.IMAGE -> { + if(messageTotalAttachment > 1) Pair(R.string.images, R.drawable.ic_images) + else Pair(R.string.image, R.drawable.ic_image) + } + AttachmentType.VIDEO -> Pair(R.string.video, R.drawable.ic_square_play) + } + } + + fun bind( + attachmentType: AttachmentType, + @ColorInt textColor: Int, + state: AttachmentState, + allMessageAttachments: List, + ) { + val (stringRes, iconRes) = getAttachmentData(attachmentType, allMessageAttachments.size) + + val totalSize = Formatter.formatFileSize(context, allMessageAttachments.sumOf { it.fileSize }) + binding.pendingDownloadIcon.setImageResource(iconRes) + + when(state){ + AttachmentState.EXPIRED -> { + val expiredColor = ColorUtils.setAlphaComponent(textColor, (0.7f * 255).toInt()) + + binding.pendingDownloadIcon.setColorFilter(expiredColor) + + binding.title.apply { + text = context.getString(R.string.attachmentsExpired) + setTextColor(expiredColor) + setTypeface(Typeface.defaultFromStyle(Typeface.ITALIC)) + } + + binding.subtitle.isVisible = false + binding.errorIcon.isVisible = false + } + + AttachmentState.DOWNLOADING -> { + binding.pendingDownloadIcon.setColorFilter(textColor) + + //todo: ATTACHMENT This will need to be tweaked to dynamically show the the downloaded amount + val title = getFormattedTitle(totalSize, context.getString(R.string.downloading)) + + binding.title.apply{ + text = title + setTextColor(textColor) + setTypeface(Typeface.defaultFromStyle(Typeface.NORMAL)) + } + + binding.subtitle.isVisible = false + binding.errorIcon.isVisible = false + } + + AttachmentState.FAILED -> { + binding.pendingDownloadIcon.setColorFilter(textColor) + + val title = getFormattedTitle(totalSize, context.getString(R.string.failedToDownload)) + binding.title.apply{ + text = title + setTextColor(textColor) + setTypeface(Typeface.defaultFromStyle(Typeface.NORMAL)) + } + + binding.subtitle.isVisible = true + binding.errorIcon.isVisible = true + } + + else -> { + binding.pendingDownloadIcon.setColorFilter(textColor) + + val title = getFormattedTitle(totalSize, + Phrase.from(context, R.string.attachmentsTapToDownload) + .put(FILE_TYPE_KEY, context.getString(stringRes).lowercase(Locale.ROOT)) + .format() + ) + + binding.title.apply{ + text = title + setTextColor(textColor) + setTypeface(Typeface.defaultFromStyle(Typeface.NORMAL)) + } + + binding.subtitle.isVisible = false + binding.errorIcon.isVisible = false + } + } + } + + private fun getFormattedTitle(size: String, title: CharSequence): CharSequence { + return ViewUtil.safeRTLString(context, "$size$separator$title") + } + // endregion + + // region Interaction + fun showDownloadDialog(threadRecipient: Recipient, attachment: DatabaseAttachment) { + if (!storage.shouldAutoDownloadAttachments(threadRecipient)) { + // just download + (context.findActivity() as? ActivityDispatcher)?.showDialog(AutoDownloadDialog(threadRecipient, attachment)) + } + } +} \ 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 00c9d0292a..1876bc5a97 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,11 +3,15 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.Manifest import android.content.Context import android.content.Intent +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable 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.graphics.drawable.toBitmap import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView @@ -17,15 +21,14 @@ 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.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences.Companion.CALL_NOTIFICATIONS_ENABLED import org.session.libsession.utilities.getColorFromAttr 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.database.model.content.DisappearingMessageUpdate import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity @@ -33,6 +36,7 @@ import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.ui.findActivity import org.thoughtcrime.securesms.ui.getSubbedCharSequence import org.thoughtcrime.securesms.ui.getSubbedString +import org.thoughtcrime.securesms.util.DateUtils import javax.inject.Inject @@ -43,10 +47,17 @@ class ControlMessageView : LinearLayout { private val binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true) - private val infoDrawable by lazy { - val d = ResourcesCompat.getDrawable(resources, R.drawable.ic_info_outline_white_24dp, context.theme) - d?.setTint(context.getColorFromAttr(R.attr.message_received_text_color)) - d + val iconSize by lazy { + resources.getDimensionPixelSize(R.dimen.medium_spacing) + } + + private val infoDrawable: Drawable? by lazy { + val icon = ResourcesCompat.getDrawable(resources, R.drawable.ic_info, context.theme)?.toBitmap() + if(icon != null) { + val d = BitmapDrawable(resources, Bitmap.createScaledBitmap(icon, iconSize, iconSize, true)) + d.setTint(context.getColorFromAttr(R.attr.message_received_text_color)) + d + } else null } constructor(context: Context) : super(context) @@ -54,6 +65,7 @@ class ControlMessageView : LinearLayout { constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) @Inject lateinit var disappearingMessages: DisappearingMessages + @Inject lateinit var dateUtils: DateUtils val controlContentView: View get() = binding.controlContentView @@ -62,7 +74,7 @@ class ControlMessageView : LinearLayout { } fun bind(message: MessageRecord, previous: MessageRecord?, longPress: (() -> Unit)? = null) { - binding.dateBreakTextView.showDateBreak(message, previous) + binding.dateBreakTextView.showDateBreak(message, previous, dateUtils) binding.iconImageView.isGone = true binding.expirationTimerView.isGone = true binding.followSetting.isGone = true @@ -70,8 +82,9 @@ class ControlMessageView : LinearLayout { binding.root.contentDescription = null binding.textView.text = messageBody + val messageContent = message.messageContent when { - message.isExpirationTimerUpdate -> { + messageContent is DisappearingMessageUpdate -> { binding.apply { expirationTimerView.isVisible = true @@ -84,12 +97,14 @@ class ControlMessageView : LinearLayout { } followSetting.isVisible = ExpirationConfiguration.isNewConfigEnabled - && !message.isOutgoing - && message.expiryMode != (MessagingModuleConfiguration.shared.storage.getExpirationConfiguration(message.threadId)?.expiryMode ?: ExpiryMode.NONE) - && threadRecipient?.isGroupOrCommunityRecipient != true + && !message.isOutgoing + && messageContent.expiryMode != (MessagingModuleConfiguration.shared.storage.getExpirationConfiguration(message.threadId)?.expiryMode ?: ExpiryMode.NONE) + && threadRecipient?.isGroupOrCommunityRecipient != true if (followSetting.isVisible) { - binding.controlContentView.setOnClickListener { disappearingMessages.showFollowSettingDialog(context, message) } + binding.controlContentView.setOnClickListener { + disappearingMessages.showFollowSettingDialog(context, threadId = message.threadId, recipient = message.recipient, messageContent) + } } else { binding.controlContentView.setOnClickListener(null) } @@ -106,13 +121,13 @@ class ControlMessageView : LinearLayout { message.isMediaSavedNotification -> { binding.iconImageView.apply { setImageDrawable( - ResourcesCompat.getDrawable(resources, R.drawable.ic_file_download_white_36dp, context.theme) + ResourcesCompat.getDrawable(resources, R.drawable.ic_arrow_down_to_line, context.theme) ) isVisible = true } } message.isMessageRequestResponse -> { - val msgRecipient = message.recipient.address.serialize() + val msgRecipient = message.recipient.address.toString() val me = TextSecurePreferences.getLocalNumber(context) binding.textView.text = if(me == msgRecipient) { // you accepted the user's request val threadRecipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(message.threadId) @@ -127,15 +142,30 @@ class ControlMessageView : LinearLayout { binding.root.contentDescription = context.getString(R.string.AccessibilityId_message_request_config_message) } message.isCallLog -> { - val drawable = when { - message.isIncomingCall -> R.drawable.ic_incoming_call - message.isOutgoingCall -> R.drawable.ic_outgoing_call - else -> R.drawable.ic_missed_call + val drawableRes = when { + message.isIncomingCall -> R.drawable.ic_phone_incoming + message.isOutgoingCall -> R.drawable.ic_phone_outgoing + else -> R.drawable.ic_phone_missed + } + + // Since this is using text drawable we need to go the long way around to size and style the drawable + // We could set the colour and style directly in the drawable's xml but it then makes it non reusable + // This will all be simplified once we turn this all to Compose + val icon = ResourcesCompat.getDrawable(resources, drawableRes, context.theme)?.toBitmap() + icon?.let{ + val drawable = BitmapDrawable(resources, Bitmap.createScaledBitmap(icon, iconSize, iconSize, true)); + binding.callTextView.setCompoundDrawablesRelativeWithIntrinsicBounds( + drawable,null, null, null) + + val iconTint = when { + message.isIncomingCall || message.isOutgoingCall -> R.attr.message_received_text_color + else -> R.attr.danger + } + + drawable.setTint(context.getColorFromAttr(iconTint)) } + 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) { @@ -171,7 +201,7 @@ class ControlMessageView : LinearLayout { button(R.string.sessionSettings) { val intent = Intent(context, PrivacySettingsActivity::class.java) // allow the screen to auto scroll to the appropriate toggle - intent.putExtra(PrivacySettingsActivity.SCROLL_KEY, CALL_NOTIFICATIONS_ENABLED) + intent.putExtra(PrivacySettingsActivity.SCROLL_AND_TOGGLE_KEY, CALL_NOTIFICATIONS_ENABLED) context.startActivity(intent) } cancelButton() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt index 7a495a9478..737d69de6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt @@ -23,8 +23,9 @@ class DeletedMessageView : LinearLayout { assert(message.isDeleted) // set the text to the message's body if it is set, else use a fallback binding.deleteTitleTextView.text = message.body.ifEmpty { context.resources.getQuantityString(R.plurals.deleteMessageDeleted, 1, 1) } - binding.deleteTitleTextView.setTextColor(textColor) - binding.deletedMessageViewIconImageView.imageTintList = ColorStateList.valueOf(textColor) + val deletedColor = textColor.also { alpha = 0.7f } // deleted messages use the regular text colour with some opacitiy applied) + binding.deleteTitleTextView.setTextColor(deletedColor) + binding.deletedMessageViewIconImageView.imageTintList = ColorStateList.valueOf(deletedColor) } // 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 0614b52e84..81d2c85052 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 @@ -2,11 +2,13 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.content.res.ColorStateList +import android.text.format.Formatter 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.session.libsession.utilities.Util import org.thoughtcrime.securesms.database.model.MmsMessageRecord class DocumentView : LinearLayout { @@ -21,9 +23,12 @@ class DocumentView : LinearLayout { // region Updating fun bind(message: MmsMessageRecord, @ColorInt textColor: Int) { val document = message.slideDeck.documentSlide!! - binding.documentTitleTextView.text = document.fileName.or("Untitled File") + binding.documentTitleTextView.text = document.filename binding.documentTitleTextView.setTextColor(textColor) + binding.documentSize.text = Formatter.formatFileSize(context, document.fileSize) + binding.documentSize.setTextColor(textColor) binding.documentViewIconImageView.imageTintList = ColorStateList.valueOf(textColor) + binding.documentViewProgress.indeterminateTintList = 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) 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 index 27714fbc05..9c7df9dd4a 100644 --- 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 @@ -38,7 +38,7 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { // Normally 6dp, but we have 1dp left+right margin on the pills themselves private val OUTER_MARGIN = ViewUtil.dpToPx(2) private var records: MutableList? = null - private var messageId: Long = 0 + private var messageId: MessageId? = null private var delegate: VisibleMessageViewDelegate? = null private val gestureHandler = Handler(Looper.getMainLooper()) private var pressCallback: Runnable? = null @@ -66,7 +66,7 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { binding.layoutEmojiContainer.removeAllViews() } - fun setReactions(messageId: Long, records: List, outgoing: Boolean, delegate: VisibleMessageViewDelegate?) { + fun setReactions(messageId: MessageId, records: List, outgoing: Boolean, delegate: VisibleMessageViewDelegate?) { this.delegate = delegate if (records == this.records) { return @@ -79,22 +79,22 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { extended = false } this.messageId = messageId - displayReactions(if (extended) Int.MAX_VALUE else DEFAULT_THRESHOLD) + displayReactions(messageId, 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), reaction.emoji) + if (action == MotionEvent.ACTION_DOWN) onDown(reaction.messageId, reaction.emoji) else if (action == MotionEvent.ACTION_CANCEL) removeLongPressCallback() else if (action == MotionEvent.ACTION_UP) onUp(reaction) return true } - private fun displayReactions(threshold: Int) { + private fun displayReactions(messageId: MessageId, threshold: Int) { val userPublicKey = getLocalNumber(context) - val reactions = buildSortedReactionsList(records!!, userPublicKey, threshold) + val reactions = buildSortedReactionsList(messageId, records!!, userPublicKey, threshold) binding.layoutEmojiContainer.removeAllViews() val overflowContainer = LinearLayout(context) overflowContainer.orientation = LinearLayout.HORIZONTAL @@ -111,7 +111,7 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { val pill = buildPill(context, this, reaction, true) pill.setOnClickListener { v: View? -> extended = true - displayReactions(Int.MAX_VALUE) + displayReactions(messageId, Int.MAX_VALUE) } pill.findViewById(R.id.reactions_pill_count).visibility = GONE pill.findViewById(R.id.reactions_pill_spacer).visibility = GONE @@ -143,7 +143,7 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { for (id in binding.groupShowLess.referencedIds) { findViewById(id).setOnClickListener { view: View? -> extended = false - displayReactions(DEFAULT_THRESHOLD) + displayReactions(messageId, DEFAULT_THRESHOLD) } } } else { @@ -151,7 +151,7 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { } } - private fun buildSortedReactionsList(records: List, userPublicKey: String?, threshold: Int): List { + private fun buildSortedReactionsList(messageId: MessageId, records: List, userPublicKey: String?, threshold: Int): List { val counters: MutableMap = LinkedHashMap() records.forEach { @@ -159,7 +159,7 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { val info = counters[baseEmoji] if (info == null) { - counters[baseEmoji] = Reaction(messageId, it.isMms, it.emoji, it.count, it.sortId, it.dateReceived, userPublicKey == it.author) + counters[baseEmoji] = Reaction(messageId, it.emoji, it.count, it.sortId, it.dateReceived, userPublicKey == it.author) } else { info.update(it.emoji, it.count, it.dateReceived, userPublicKey == it.author) @@ -214,10 +214,8 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { } private fun onReactionClicked(reaction: Reaction) { - if (reaction.messageId != 0L) { - val messageId = MessageId(reaction.messageId, reaction.isMms) - delegate!!.onReactionClicked(reaction.emoji!!, messageId, reaction.userWasSender) - } + val messageId = this.messageId ?: return + delegate!!.onReactionClicked(reaction.emoji!!, messageId, reaction.userWasSender) } private fun onDown(messageId: MessageId, emoji: String?) { @@ -257,8 +255,7 @@ class EmojiReactionsView : ConstraintLayout, OnTouchListener { } internal class Reaction( - internal val messageId: Long, - internal val isMms: Boolean, + internal val messageId: MessageId, internal var emoji: String?, internal var count: Long, internal val sortIndex: Long, 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 d064d02872..b8a43b4690 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 @@ -42,7 +42,6 @@ class LinkPreviewView : LinearLayout { if (linkPreview.getThumbnail().isPresent) { // This internally fetches the thumbnail binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false) - binding.thumbnailImageView.root.loadIndicator.isVisible = false } // Title binding.titleTextView.text = linkPreview.title diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/MessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/MessageUtilities.kt index 08793d8c9a..21866a0637 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/MessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/MessageUtilities.kt @@ -2,14 +2,17 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.widget.TextView import androidx.core.view.isVisible +import java.util.Locale +import kotlin.time.Duration.Companion.minutes import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.util.DateUtils -import java.util.Locale -private const val maxTimeBetweenBreaks = 5 * 60 * 1000L // 5 minutes +private val maxTimeBetweenBreaksMS = 5.minutes.inWholeMilliseconds -fun TextView.showDateBreak(message: MessageRecord, previous: MessageRecord?) { - val showDateBreak = previous == null || message.timestamp - previous.timestamp > maxTimeBetweenBreaks +fun TextView.showDateBreak(message: MessageRecord, previous: MessageRecord?, dateUtils: DateUtils) { + val showDateBreak = (previous == null || message.timestamp - previous.timestamp > maxTimeBetweenBreaksMS) isVisible = showDateBreak - text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else "" -} + text = if (showDateBreak) dateUtils.getDisplayFormattedTimeSpanString( + message.timestamp + ) else "" +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt index fc9a46228e..862aed6010 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt @@ -11,6 +11,7 @@ import network.loki.messenger.R import network.loki.messenger.databinding.ViewOpenGroupInvitationBinding import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.utilities.OpenGroupUrlParser +import org.session.libsession.utilities.getColorFromAttr import org.thoughtcrime.securesms.conversation.v2.dialogs.JoinOpenGroupDialog import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.util.getAccentColor @@ -29,8 +30,10 @@ class OpenGroupInvitationView : LinearLayout { val data = umd.kind as UpdateMessageData.Kind.OpenGroupInvitation this.data = data val iconID = if (message.isOutgoing) R.drawable.ic_globe else R.drawable.ic_plus + val backgroundColor = if (!message.isOutgoing) context.getAccentColor() else ContextCompat.getColor(context, R.color.transparent_black_6) + with(binding){ openGroupInvitationIconImageView.setImageResource(iconID) openGroupInvitationIconBackground.backgroundTintList = ColorStateList.valueOf(backgroundColor) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/PendingAttachmentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/PendingAttachmentView.kt deleted file mode 100644 index 72e37b5ddd..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/PendingAttachmentView.kt +++ /dev/null @@ -1,65 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.messages - -import android.content.Context -import android.util.AttributeSet -import android.widget.LinearLayout -import androidx.annotation.ColorInt -import com.squareup.phrase.Phrase -import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.R -import network.loki.messenger.databinding.ViewPendingAttachmentBinding -import org.session.libsession.database.StorageProtocol -import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment -import org.session.libsession.utilities.StringSubstitutionConstants.FILE_TYPE_KEY -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.conversation.v2.dialogs.AutoDownloadDialog -import org.thoughtcrime.securesms.util.ActivityDispatcher -import org.thoughtcrime.securesms.util.displaySize -import java.util.Locale -import javax.inject.Inject - -@AndroidEntryPoint -class PendingAttachmentView: LinearLayout { - private val binding by lazy { ViewPendingAttachmentBinding.bind(this) } - enum class AttachmentType { - AUDIO, - DOCUMENT, - IMAGE, - VIDEO, - } - - // 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) - - // endregion - @Inject lateinit var storage: StorageProtocol - - // region Updating - fun bind(attachmentType: AttachmentType, @ColorInt textColor: Int, attachment: DatabaseAttachment) { - val stringRes = when (attachmentType) { - AttachmentType.AUDIO -> R.string.audio - AttachmentType.DOCUMENT -> R.string.document - AttachmentType.IMAGE -> R.string.image - AttachmentType.VIDEO -> R.string.video - } - - val text = Phrase.from(context, R.string.attachmentsTapToDownload) - .put(FILE_TYPE_KEY, context.getString(stringRes).lowercase(Locale.ROOT)) - .format() - - binding.pendingDownloadIcon.setColorFilter(textColor) - binding.pendingDownloadSize.text = attachment.displaySize() - binding.pendingDownloadTitle.text = text - } - // endregion - - // region Interaction - fun showDownloadDialog(threadRecipient: Recipient, attachment: DatabaseAttachment) { - if (!storage.shouldAutoDownloadAttachments(threadRecipient)) { - // just download - ActivityDispatcher.get(context)?.showDialog(AutoDownloadDialog(threadRecipient, attachment)) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt index d9180f3ca5..5d4e1db1e7 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 @@ -4,25 +4,39 @@ import android.content.Context import android.content.res.ColorStateList import android.util.AttributeSet import androidx.annotation.ColorInt +import androidx.compose.foundation.layout.widthIn +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.dp import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.use +import androidx.core.graphics.toColor import androidx.core.text.toSpannable import androidx.core.view.isVisible +import com.bumptech.glide.RequestManager import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewQuoteBinding import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.utilities.Address import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.truncateIdForDisplay import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities import org.thoughtcrime.securesms.database.SessionContactDatabase -import com.bumptech.glide.RequestManager -import org.session.libsession.utilities.truncateIdForDisplay import org.thoughtcrime.securesms.mms.SlideDeck +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.ui.ProBadgeText +import org.thoughtcrime.securesms.ui.proBadgeColorOutgoing +import org.thoughtcrime.securesms.ui.proBadgeColorStandard +import org.thoughtcrime.securesms.ui.setThemedContent +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.bold import org.thoughtcrime.securesms.util.MediaUtil -import org.thoughtcrime.securesms.util.getAccentColor import org.thoughtcrime.securesms.util.toPx import javax.inject.Inject @@ -37,6 +51,8 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? @Inject lateinit var contactDb: SessionContactDatabase + @Inject lateinit var proStatusManager: ProStatusManager + private val binding: ViewQuoteBinding by lazy { ViewQuoteBinding.bind(this) } private val vPadding by lazy { toPx(6, resources) } var delegate: QuoteViewDelegate? = null @@ -67,10 +83,11 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? // endregion // region Updating - fun bind(authorPublicKey: String, body: String?, attachments: SlideDeck?, thread: Recipient, + fun bind(authorRecipient: Recipient, body: String?, attachments: SlideDeck?, thread: Recipient, isOutgoingMessage: Boolean, isOpenGroupInvitation: Boolean, threadID: Long, isOriginalMissing: Boolean, glide: RequestManager) { // Author + val authorPublicKey = authorRecipient.address.toString() val author = contactDb.getContactWithAccountID(authorPublicKey) val localNumber = TextSecurePreferences.getLocalNumber(context) val quoteIsLocalUser = localNumber != null && authorPublicKey == localNumber @@ -78,9 +95,29 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? val authorDisplayName = if (quoteIsLocalUser) context.getString(R.string.you) else author?.displayName(Contact.contextForRecipient(thread)) ?: truncateIdForDisplay(authorPublicKey) - binding.quoteViewAuthorTextView.text = authorDisplayName + val textColor = getTextColor(isOutgoingMessage) - binding.quoteViewAuthorTextView.setTextColor(textColor) + + // set up quote author + binding.quoteAuthor.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + var modifier: Modifier = Modifier + if(mode == Mode.Regular){ + modifier = modifier.widthIn(max = 240.dp) // this value is hardcoded in the xml files > when we move to composable messages this will be handled better internally + } + + setThemedContent { + ProBadgeText( + modifier = modifier, + text = authorDisplayName, //todo badge we need to rework te naming logic to get the name (no account id for blinded here...) - waiting on the Recipient refactor + textStyle = LocalType.current.small.bold().copy(color = Color(textColor)), + showBadge = proStatusManager.shouldShowProBadge(authorRecipient.address), + badgeColors = if(isOutgoingMessage && mode == Mode.Regular) proBadgeColorOutgoing() + else proBadgeColorStandard() + ) + } + } + // Body binding.quoteViewBodyTextView.text = if (isOpenGroupInvitation) resources.getString(R.string.communityInvitation) @@ -100,37 +137,57 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? binding.quoteViewAccentLine.setBackgroundColor(getLineColor(isOutgoingMessage)) } else if (attachments != null) { binding.quoteViewAttachmentPreviewImageView.imageTintList = ColorStateList.valueOf(textColor) - binding.quoteViewAttachmentPreviewImageView.isVisible = false + binding.quoteViewAttachmentPreviewImageView.isVisible = true binding.quoteViewAttachmentThumbnailImageView.root.isVisible = false when { attachments.audioSlide != null -> { - binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_microphone) - binding.quoteViewAttachmentPreviewImageView.isVisible = true - val isVoiceNote = attachments.isVoiceNote - binding.quoteViewBodyTextView.text = if (isVoiceNote) { - resources.getString(R.string.messageVoice) + if (isVoiceNote) { + updateQuoteTextIfEmpty(resources.getString(R.string.messageVoice)) + binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_mic) } else { - resources.getString(R.string.audio) + updateQuoteTextIfEmpty(resources.getString(R.string.audio)) + binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_volume_2) } } attachments.documentSlide != null -> { - binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_document_large_light) - binding.quoteViewAttachmentPreviewImageView.isVisible = true - binding.quoteViewBodyTextView.text = resources.getString(R.string.document) + binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_file) + updateQuoteTextIfEmpty(resources.getString(R.string.document)) } attachments.thumbnailSlide != null -> { val slide = attachments.thumbnailSlide!! - // This internally fetches the thumbnail - binding.quoteViewAttachmentThumbnailImageView - .root.setRoundedCorners(toPx(4, resources)) - binding.quoteViewAttachmentThumbnailImageView.root.setImageResource(glide, slide, false) - binding.quoteViewAttachmentThumbnailImageView.root.isVisible = true - binding.quoteViewBodyTextView.text = if (MediaUtil.isVideo(slide.asAttachment())) resources.getString(R.string.video) else resources.getString(R.string.image) + + if (MediaUtil.isVideo(slide.asAttachment())){ + updateQuoteTextIfEmpty(resources.getString(R.string.video)) + binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_square_play) + } else { + updateQuoteTextIfEmpty(resources.getString(R.string.image)) + binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_image) + } + + // display the image if we are in the appropriate state + if(attachments.asAttachments().all { it.isDone }) { + binding.quoteViewAttachmentThumbnailImageView + .root.setRoundedCorners(toPx(4, resources)) + binding.quoteViewAttachmentThumbnailImageView.root.setImageResource( + glide, + slide, + false + ) + binding.quoteViewAttachmentThumbnailImageView.root.isVisible = true + binding.quoteViewAttachmentPreviewImageView.isVisible = false + } + } } } } + + private fun updateQuoteTextIfEmpty(text: String){ + if(binding.quoteViewBodyTextView.text.isNullOrEmpty()){ + binding.quoteViewBodyTextView.text = text + } + } // endregion // region Convenience @@ -155,6 +212,5 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? } interface QuoteViewDelegate { - fun cancelQuoteDraft() } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index a99e7b60d1..566b5e6f20 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 @@ -1,40 +1,58 @@ package org.thoughtcrime.securesms.conversation.v2.messages +import android.content.ActivityNotFoundException import android.content.Context -import android.graphics.Color +import android.content.Intent +import android.content.res.ColorStateList import android.graphics.Rect +import android.text.Layout import android.text.Spannable +import android.text.StaticLayout 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.util.Log import android.view.MotionEvent import android.view.View +import android.view.ViewTreeObserver +import android.widget.Toast import androidx.annotation.ColorInt import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.graphics.ColorUtils import androidx.core.text.getSpans import androidx.core.text.toSpannable import androidx.core.view.children +import androidx.core.view.doOnAttach +import androidx.core.view.doOnLayout +import androidx.core.view.doOnPreDraw import androidx.core.view.isVisible import com.bumptech.glide.Glide import com.bumptech.glide.RequestManager import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageContentBinding import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState 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.needsCollapsing import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.conversation.v2.messages.AttachmentControlView.AttachmentType.AUDIO +import org.thoughtcrime.securesms.conversation.v2.messages.AttachmentControlView.AttachmentType.DOCUMENT +import org.thoughtcrime.securesms.conversation.v2.messages.AttachmentControlView.AttachmentType.IMAGE +import org.thoughtcrime.securesms.conversation.v2.messages.AttachmentControlView.AttachmentType.VIDEO +import org.thoughtcrime.securesms.conversation.v2.messages.AttachmentControlView.AttachmentType.VOICE import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities import org.thoughtcrime.securesms.conversation.v2.utilities.ModalURLSpan import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.SearchUtil import org.thoughtcrime.securesms.util.getAccentColor @@ -47,6 +65,8 @@ class VisibleMessageContentView : ConstraintLayout { var delegate: VisibleMessageViewDelegate? = null var indexInAdapter: Int = -1 + private val MAX_COLLAPSED_LINE_COUNT = 25 + // region Lifecycle constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet) : super(context, attrs) @@ -61,18 +81,40 @@ class VisibleMessageContentView : ConstraintLayout { glide: RequestManager = Glide.with(this), thread: Recipient, searchQuery: String? = null, - onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit, - suppressThumbnails: Boolean = false + downloadPendingAttachment: (DatabaseAttachment) -> Unit, + retryFailedAttachments: (List) -> Unit, + suppressThumbnails: Boolean = false, + isTextExpanded: Boolean = false, + onTextExpanded: ((MessageId) -> Unit)? = null ) { // Background val color = if (message.isOutgoing) context.getAccentColor() else context.getColorFromAttr(R.attr.message_received_background_color) binding.contentParent.mainColor = color + binding.documentView.root.backgroundTintList = ColorStateList.valueOf(color) + binding.voiceMessageView.root.backgroundTintList = ColorStateList.valueOf(color) binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius) - val mediaDownloaded = message is MmsMessageRecord && message.slideDeck.asAttachments().all { it.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE } + // we considered all media downloaded if all are done, barring potential 404s. + // Meaning if the one or all attachments are 404 we consider the overall attachment expired + // But in the case of random errors where one image in a set of many somehow reaches a 404 state + // we still want the rest of the images to show up as downloaded + val hasExpired = haveAttachmentsExpired(message) + val mediaDownloaded = !hasExpired && // it is not done if ALL attachments have expired + message is MmsMessageRecord && + // we filter out potential expiry in the set, which would be rare errors, and consider the rest + message.slideDeck.asAttachments().filter { it.transferState != AttachmentState.EXPIRED.value }.all { it.isDone } val mediaInProgress = message is MmsMessageRecord && message.slideDeck.asAttachments().any { it.isInProgress } - val mediaThumbnailMessage = message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null + val hasFailed = message is MmsMessageRecord && message.slideDeck.asAttachments().any { it.isFailed } + val overallAttachmentState = when { + mediaDownloaded -> AttachmentState.DONE + hasExpired -> AttachmentState.EXPIRED + hasFailed -> AttachmentState.FAILED + mediaInProgress -> AttachmentState.DOWNLOADING + else -> AttachmentState.PENDING + } + + val databaseAttachments = (message as? MmsMessageRecord)?.slideDeck?.asAttachments()?.filterIsInstance() // reset visibilities / containers onContentClick.clear() @@ -84,6 +126,7 @@ class VisibleMessageContentView : ConstraintLayout { binding.deletedMessageView.root.isVisible = true binding.deletedMessageView.root.bind(message, getTextColor(context, message)) binding.bodyTextView.isVisible = false + binding.readMore.isVisible = false binding.quoteView.root.isVisible = false binding.linkPreviewView.root.isVisible = false binding.voiceMessageView.root.isVisible = false @@ -99,11 +142,19 @@ class VisibleMessageContentView : ConstraintLayout { // sized based on text content from a recycled view binding.bodyTextView.text = null binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null + // if a quote is by itself we should add bottom padding + binding.quoteView.root.setPadding( + binding.quoteView.root.paddingStart, + binding.quoteView.root.paddingTop, + binding.quoteView.root.paddingEnd, + if(message.body.isNotEmpty()) 0 else + context.resources.getDimensionPixelSize(R.dimen.message_spacing) + ) binding.linkPreviewView.root.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty() - binding.pendingAttachmentView.root.isVisible = !mediaDownloaded && !mediaInProgress && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty() - binding.voiceMessageView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.audioSlide != null - binding.documentView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.documentSlide != null - binding.albumThumbnailView.root.isVisible = mediaThumbnailMessage + binding.attachmentControlView.root.isVisible = false + binding.voiceMessageView.root.isVisible = false + binding.documentView.root.isVisible = false + binding.albumThumbnailView.root.isVisible = false binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation var hideBody = false @@ -116,26 +167,25 @@ class VisibleMessageContentView : ConstraintLayout { } else { quote.text } - binding.quoteView.root.bind(quote.author.toString(), quoteText, quote.attachment, thread, + binding.quoteView.root.bind(Recipient.from(context, quote.author, false), quoteText, quote.attachment, thread, message.isOutgoing, message.isOpenGroupInvitation, message.threadId, quote.isOriginalMissing, glide) onContentClick.add { event -> val r = Rect() binding.quoteView.root.getGlobalVisibleRect(r) if (r.contains(event.rawX.roundToInt(), event.rawY.roundToInt())) { - delegate?.scrollToMessageIfPossible(quote.id) + delegate?.highlightMessageFromTimestamp(quote.id) } } } if (message is MmsMessageRecord) { - message.slideDeck.asAttachments().forEach { attach -> - val dbAttachment = attach as? DatabaseAttachment ?: return@forEach - onAttachmentNeedsDownload(dbAttachment) + databaseAttachments?.forEach { attach -> + downloadPendingAttachment(attach) } message.linkPreviews.forEach { preview -> val previewThumbnail = preview.getThumbnail().orNull() as? DatabaseAttachment ?: return@forEach - onAttachmentNeedsDownload(previewThumbnail) + downloadPendingAttachment(previewThumbnail) } } @@ -148,11 +198,16 @@ class VisibleMessageContentView : ConstraintLayout { // When in a link preview ensure the bodyTextView can expand to the full width binding.bodyTextView.maxWidth = binding.linkPreviewView.root.layoutParams.width } + // AUDIO message is MmsMessageRecord && message.slideDeck.audioSlide != null -> { - hideBody = true + + // Show any text message associated with the audio message (which may be a voice clip - but could also be a mp3 or such) + hideBody = false + // Audio attachment - if (mediaDownloaded || mediaInProgress || message.isOutgoing) { + if (overallAttachmentState == AttachmentState.DONE || message.isOutgoing) { + binding.voiceMessageView.root.isVisible = true binding.voiceMessageView.root.indexInAdapter = indexInAdapter binding.voiceMessageView.root.delegate = context as? ConversationActivityV2 binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster) @@ -160,67 +215,117 @@ class VisibleMessageContentView : ConstraintLayout { // message view) so as to not interfere with all the other gestures. onContentClick.add { binding.voiceMessageView.root.togglePlayback() } onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() } + binding.attachmentControlView.root.isVisible = false } else { - hideBody = true - (message.slideDeck.audioSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment -> - binding.pendingAttachmentView.root.bind( - PendingAttachmentView.AttachmentType.AUDIO, - getTextColor(context,message), - attachment + val attachment = message.slideDeck.audioSlide?.asAttachment() as? DatabaseAttachment + attachment?.let { + showAttachmentControl( + thread = thread, + message = message, + attachments = listOf(it), + type = if (it.isVoiceNote) VOICE + else AUDIO, + overallAttachmentState, + retryFailedAttachments = retryFailedAttachments ) - onContentClick.add { binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) } } } } + + //todo: ATTACHMENT should the glowView encompass the whole message instead of just the body? Currently tapped quotes only highlight text messages, not images nor attachment control + // DOCUMENT message is MmsMessageRecord && message.slideDeck.documentSlide != null -> { - hideBody = true // TODO: check if this is still the logic we want + // Show any message that came with the attached document + hideBody = false + // Document attachment - if (mediaDownloaded || mediaInProgress || message.isOutgoing) { + if (overallAttachmentState == AttachmentState.DONE || message.isOutgoing) { + binding.attachmentControlView.root.isVisible = false + + binding.documentView.root.isVisible = true binding.documentView.root.bind(message, getTextColor(context, message)) + + message.slideDeck.documentSlide?.let { slide -> + if(!mediaInProgress) { // do not attempt to open a doc in progress of downloading + onContentClick.add { + // open the document when tapping it + try { + val intent = Intent(Intent.ACTION_VIEW) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.setDataAndType( + PartAuthority.getAttachmentPublicUri(slide.uri), + slide.contentType + ) + + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Log.e("VisibleMessageContentView", "Error opening document", e) + Toast.makeText( + context, + R.string.attachmentsErrorOpen, + Toast.LENGTH_LONG + ).show() + } + } + } + } } else { - hideBody = true - (message.slideDeck.documentSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment -> - binding.pendingAttachmentView.root.bind( - PendingAttachmentView.AttachmentType.DOCUMENT, - getTextColor(context,message), - attachment - ) - onContentClick.add { binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) } + (message.slideDeck.documentSlide?.asAttachment() as? DatabaseAttachment)?.let { + showAttachmentControl( + thread = thread, + message = message, + attachments = listOf(it), + type = DOCUMENT, + overallAttachmentState, + retryFailedAttachments = retryFailedAttachments + ) } } } + // IMAGE / VIDEO - message is MmsMessageRecord && !suppressThumbnails && message.slideDeck.asAttachments().isNotEmpty() -> { - if (mediaDownloaded || mediaInProgress || 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.root.bind( - glideRequests = glide, - message = message, - isStart = isStartOfMessageCluster, - isEnd = isEndOfMessageCluster - ) - binding.albumThumbnailView.root.modifyLayoutParams { - horizontalBias = if (message.isOutgoing) 1f else 0f - } - onContentClick.add { event -> - binding.albumThumbnailView.root.calculateHitObject(event, message, thread, onAttachmentNeedsDownload) - } - } else { - hideBody = true - binding.albumThumbnailView.root.clearViews() - val firstAttachment = message.slideDeck.asAttachments().first() as? DatabaseAttachment - firstAttachment?.let { attachment -> - binding.pendingAttachmentView.root.bind( - PendingAttachmentView.AttachmentType.IMAGE, - getTextColor(context,message), - attachment + message is MmsMessageRecord && message.slideDeck.asAttachments().isNotEmpty() -> { + hideBody = false + + if (overallAttachmentState == AttachmentState.DONE || message.isOutgoing) { + if(!suppressThumbnails) { // suppress thumbnail should hide the image, but we still want to show the attachment control if the state demands it + + binding.attachmentControlView.root.isVisible = false + + // 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.root.isVisible = true + binding.albumThumbnailView.root.bind( + glideRequests = glide, + message = message, + isStart = isStartOfMessageCluster, + isEnd = isEndOfMessageCluster + ) + binding.albumThumbnailView.root.modifyLayoutParams { + horizontalBias = if (message.isOutgoing) 1f else 0f + } + onContentClick.add { event -> + binding.albumThumbnailView.root.calculateHitObject( + event, + message, + thread, + downloadPendingAttachment ) - onContentClick.add { - binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) } } + } else { + databaseAttachments?.let { + showAttachmentControl( + thread = thread, + message = message, + attachments = it, + type = if (message.slideDeck.hasVideo()) VIDEO + else IMAGE, + state = overallAttachmentState, + retryFailedAttachments = retryFailedAttachments + ) + } } } message.isOpenGroupInvitation -> { @@ -231,6 +336,11 @@ class VisibleMessageContentView : ConstraintLayout { } binding.bodyTextView.isVisible = message.body.isNotEmpty() && !hideBody + // set a max lines + binding.bodyTextView.maxLines = if(isTextExpanded) Int.MAX_VALUE else MAX_COLLAPSED_LINE_COUNT + + binding.readMore.isVisible = false + binding.contentParent.apply { isVisible = children.any { it.isVisible } } if (message.body.isNotEmpty() && !hideBody) { @@ -244,12 +354,100 @@ class VisibleMessageContentView : ConstraintLayout { span.onClick(binding.bodyTextView) } } + + // if the text was already manually expanded, we can skip this logic + if(!isTextExpanded && binding.bodyTextView.needsCollapsing( + availableWidthPx = context.resources.getDimensionPixelSize(R.dimen.max_bubble_width), + maxLines = MAX_COLLAPSED_LINE_COUNT) + ){ + // show the "Read mode" button + binding.readMore.setTextColor(color) + binding.readMore.isVisible = true + + // add read more click listener + val readMoreClickHandler: (MotionEvent) -> Unit = { event -> + val r = Rect() + binding.readMore.getGlobalVisibleRect(r) + if (r.contains(event.rawX.roundToInt(), event.rawY.roundToInt())) { + binding.bodyTextView.maxLines = Int.MAX_VALUE + binding.readMore.isVisible = false + onTextExpanded?.invoke(message.messageId) // Notify that text was expanded + } + } + onContentClick.add(readMoreClickHandler) + } else { + binding.readMore.isVisible = false + } } - binding.contentParent.modifyLayoutParams { - horizontalBias = if (message.isOutgoing) 1f else 0f + + // handle bias for our views + val bias = if (message.isOutgoing) 1f else 0f + binding.contentParent.modifyLayoutParams { + horizontalBias = bias + } + + binding.documentView.root.modifyLayoutParams { + horizontalBias = bias + } + + binding.voiceMessageView.root.modifyLayoutParams { + horizontalBias = bias + } + + binding.attachmentControlView.root.modifyLayoutParams { + horizontalBias = bias } } + private fun showAttachmentControl( + thread: Recipient, + message: MmsMessageRecord, + attachments: List, + type: AttachmentControlView.AttachmentType, + state: AttachmentState, + retryFailedAttachments: (List) -> Unit, + ){ + binding.attachmentControlView.root.isVisible = true + binding.albumThumbnailView.root.clearViews() + + binding.attachmentControlView.root.bind( + attachmentType = type, + textColor = getTextColor(context,message), + state = state, + allMessageAttachments = message.slideDeck.slides + ) + + when(state) { + // While downloads haven't been enabled for this convo, show a confirmation dialog + AttachmentState.PENDING -> { + onContentClick.add { + binding.attachmentControlView.root.showDownloadDialog( + thread, + attachments.first() + ) + } + } + + // Attempt to redownload a failed attachment on tap + AttachmentState.FAILED -> { + onContentClick.add { + retryFailedAttachments(attachments) + } + } + + // no click actions for other cases + else -> {} + } + } + + private fun haveAttachmentsExpired(message: MessageRecord): Boolean = + // expired attachments are for Mms records only + message is MmsMessageRecord && + // with a state marked as expired + (message.slideDeck.asAttachments().all { it.transferState == AttachmentState.EXPIRED.value } || + // with a state marked as downloaded yet without a URI attached + (!message.hasAttachmentUri() && message.slideDeck.asAttachments().all { it.isDone })) + private val onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf() fun onContentClick(event: MotionEvent) { @@ -262,7 +460,7 @@ class VisibleMessageContentView : ConstraintLayout { fun recycle() { arrayOf( binding.deletedMessageView.root, - binding.pendingAttachmentView.root, + binding.attachmentControlView.root, binding.voiceMessageView.root, binding.openGroupInvitationView.root, binding.documentView.root, @@ -280,10 +478,11 @@ class VisibleMessageContentView : ConstraintLayout { 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) + val startColor = ColorUtils.setAlphaComponent(targetColor, (0.5f * 255).toInt()) + val endColor = 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) + GlowViewUtilities.animateShadowColorChange(binding.contentParent, startColor, endColor, 1600) } // endregion @@ -300,9 +499,13 @@ class VisibleMessageContentView : ConstraintLayout { context = context ) body = SearchUtil.getHighlightedSpan(Locale.getDefault(), - { BackgroundColorSpan(Color.WHITE) }, body, searchQuery) + { + BackgroundColorSpan(context.getColorFromAttr(R.attr.colorPrimary)) + }, body, searchQuery) body = SearchUtil.getHighlightedSpan(Locale.getDefault(), - { ForegroundColorSpan(Color.BLACK) }, body, searchQuery) + { + ForegroundColorSpan(context.getColorFromAttr(android.R.attr.textColorPrimary)) + }, body, searchQuery) Linkify.addLinks(body, Linkify.WEB_URLS) 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 0a9c5089e7..0fcf127468 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 @@ -2,12 +2,12 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.annotation.SuppressLint import android.content.Context -import android.content.Intent import android.graphics.Canvas import android.graphics.Rect import android.graphics.drawable.ColorDrawable import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.util.AttributeSet import android.view.Gravity import android.view.HapticFeedbackConstants @@ -19,60 +19,58 @@ import android.widget.FrameLayout import androidx.annotation.ColorInt import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat -import androidx.core.os.bundleOf import androidx.core.view.isVisible -import androidx.core.view.marginBottom +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestManager import dagger.hilt.android.AndroidEntryPoint -import java.util.Date -import java.util.Locale -import javax.inject.Inject -import kotlin.math.abs -import kotlin.math.min -import kotlin.math.roundToInt -import kotlin.math.sqrt import network.loki.messenger.R import network.loki.messenger.databinding.ViewEmojiReactionsBinding import network.loki.messenger.databinding.ViewVisibleMessageBinding import network.loki.messenger.databinding.ViewstubVisibleMessageMarkerContainerBinding +import network.loki.messenger.libsession_util.getOrNull 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.messaging.sending_receiving.attachments.DatabaseAttachment -import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ThemeUtil.getThemedColor +import org.session.libsession.utilities.UsernameUtils import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.modifyLayoutParams +import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix -import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.database.LastSentTimestampCache +import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord -import org.thoughtcrime.securesms.groups.OpenGroupManager -import org.thoughtcrime.securesms.home.UserDetailsBottomSheet -import com.bumptech.glide.Glide -import com.bumptech.glide.RequestManager -import network.loki.messenger.libsession_util.getOrNull -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.utilities.Address.Companion.fromSerialized -import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsession.utilities.truncateIdForDisplay -import org.session.libsignal.utilities.AccountId -import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.model.MmsMessageRecord -import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.groups.OpenGroupManager +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.ui.ProBadgeText +import org.thoughtcrime.securesms.ui.setThemedContent +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.bold import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.toDp import org.thoughtcrime.securesms.util.toPx +import java.util.Date +import javax.inject.Inject +import kotlin.math.abs +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sqrt private const val TAG = "VisibleMessageView" @@ -86,8 +84,11 @@ class VisibleMessageView : FrameLayout { @Inject lateinit var mmsSmsDb: MmsSmsDatabase @Inject lateinit var smsDb: SmsDatabase @Inject lateinit var mmsDb: MmsDatabase - @Inject lateinit var lastSentTimestampCache: LastSentTimestampCache + @Inject lateinit var dateUtils: DateUtils @Inject lateinit var configFactory: ConfigFactoryProtocol + @Inject lateinit var usernameUtils: UsernameUtils + @Inject lateinit var openGroupManager: OpenGroupManager + @Inject lateinit var proStatusManager: ProStatusManager private val binding = ViewVisibleMessageBinding.inflate(LayoutInflater.from(context), this, true) @@ -99,7 +100,11 @@ class VisibleMessageView : FrameLayout { ViewEmojiReactionsBinding.bind(binding.emojiReactionsView.inflate()) } - private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate() + private val swipeToReplyIcon by lazy { + val d = ContextCompat.getDrawable(context, R.drawable.ic_reply)!!.mutate() + d.setTint(context.getColorFromAttr(R.attr.colorControlNormal)) + d + } private val swipeToReplyIconRect = Rect() private var dx = 0.0f private var previousTranslationX = 0.0f @@ -108,8 +113,10 @@ class VisibleMessageView : FrameLayout { private var longPressCallback: Runnable? = null private var onDownTimestamp = 0L private var onDoubleTap: (() -> Unit)? = null + private var isOutgoing: Boolean = false + var indexInAdapter: Int = -1 - var snIsSelected = false + var isMessageSelected = false set(value) { field = value handleIsSelectedChanged() @@ -119,6 +126,10 @@ class VisibleMessageView : FrameLayout { var onLongPress: (() -> Unit)? = null val messageContentView: VisibleMessageContentView get() = binding.messageContentView.root + // Prevent button spam + val MINIMUM_DURATION_BETWEEN_CLICKS_ON_SAME_VIEW_MS = 500L + var lastClickTimestampMS = 0L + companion object { const val swipeToReplyThreshold = 64.0f // dp const val longPressMovementThreshold = 10.0f // dp @@ -157,9 +168,17 @@ class VisibleMessageView : FrameLayout { groupId: AccountId? = null, senderAccountID: String, lastSeen: Long, + lastSentMessageId: MessageId?, delegate: VisibleMessageViewDelegate? = null, - onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit + downloadPendingAttachment: (DatabaseAttachment) -> Unit, + retryFailedAttachments: (List) -> Unit, + isTextExpanded: Boolean = false, + onTextExpanded: ((MessageId) -> Unit)? = null ) { + clipToPadding = false + clipChildren = false + + isOutgoing = message.isOutgoing replyDisabled = message.isOpenGroupInvitation val threadID = message.threadId val thread = threadDb.getRecipientForThreadId(threadID) ?: return @@ -192,19 +211,9 @@ class VisibleMessageView : FrameLayout { binding.profilePictureView.publicKey = senderAccountID binding.profilePictureView.update(message.individualRecipient) binding.profilePictureView.setOnClickListener { - if (thread.isCommunityRecipient) { - val openGroup = lokiThreadDb.getOpenGroupChat(threadID) - if (IdPrefix.fromValue(senderAccountID) == 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(senderAccountID)) - context.startActivity(intent) - } - } else { - maybeShowUserDetails(senderAccountID, threadID) - } + delegate?.showUserProfileModal(message.recipient) } + if (thread.isCommunityRecipient) { val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return var standardPublicKey = "" @@ -214,7 +223,11 @@ class VisibleMessageView : FrameLayout { } else { standardPublicKey = senderAccountID } - val isModerator = OpenGroupManager.isUserModerator(context, openGroup.groupId, standardPublicKey, blindedPublicKey) + val isModerator = openGroupManager.isUserModerator( + openGroup.groupId, + standardPublicKey, + blindedPublicKey + ) binding.moderatorIconImageView.isVisible = isModerator } else if (thread.isLegacyGroupRecipient) { // legacy groups @@ -224,7 +237,7 @@ class VisibleMessageView : FrameLayout { binding.moderatorIconImageView.isVisible = isAdmin } else if (thread.isGroupV2Recipient) { // groups v2 - val isAdmin = configFactory.withGroupConfigs(AccountId(thread.address.serialize())) { + val isAdmin = configFactory.withGroupConfigs(AccountId(thread.address.toString())) { it.groupMembers.getOrNull(senderAccountID)?.admin == true } @@ -232,15 +245,35 @@ class VisibleMessageView : FrameLayout { } } } - binding.senderNameTextView.isVisible = !message.isOutgoing && (isStartOfMessageCluster && (isGroupThread || snIsSelected)) - val contactContext = - if (thread.isCommunityRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR - binding.senderNameTextView.text = MessagingModuleConfiguration.shared.storage.getContactNameWithAccountID( - contact = contact, - accountID = senderAccountID, - contactContext = contactContext, - groupId = groupId - ) + if(!message.isOutgoing && (isStartOfMessageCluster && isGroupThread)){ + binding.senderName.setOnClickListener { + delegate?.showUserProfileModal(message.recipient) + } + + val contactContext = + if (thread.isCommunityRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR + + // set up message author + binding.senderName.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setThemedContent { + ProBadgeText( + text = usernameUtils.getContactNameWithAccountID( //todo badge we need to rework te naming logic to get the name + account id separately - waiting on the Recipient refactor + contact = contact, + accountID = senderAccountID, + contactContext = contactContext, + groupId = groupId + ), + textStyle = LocalType.current.base.bold().copy(color = LocalColors.current.text), + showBadge = proStatusManager.shouldShowProBadge(message.recipient.address), + ) + } + } + + binding.senderName.isVisible = true + } else { + binding.senderName.isVisible = false + } // Unread marker val shouldShowUnreadMarker = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing @@ -252,19 +285,21 @@ class VisibleMessageView : FrameLayout { } // Date break - val showDateBreak = isStartOfMessageCluster || snIsSelected - binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null + val showDateBreak = isStartOfMessageCluster + binding.dateBreakTextView.text = if (showDateBreak) dateUtils.getDisplayFormattedTimeSpanString( + message.timestamp + ) else null binding.dateBreakTextView.isVisible = showDateBreak // Update message status indicator - showStatusMessage(message) + showStatusMessage(message, lastSentMessageId) // Emoji Reactions if (!message.isDeleted && 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.setReactions(message.messageId, message.reactions, message.isOutgoing, delegate) root.isVisible = true (root.layoutParams as ConstraintLayout.LayoutParams).apply { horizontalBias = if (message.isOutgoing) 1f else 0f @@ -287,7 +322,10 @@ class VisibleMessageView : FrameLayout { glide, thread, searchQuery, - onAttachmentNeedsDownload + downloadPendingAttachment = downloadPendingAttachment, + retryFailedAttachments = retryFailedAttachments, + isTextExpanded = isTextExpanded, + onTextExpanded = onTextExpanded ) binding.messageContentView.root.delegate = delegate onDoubleTap = { binding.messageContentView.root.onContentDoubleTap?.invoke() } @@ -297,7 +335,7 @@ class VisibleMessageView : FrameLayout { // 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 either "Sent" or "Read" and the animating clock icon. - private fun showStatusMessage(message: MessageRecord) { + private fun showStatusMessage(message: MessageRecord, lastSentMessageId: MessageId?) { // We'll start by hiding everything and then only make visible what we need binding.messageStatusTextView.isVisible = false binding.messageStatusImageView.isVisible = false @@ -361,8 +399,7 @@ class VisibleMessageView : FrameLayout { } 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 + val isLastSent = lastSentMessageId != null && lastSentMessageId.id == message.id && lastSentMessageId.mms == message.isMms binding.messageStatusTextView.isVisible = isLastSent binding.messageStatusImageView.isVisible = isLastSent if (isLastSent) { binding.messageStatusImageView.bringToFront() } @@ -389,14 +426,14 @@ class VisibleMessageView : FrameLayout { } private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean = - previous == null || previous.isControlMessage || !DateUtils.isSameHour(current.timestamp, previous.timestamp) || if (isGroupThread) { + previous == null || previous.isControlMessage || !dateUtils.isSameHour(current.timestamp, previous.timestamp) || if (isGroupThread) { current.recipient.address != previous.recipient.address } else { current.isOutgoing != previous.isOutgoing } private fun isEndOfMessageCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean = - next == null || next.isControlMessage || !DateUtils.isSameHour(current.timestamp, next.timestamp) || if (isGroupThread) { + next == null || next.isControlMessage || !dateUtils.isSameHour(current.timestamp, next.timestamp) || if (isGroupThread) { current.recipient.address != next.recipient.address } else { current.isOutgoing != next.isOutgoing @@ -408,13 +445,13 @@ class VisibleMessageView : FrameLayout { private fun getMessageStatusInfo(message: MessageRecord): MessageStatusInfo? = when { message.isFailed -> - MessageStatusInfo(R.drawable.ic_delivery_status_failed, + MessageStatusInfo(R.drawable.ic_triangle_alert, getThemedColor(context, R.attr.danger), R.string.messageStatusFailedToSend ) message.isSyncFailed -> MessageStatusInfo( - R.drawable.ic_delivery_status_failed, + R.drawable.ic_triangle_alert, context.getColorFromAttr(R.attr.warning), R.string.messageStatusFailedToSync ) @@ -422,14 +459,14 @@ class VisibleMessageView : FrameLayout { // Non-mms messages (or quote messages, which happen to be mms for some reason) display 'Sending'.. if (!message.isMms || (message as? MmsMessageRecord)?.quote != null) { MessageStatusInfo( - R.drawable.ic_delivery_status_sending, + R.drawable.ic_circle_dots_custom, context.getColorFromAttr(R.attr.message_status_color), R.string.sending ) } else { // ..and Mms messages display 'Uploading'. MessageStatusInfo( - R.drawable.ic_delivery_status_sending, + R.drawable.ic_circle_dots_custom, context.getColorFromAttr(R.attr.message_status_color), R.string.uploading ) @@ -437,19 +474,19 @@ class VisibleMessageView : FrameLayout { } message.isResyncing -> MessageStatusInfo( - R.drawable.ic_delivery_status_sending, + R.drawable.ic_circle_dots_custom, context.getColorFromAttr(R.attr.message_status_color), R.string.messageStatusSyncing ) message.isRead || message.isIncoming -> MessageStatusInfo( - R.drawable.ic_delivery_status_read, + R.drawable.ic_eye, context.getColorFromAttr(R.attr.message_status_color), R.string.read ) message.isSyncing || message.isSent -> // syncing should happen silently in the bg so we can mark it as sent MessageStatusInfo( - R.drawable.ic_delivery_status_sent, + R.drawable.ic_circle_check, context.getColorFromAttr(R.attr.message_status_color), R.string.disappearingMessagesSent ) @@ -471,16 +508,18 @@ class VisibleMessageView : FrameLayout { } private fun handleIsSelectedChanged() { - background = if (snIsSelected) ColorDrawable(context.getColorFromAttr(R.attr.message_selected)) else null + background = if (isMessageSelected) ColorDrawable(context.getColorFromAttr(R.attr.message_selected)) else null } override fun onDraw(canvas: Canvas) { - val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing) + val spacing = context.resources.getDimensionPixelSize(R.dimen.medium_spacing) val iconSize = toPx(24, context.resources) - val left = binding.messageInnerContainer.left + binding.messageContentView.root.right + spacing - val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.marginBottom - (iconSize / 2) + val left = if(isOutgoing) binding.messageInnerContainer.right + spacing + else binding.messageInnerContainer.left + binding.messageContentView.root.right + spacing + val top = (binding.messageInnerContainer.height / 2) + (iconSize / 2) val right = left + iconSize val bottom = top + iconSize + swipeToReplyIconRect.left = left swipeToReplyIconRect.top = top swipeToReplyIconRect.right = right @@ -534,7 +573,7 @@ class VisibleMessageView : FrameLayout { private fun onMove(event: MotionEvent) { val translationX = toDp(event.rawX + dx, context.resources) - if (abs(translationX) < longPressMovementThreshold || snIsSelected) { + if (abs(translationX) < longPressMovementThreshold || isMessageSelected) { return } else { longPressCallback?.let { gestureHandler.removeCallbacks(it) } @@ -603,24 +642,21 @@ class VisibleMessageView : FrameLayout { onLongPress?.invoke() } - fun onContentClick(event: MotionEvent) { - binding.messageContentView.root.onContentClick(event) - } + private fun clickedTooFast() = (SystemClock.elapsedRealtime() - lastClickTimestampMS < MINIMUM_DURATION_BETWEEN_CLICKS_ON_SAME_VIEW_MS) + // Note: `onPress` is called BEFORE `onContentClick` is called, so we only filter here rather than + // in both places otherwise `onContentClick` will instantly fail the button spam test. private fun onPress(event: MotionEvent) { + // Don't process the press if it's too soon after the last one.. + if (clickedTooFast()) return + + // ..otherwise take note of the time and process the event. + lastClickTimestampMS = SystemClock.elapsedRealtime() onPress?.invoke(event) pressCallback = null } - private fun maybeShowUserDetails(publicKey: String, threadID: Long) { - UserDetailsBottomSheet().apply { - arguments = bundleOf( - UserDetailsBottomSheet.ARGUMENT_PUBLIC_KEY to publicKey, - UserDetailsBottomSheet.ARGUMENT_THREAD_ID to threadID - ) - show((this@VisibleMessageView.context as AppCompatActivity).supportFragmentManager, tag) - } - } + fun onContentClick(event: MotionEvent) = binding.messageContentView.root.onContentClick(event) fun playVoiceMessage() { binding.messageContentView.root.playVoiceMessage() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageViewDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageViewDelegate.kt index 69797b8848..e2a881420c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageViewDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageViewDelegate.kt @@ -1,15 +1,12 @@ package org.thoughtcrime.securesms.conversation.v2.messages +import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.database.model.MessageId interface VisibleMessageViewDelegate { - fun playVoiceMessageAtIndexIfPossible(indexInAdapter: Int) - - fun scrollToMessageIfPossible(timestamp: Long) - + fun highlightMessageFromTimestamp(timestamp: Long) fun onReactionClicked(emoji: String, messageId: MessageId, userWasSender: Boolean) - fun onReactionLongClicked(messageId: MessageId, emoji: String?) - + fun showUserProfileModal(recipient: Recipient) } \ No newline at end of file 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 06a5168a99..4e4166105d 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 @@ -3,91 +3,91 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.graphics.Canvas import android.util.AttributeSet -import android.view.View import android.widget.RelativeLayout import androidx.core.view.isVisible +import androidx.media3.common.C import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewVoiceMessageBinding import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.audio.AudioSlidePlayer import org.thoughtcrime.securesms.components.CornerMask import org.thoughtcrime.securesms.conversation.v2.utilities.MessageBubbleUtilities import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.model.MmsMessageRecord -import java.util.concurrent.TimeUnit +import org.thoughtcrime.securesms.util.MediaUtil import javax.inject.Inject import kotlin.math.roundToInt import kotlin.math.roundToLong @AndroidEntryPoint -class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener { +class VoiceMessageView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : RelativeLayout(context, attrs, defStyleAttr), AudioSlidePlayer.Listener { + private val TAG = "VoiceMessageView" @Inject lateinit var attachmentDb: AttachmentDatabase private val binding: ViewVoiceMessageBinding by lazy { ViewVoiceMessageBinding.bind(this) } private val cornerMask by lazy { CornerMask(this) } + private var isPlaying = false - set(value) { - field = value - renderIcon() - } + set(value) { + field = value + renderIcon() + } + private var progress = 0.0 - private var duration = 0L + private var durationMS = 0L private var player: AudioSlidePlayer? = null var delegate: VisibleMessageViewDelegate? = null var indexInAdapter = -1 - // 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) - - override fun onFinishInflate() { - super.onFinishInflate() - binding.voiceMessageViewDurationTextView.text = String.format("%01d:%02d", - TimeUnit.MILLISECONDS.toMinutes(0), - TimeUnit.MILLISECONDS.toSeconds(0)) - } - - // endregion - // region Updating fun bind(message: MmsMessageRecord, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean) { - val audio = message.slideDeck.audioSlide!! - binding.voiceMessageViewLoader.isVisible = audio.isInProgress + val audioSlide = message.slideDeck.audioSlide!! + + binding.voiceMessageViewLoader.isVisible = audioSlide.isInProgress 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 process audio if downloaded - if (audio.isPendingDownload || audio.isInProgress) { + // This sets the final duration of the uploaded voice message + (audioSlide.asAttachment() as? DatabaseAttachment)?.let { attachment -> + if (attachment.audioDurationMs > 0) { + val formattedVoiceMessageDuration = MediaUtil.getFormattedVoiceMessageDuration(attachment.audioDurationMs) + binding.voiceMessageViewDurationTextView.text = formattedVoiceMessageDuration + durationMS = attachment.audioDurationMs + } else { + Log.w(TAG, "For some reason attachment.audioDurationMs was NOT greater than zero!") + binding.voiceMessageViewDurationTextView.text = "--:--" + } + } + + // On initial upload (and while processing audio) we will exit at this point and then return when processing is complete + if (audioSlide.isPendingDownload || audioSlide.isInProgress) { this.player = null return } - val player = AudioSlidePlayer.createFor(context.applicationContext, audio, this) - this.player = player - - (audio.asAttachment() as? DatabaseAttachment)?.let { attachment -> - attachmentDb.getAttachmentAudioExtras(attachment.attachmentId)?.let { audioExtras -> - if (audioExtras.durationMs > 0) { - duration = audioExtras.durationMs - binding.voiceMessageViewDurationTextView.visibility = View.VISIBLE - binding.voiceMessageViewDurationTextView.text = String.format("%01d:%02d", - TimeUnit.MILLISECONDS.toMinutes(audioExtras.durationMs), - TimeUnit.MILLISECONDS.toSeconds(audioExtras.durationMs) % 60) - } - } - } + this.player = AudioSlidePlayer.createFor(context.applicationContext, audioSlide, this) } override fun onPlayerStart(player: AudioSlidePlayer) { isPlaying = true + + if (player.duration != C.TIME_UNSET) { + durationMS = player.duration + } } + override fun onPlayerStop(player: AudioSlidePlayer) { isPlaying = false } + override fun onPlayerProgress(player: AudioSlidePlayer, progress: Double, unused: Long) { if (progress == 1.0) { togglePlayback() @@ -100,18 +100,17 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener { private fun handleProgressChanged(progress: Double) { 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()) % 60) + + // As playback progress increases the remaining duration of the audio decreases + val remainingDurationMS = durationMS - (progress * durationMS.toDouble()).roundToLong() + + binding.voiceMessageViewDurationTextView.text = MediaUtil.getFormattedVoiceMessageDuration(remainingDurationMS) + val layoutParams = binding.progressView.layoutParams as RelativeLayout.LayoutParams layoutParams.width = (width.toFloat() * progress.toFloat()).roundToInt() binding.progressView.layoutParams = layoutParams } - override fun onPlayerStop(player: AudioSlidePlayer) { - isPlaying = false - } - override fun dispatchDraw(canvas: Canvas) { super.dispatchDraw(canvas) cornerMask.mask(canvas) @@ -121,7 +120,6 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener { val iconID = if (isPlaying) R.drawable.exo_icon_pause else R.drawable.exo_icon_play binding.voiceMessagePlaybackImageView.setImageResource(iconID) } - // endregion // region Interaction @@ -136,8 +134,11 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener { } fun handleDoubleTap() { - val player = this.player ?: return - player.playbackSpeed = if (player.playbackSpeed == 1.0f) 1.5f else 1.0f + if (this.player == null) { + Log.w(TAG, "Could not get player to adjust voice message playback speed.") + return + } + this.player?.playbackSpeed = if (this.player?.playbackSpeed == 1f) 1.5f else 1f } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt index a379a23445..53642aca3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt @@ -5,11 +5,9 @@ import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.widget.LinearLayout -import com.squareup.phrase.Phrase import network.loki.messenger.R import network.loki.messenger.databinding.ViewSearchBottomBarBinding -import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.TOTAL_COUNT_KEY +import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel.Companion.MIN_QUERY_SIZE class SearchBottomBar : LinearLayout { private lateinit var binding: ViewSearchBottomBarBinding @@ -24,8 +22,8 @@ class SearchBottomBar : LinearLayout { binding = ViewSearchBottomBarBinding.inflate(LayoutInflater.from(context), this, true) } - fun setData(position: Int, count: Int) = with(binding) { - searchProgressWheel.visibility = GONE + fun setData(position: Int, count: Int, searchQuery: String?) = with(binding) { + binding.loading.visibility = GONE searchUp.setOnClickListener { v: View? -> if (eventListener != null) { eventListener!!.onSearchMoveUpPressed() @@ -36,9 +34,15 @@ class SearchBottomBar : LinearLayout { eventListener!!.onSearchMoveDownPressed() } } - if (count > 0) { + if (count > 0) { // we have results searchPosition.text = resources.getQuantityString(R.plurals.searchMatches, count, position + 1, count) - } else { + } else if ( // we have a legitimate query but no results + searchQuery != null && + searchQuery.length >= MIN_QUERY_SIZE && + count == 0 + ) { + searchPosition.text = resources.getString(R.string.searchMatchesNone) + } else { // we have no legitimate query yet searchPosition.text = "" } setViewEnabled(searchUp, position < count - 1) @@ -46,7 +50,7 @@ class SearchBottomBar : LinearLayout { } fun showLoading() { - binding.searchProgressWheel.visibility = VISIBLE + binding.loading.visibility = VISIBLE } private fun setViewEnabled(view: View, enabled: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt index 82156b32e7..6b315ceeb6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt @@ -1,48 +1,48 @@ package org.thoughtcrime.securesms.conversation.v2.search -import android.content.Context import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.io.Closeable +import javax.inject.Inject import org.session.libsession.utilities.Debouncer import org.session.libsession.utilities.Util.runOnMain -import org.session.libsession.utilities.concurrent.SignalExecutors -import org.thoughtcrime.securesms.contacts.ContactAccessor import org.thoughtcrime.securesms.database.CursorList -import org.thoughtcrime.securesms.database.SearchDatabase -import org.thoughtcrime.securesms.database.SessionContactDatabase -import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.search.SearchRepository import org.thoughtcrime.securesms.search.model.MessageResult import org.thoughtcrime.securesms.util.CloseableLiveData -import java.io.Closeable -import javax.inject.Inject @HiltViewModel class SearchViewModel @Inject constructor( private val searchRepository: SearchRepository ) : ViewModel() { + companion object { + const val MIN_QUERY_SIZE = 2 + } + private val result: CloseableLiveData = CloseableLiveData() - private val debouncer: Debouncer = Debouncer(500) - private var firstSearch = false + private val debouncer: Debouncer = Debouncer(200) private var searchOpen = false - private var activeQuery: String? = null private var activeThreadId: Long = 0 val searchResults: LiveData get() = result + private val mutableSearchQuery: MutableStateFlow = MutableStateFlow(null) + val searchQuery: StateFlow get() = mutableSearchQuery + fun onQueryUpdated(query: String, threadId: Long) { - if (query == activeQuery) { + if (query == mutableSearchQuery.value) { return } updateQuery(query, threadId) } fun onMissingResult() { - if (activeQuery != null) { - updateQuery(activeQuery!!, activeThreadId) + if (mutableSearchQuery.value != null) { + updateQuery(mutableSearchQuery.value!!, activeThreadId) } } @@ -62,12 +62,11 @@ class SearchViewModel @Inject constructor( fun onSearchOpened() { searchOpen = true - firstSearch = true } fun onSearchClosed() { searchOpen = false - activeQuery = null + mutableSearchQuery.value = null debouncer.clear() result.close() } @@ -78,13 +77,18 @@ class SearchViewModel @Inject constructor( } private fun updateQuery(query: String, threadId: Long) { - activeQuery = query + mutableSearchQuery.value = query activeThreadId = threadId + + if(query.length < MIN_QUERY_SIZE) { + result.value = SearchResult(CursorList.emptyList(), 0) + return + } + debouncer.publish { - firstSearch = false searchRepository.query(query, threadId) { messages: CursorList -> runOnMain { - if (searchOpen && query == activeQuery) { + if (searchOpen && query == mutableSearchQuery.value) { result.setValue(SearchResult(messages, 0)) } else { messages.close() @@ -94,9 +98,10 @@ class SearchViewModel @Inject constructor( } } - public fun getActiveQuery() = activeQuery - - class SearchResult(private val results: CursorList, val position: Int) : Closeable { + class SearchResult( + private val results: CursorList, + val position: Int + ) : Closeable { fun getResults(): List { return results diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsActivity.kt new file mode 100644 index 0000000000..241aeca9a4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsActivity.kt @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.conversation.v2.settings + +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.core.content.IntentCompat +import dagger.hilt.android.AndroidEntryPoint +import org.session.libsession.utilities.Address +import org.thoughtcrime.securesms.FullComposeScreenLockActivity +import org.thoughtcrime.securesms.ui.UINavigator +import javax.inject.Inject + +@AndroidEntryPoint +class ConversationSettingsActivity: FullComposeScreenLockActivity() { + + companion object { + const val THREAD_ID = "conversation_settings_thread_id" + const val THREAD_ADDRESS = "conversation_settings_thread_address" + + fun createIntent(context: Context, threadId: Long, threadAddress: Address?): Intent { + return Intent(context, ConversationSettingsActivity::class.java).apply { + putExtra(THREAD_ID, threadId) + putExtra(THREAD_ADDRESS, threadAddress) + } + } + } + + @Inject + lateinit var navigator: UINavigator + + @Composable + override fun ComposeContent() { + ConversationSettingsNavHost( + threadId = intent.getLongExtra(THREAD_ID, 0), + threadAddress = IntentCompat.getParcelableExtra(intent, THREAD_ADDRESS, Address::class.java), + navigator = navigator, + returnResult = { code, value -> + setResult(RESULT_OK, Intent().putExtra(code, value)) + finish() + }, + onBack = this::finish + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt new file mode 100644 index 0000000000..f752697802 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -0,0 +1,442 @@ +package org.thoughtcrime.securesms.conversation.v2.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.ClearMessagesGroupDeviceOnly +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.ClearMessagesGroupEveryone +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.HideGroupAdminClearMessagesDialog +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.HideGroupEditDialog +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.HideNicknameDialog +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.HidePinCTADialog +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.HideProBadgeCTA +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.HideSimpleDialog +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.RemoveNickname +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.SetGroupText +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.SetNickname +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.UpdateGroupDescription +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.UpdateGroupName +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.UpdateNickname +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.DialogButtonData +import org.thoughtcrime.securesms.ui.GenericProCTA +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.PinProCTA +import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.SimpleSessionProActivatedCTA +import org.thoughtcrime.securesms.ui.components.AnnotatedTextWithIcon +import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton +import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme + +@Composable +fun ConversationSettingsDialogs( + dialogsState: ConversationSettingsViewModel.DialogsState, + sendCommand: (ConversationSettingsViewModel.Commands) -> Unit +){ + val context = LocalContext.current + + // Simple dialogs + if (dialogsState.showSimpleDialog != null) { + val buttons = mutableListOf() + if(dialogsState.showSimpleDialog.positiveText != null) { + buttons.add( + DialogButtonData( + text = GetString(dialogsState.showSimpleDialog.positiveText), + color = if (dialogsState.showSimpleDialog.positiveStyleDanger) LocalColors.current.danger + else LocalColors.current.text, + qaTag = dialogsState.showSimpleDialog.positiveQaTag, + onClick = dialogsState.showSimpleDialog.onPositive + ) + ) + } + if(dialogsState.showSimpleDialog.negativeText != null){ + buttons.add( + DialogButtonData( + text = GetString(dialogsState.showSimpleDialog.negativeText), + qaTag = dialogsState.showSimpleDialog.negativeQaTag, + onClick = dialogsState.showSimpleDialog.onNegative + ) + ) + } + + AlertDialog( + onDismissRequest = { + // hide dialog + sendCommand(HideSimpleDialog) + }, + title = annotatedStringResource(dialogsState.showSimpleDialog.title), + text = annotatedStringResource(dialogsState.showSimpleDialog.message), + showCloseButton = dialogsState.showSimpleDialog.showXIcon, + buttons = buttons + ) + } + + // Group admin clear messages + if(dialogsState.groupAdminClearMessagesDialog != null) { + GroupAdminClearMessagesDialog( + groupName = dialogsState.groupAdminClearMessagesDialog.groupName, + sendCommand = sendCommand + ) + } + + // Nickname + if(dialogsState.nicknameDialog != null){ + + val focusRequester = remember { FocusRequester() } + LaunchedEffect (Unit) { + focusRequester.requestFocus() + } + + AlertDialog( + onDismissRequest = { + // hide dialog + sendCommand(HideNicknameDialog) + }, + title = AnnotatedString(stringResource(R.string.nicknameSet)), + text = annotatedStringResource(Phrase.from(context, R.string.nicknameDescription) + .put(NAME_KEY, dialogsState.nicknameDialog.name) + .format()), + showCloseButton = true, + content = { + SessionOutlinedTextField( + text = dialogsState.nicknameDialog.inputNickname ?: "", + modifier = Modifier.qaTag(R.string.qa_conversation_settings_dialog_nickname_input) + .focusRequester(focusRequester) + .padding(top = LocalDimensions.current.smallSpacing), + placeholder = stringResource(R.string.nicknameEnter), + innerPadding = PaddingValues(LocalDimensions.current.smallSpacing), + onChange = { updatedText -> + sendCommand(UpdateNickname(updatedText)) + }, + showClear = true, + singleLine = true, + onContinue = { sendCommand(SetNickname) }, + error = dialogsState.nicknameDialog.error, + ) + }, + buttons = listOf( + DialogButtonData( + text = GetString(stringResource(id = R.string.save)), + enabled = dialogsState.nicknameDialog.setEnabled, + qaTag = stringResource(R.string.qa_conversation_settings_dialog_nickname_set), + onClick = { sendCommand(SetNickname) } + ), + DialogButtonData( + text = GetString(stringResource(R.string.remove)), + color = LocalColors.current.danger, + enabled = dialogsState.nicknameDialog.removeEnabled, + qaTag = stringResource(R.string.qa_conversation_settings_dialog_nickname_remove), + onClick = { + sendCommand(RemoveNickname) + } + ) + ) + ) + } + + // Group Edit + if(dialogsState.groupEditDialog != null){ + + val focusRequester = remember { FocusRequester() } + LaunchedEffect (Unit) { + focusRequester.requestFocus() + } + + AlertDialog( + onDismissRequest = { + // hide dialog + sendCommand(HideGroupEditDialog) + }, + title = stringResource(R.string.updateGroupInformation), + text = stringResource(R.string.updateGroupInformationDescription), + showCloseButton = true, + content = { + Column { + // group name + SessionOutlinedTextField( + text = dialogsState.groupEditDialog.inputName ?: "", + modifier = Modifier.qaTag(R.string.qa_conversation_settings_dialog_groupname_input) + .focusRequester(focusRequester) + .padding(top = LocalDimensions.current.smallSpacing), + innerPadding = PaddingValues(LocalDimensions.current.smallSpacing), + placeholder = stringResource(R.string.groupNameEnter), + onChange = { updatedText -> + sendCommand(UpdateGroupName(updatedText)) + }, + showClear = true, + clearQaTag = R.string.qa_input_clear_name, + singleLine = true, + error = dialogsState.groupEditDialog.errorName, + ) + + // group description + SessionOutlinedTextField( + text = dialogsState.groupEditDialog.inputtedDescription ?: "", + modifier = Modifier.qaTag(R.string.qa_conversation_settings_dialog_groupname_description_input) + .padding(top = LocalDimensions.current.xxsSpacing), + placeholder = stringResource(R.string.groupDescriptionEnter), + innerPadding = PaddingValues(LocalDimensions.current.smallSpacing), + minLines = 3, + maxLines = 12, + onChange = { updatedText -> + sendCommand(UpdateGroupDescription(updatedText)) + }, + showClear = true, + clearQaTag = R.string.qa_input_clear_description, + error = dialogsState.groupEditDialog.errorDescription, + ) + } + }, + buttons = listOf( + DialogButtonData( + text = GetString(stringResource(id = R.string.save)), + enabled = dialogsState.groupEditDialog.saveEnabled, + qaTag = stringResource(R.string.qa_conversation_settings_dialog_groupname_save), + onClick = { sendCommand(SetGroupText) } + ), + DialogButtonData( + text = GetString(stringResource(R.string.cancel)), + color = LocalColors.current.danger, + qaTag = stringResource(R.string.qa_conversation_settings_dialog_groupname_cancel), + ) + ) + ) + } + + // pin CTA + if(dialogsState.pinCTA != null){ + PinProCTA( + overTheLimit = dialogsState.pinCTA.overTheLimit, + onDismissRequest = { + sendCommand(HidePinCTADialog) + } + ) + } + + when(dialogsState.proBadgeCTA){ + is ConversationSettingsViewModel.ProBadgeCTA.Generic -> { + GenericProCTA( + onDismissRequest = { + sendCommand(HideProBadgeCTA) + } + ) + } + + is ConversationSettingsViewModel.ProBadgeCTA.Group -> { + SimpleSessionProActivatedCTA( + heroImage = R.drawable.cta_hero_group, + title = stringResource(R.string.proGroupActivated), + textContent = { + AnnotatedTextWithIcon( + modifier = Modifier + .fillMaxWidth(), + text = stringResource(R.string.proGroupActivatedDescription), + iconRes = R.drawable.ic_pro_badge, + iconSize = 40.sp to 18.sp, + style = LocalType.current.large, + ) + }, + onCancel = { + sendCommand(HideProBadgeCTA) + } + ) + } + + else -> {} + } +} + +@Composable +fun GroupAdminClearMessagesDialog( + modifier: Modifier = Modifier, + groupName: String, + sendCommand: (ConversationSettingsViewModel.Commands) -> Unit, +){ + var deleteForEveryone by remember { mutableStateOf(false) } + + val context = LocalContext.current + + AlertDialog( + modifier = modifier, + onDismissRequest = { + // hide dialog + sendCommand(HideGroupAdminClearMessagesDialog) + }, + title = annotatedStringResource(R.string.clearMessages), + text = annotatedStringResource(Phrase.from(context, R.string.clearMessagesGroupAdminDescriptionUpdated) + .put(GROUP_NAME_KEY, groupName) + .format()), + content = { + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(stringResource(R.string.clearOnThisDevice)), + qaTag = GetString(R.string.qa_conversation_settings_clear_messages_radio_device), + selected = !deleteForEveryone + ) + ) { + deleteForEveryone = false + } + + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(stringResource(R.string.clearMessagesForEveryone)), + qaTag = GetString(R.string.qa_conversation_settings_clear_messages_radio_everyone), + selected = deleteForEveryone, + ) + ) { + deleteForEveryone = true + } + }, + buttons = listOf( + DialogButtonData( + text = GetString(stringResource(id = R.string.clear)), + color = LocalColors.current.danger, + onClick = { + // clear messages based on chosen option + sendCommand( + if(deleteForEveryone) ClearMessagesGroupEveryone + else ClearMessagesGroupDeviceOnly + ) + } + ), + DialogButtonData( + GetString(stringResource(R.string.cancel)) + ) + ) + ) +} + +@Preview +@Composable +fun PreviewNicknameSetDialog() { + PreviewTheme { + ConversationSettingsDialogs( + dialogsState = ConversationSettingsViewModel.DialogsState( + nicknameDialog = ConversationSettingsViewModel.NicknameDialogData( + name = "Rick", + currentNickname = "Razza", + inputNickname = "Rickety", + setEnabled = true, + removeEnabled = true, + error = null, + ) + ), + sendCommand = {} + ) + } +} + + +@Preview +@Composable +fun PreviewNicknameEmptyDialog() { + PreviewTheme { + ConversationSettingsDialogs( + dialogsState = ConversationSettingsViewModel.DialogsState( + nicknameDialog = ConversationSettingsViewModel.NicknameDialogData( + name = "Rick", + currentNickname = null, + inputNickname = null, + setEnabled = false, + removeEnabled = false, + error = null, + ) + ), + sendCommand = {} + ) + } +} + +@Preview +@Composable +fun PreviewNicknameEmptyWithInputDialog() { + PreviewTheme { + ConversationSettingsDialogs( + dialogsState = ConversationSettingsViewModel.DialogsState( + nicknameDialog = ConversationSettingsViewModel.NicknameDialogData( + name = "Rick", + currentNickname = null, + inputNickname = "Rickety", + setEnabled = true, + removeEnabled = false, + error = null, + ) + ), + sendCommand = {} + ) + } +} + +@Preview +@Composable +fun PreviewBaseGroupDialog() { + PreviewTheme { + ConversationSettingsDialogs( + dialogsState = ConversationSettingsViewModel.DialogsState( + groupEditDialog = ConversationSettingsViewModel.GroupEditDialog( + currentName = "the Crew", + inputName = null, + currentDescription = null, + inputtedDescription = null, + saveEnabled = true, + errorName = null, + errorDescription = null, + ) + ), + sendCommand = {} + ) + } +} + +@Preview +@Composable +fun PreviewClearAllMsgGroupDialog() { + PreviewTheme { + ConversationSettingsDialogs( + dialogsState = ConversationSettingsViewModel.DialogsState( + groupAdminClearMessagesDialog = ConversationSettingsViewModel.GroupAdminClearMessageDialog("Testy") + ), + sendCommand = {} + ) + } +} + +@Preview +@Composable +fun PreviewCTAGroupDialog() { + PreviewTheme { + ConversationSettingsDialogs( + dialogsState = ConversationSettingsViewModel.DialogsState( + proBadgeCTA = ConversationSettingsViewModel.ProBadgeCTA.Group + ), + sendCommand = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt new file mode 100644 index 0000000000..e4c55cdc57 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -0,0 +1,295 @@ +package org.thoughtcrime.securesms.conversation.v2.settings + +import android.annotation.SuppressLint +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import kotlinx.serialization.Serializable +import network.loki.messenger.BuildConfig +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.utilities.Address +import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesViewModel +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.DisappearingMessagesScreen +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.* +import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsScreen +import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsViewModel +import org.thoughtcrime.securesms.groups.EditGroupViewModel +import org.thoughtcrime.securesms.groups.GroupMembersViewModel +import org.thoughtcrime.securesms.groups.SelectContactsViewModel +import org.thoughtcrime.securesms.groups.compose.EditGroupScreen +import org.thoughtcrime.securesms.groups.compose.GroupMembersScreen +import org.thoughtcrime.securesms.groups.compose.GroupMinimumVersionBanner +import org.thoughtcrime.securesms.groups.compose.InviteContactsScreen +import org.thoughtcrime.securesms.media.MediaOverviewScreen +import org.thoughtcrime.securesms.media.MediaOverviewViewModel +import org.thoughtcrime.securesms.ui.NavigationAction +import org.thoughtcrime.securesms.ui.ObserveAsEvents +import org.thoughtcrime.securesms.ui.UINavigator +import org.thoughtcrime.securesms.ui.horizontalSlideComposable + +// Destinations +sealed interface ConversationSettingsDestination { + @Serializable + data object RouteConversationSettings: ConversationSettingsDestination + + @Serializable + data class RouteGroupMembers( + val groupId: String + ): ConversationSettingsDestination + + @Serializable + data class RouteManageMembers( + val groupId: String + ): ConversationSettingsDestination + + @Serializable + data class RouteInviteToGroup( + val groupId: String, + val excludingAccountIDs: List + ): ConversationSettingsDestination + + @Serializable + data object RouteDisappearingMessages: ConversationSettingsDestination + + @Serializable + data object RouteAllMedia: ConversationSettingsDestination + + @Serializable + data object RouteNotifications: ConversationSettingsDestination + + @Serializable + data class RouteInviteToCommunity( + val communityUrl: String + ): ConversationSettingsDestination +} + +@SuppressLint("RestrictedApi") +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun ConversationSettingsNavHost( + threadId: Long, + threadAddress: Address?, + navigator: UINavigator, + returnResult: (String, Boolean) -> Unit, + onBack: () -> Unit +){ + SharedTransitionLayout { + val navController = rememberNavController() + + ObserveAsEvents(flow = navigator.navigationActions) { action -> + when (action) { + is NavigationAction.Navigate -> navController.navigate( + action.destination + ) { + action.navOptions(this) + } + + NavigationAction.NavigateUp -> navController.navigateUp() + + is NavigationAction.NavigateToIntent -> { + navController.context.startActivity(action.intent) + } + + is NavigationAction.ReturnResult -> { + returnResult(action.code, action.value) + } + } + } + + NavHost(navController = navController, startDestination = RouteConversationSettings) { + // Conversation Settings + horizontalSlideComposable { + val viewModel = + hiltViewModel { factory -> + factory.create(threadId) + } + + val lifecycleOwner = LocalLifecycleOwner.current + + // capture the moment we resume the settings page + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.onResume() + } + } + + ConversationSettingsScreen( + viewModel = viewModel, + onBack = onBack, + ) + } + + // Group Members + horizontalSlideComposable { backStackEntry -> + val data: RouteGroupMembers = backStackEntry.toRoute() + + val viewModel = + hiltViewModel { factory -> + factory.create(AccountId(data.groupId)) + } + + GroupMembersScreen( + viewModel = viewModel, + onBack = dropUnlessResumed { + navController.popBackStack() + }, + ) + } + // Edit Group + horizontalSlideComposable { backStackEntry -> + val data: RouteManageMembers = backStackEntry.toRoute() + + val viewModel = + hiltViewModel { factory -> + factory.create(AccountId(data.groupId)) + } + + EditGroupScreen( + viewModel = viewModel, + navigateToInviteContact = { + navController.navigate( + RouteInviteToGroup( + groupId = data.groupId, + excludingAccountIDs = viewModel.excludingAccountIDsFromContactSelection.toList() + ) + ) + }, + onBack = dropUnlessResumed { + navController.popBackStack() + }, + ) + } + + // Invite Contacts to group + horizontalSlideComposable { backStackEntry -> + val data: RouteInviteToGroup = backStackEntry.toRoute() + + val viewModel = + hiltViewModel { factory -> + factory.create( + excludingAccountIDs = data.excludingAccountIDs.map(Address::fromSerialized).toSet() + ) + } + + // grab a hold of manage group's VM + val parentEntry = remember(backStackEntry) { + navController.getBackStackEntry( + RouteManageMembers(data.groupId) + ) + } + val editGroupViewModel: EditGroupViewModel = hiltViewModel(parentEntry) + + InviteContactsScreen( + viewModel = viewModel, + onDoneClicked = dropUnlessResumed { + //send invites from the manage group screen + editGroupViewModel.onContactSelected(viewModel.currentSelected) + + navController.popBackStack() + }, + onBack = dropUnlessResumed { + navController.popBackStack() + }, + banner = { + GroupMinimumVersionBanner() + } + ) + } + + // Invite Contacts to community + horizontalSlideComposable { backStackEntry -> + val viewModel = + hiltViewModel { factory -> + factory.create() + } + + // grab a hold of settings' VM + val parentEntry = remember(backStackEntry) { + navController.getBackStackEntry( + RouteConversationSettings + ) + } + val settingsViewModel: ConversationSettingsViewModel = hiltViewModel(parentEntry) + + InviteContactsScreen( + viewModel = viewModel, + onDoneClicked = { + //send invites from the settings screen + settingsViewModel.inviteContactsToCommunity(viewModel.currentSelected) + + // clear selected contacts + viewModel.clearSelection() + }, + onBack = dropUnlessResumed { + navController.popBackStack() + }, + ) + } + + // Disappearing Messages + horizontalSlideComposable { + val viewModel: DisappearingMessagesViewModel = + hiltViewModel { factory -> + factory.create( + threadId = threadId, + isNewConfigEnabled = ExpirationConfiguration.isNewConfigEnabled, + showDebugOptions = BuildConfig.BUILD_TYPE != "release" + ) + } + + DisappearingMessagesScreen( + viewModel = viewModel, + onBack = dropUnlessResumed { + navController.popBackStack() + }, + ) + } + + // All Media + horizontalSlideComposable { + if (threadAddress == null) { + navController.popBackStack() + return@horizontalSlideComposable + } + + val viewModel = + hiltViewModel { factory -> + factory.create(threadAddress) + } + + MediaOverviewScreen( + viewModel = viewModel, + onClose = dropUnlessResumed { + navController.popBackStack() + }, + ) + } + + // Notifications + horizontalSlideComposable { + val viewModel = + hiltViewModel { factory -> + factory.create(threadId) + } + + NotificationSettingsScreen( + viewModel = viewModel, + onBack = dropUnlessResumed { + navController.popBackStack() + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt new file mode 100644 index 0000000000..1ad337d596 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsScreen.kt @@ -0,0 +1,488 @@ +package org.thoughtcrime.securesms.conversation.v2.settings + +import android.annotation.SuppressLint +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.onLongClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import network.loki.messenger.R +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.* +import org.thoughtcrime.securesms.ui.AccountIdHeader +import org.thoughtcrime.securesms.ui.AvatarQrWidget +import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.Divider +import org.thoughtcrime.securesms.ui.ExpandableText +import org.thoughtcrime.securesms.ui.LargeItemButton +import org.thoughtcrime.securesms.ui.LoadingDialog +import org.thoughtcrime.securesms.ui.components.AnnotatedTextWithIcon +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.getCellBottomShape +import org.thoughtcrime.securesms.ui.getCellTopShape +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.safeContentWidth +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.bold +import org.thoughtcrime.securesms.ui.theme.dangerButtonColors +import org.thoughtcrime.securesms.ui.theme.monospace +import org.thoughtcrime.securesms.ui.theme.primaryBlue +import org.thoughtcrime.securesms.ui.theme.transparentButtonColors +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUIElement + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun ConversationSettingsScreen( + viewModel: ConversationSettingsViewModel, + onBack: () -> Unit, +) { + val data by viewModel.uiState.collectAsState() + val dialogsState by viewModel.dialogState.collectAsState() + + ConversationSettings( + data = data, + dialogsState = dialogsState, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@Composable +fun ConversationSettings( + data: ConversationSettingsViewModel.UIState, + dialogsState: ConversationSettingsViewModel.DialogsState, + sendCommand: (ConversationSettingsViewModel.Commands) -> Unit, + onBack: () -> Unit, +) { + Scaffold( + topBar = { + BackAppBar( + title = stringResource(id = R.string.sessionSettings), + onBack = onBack, + actions = { + if(data.editCommand != null) { + IconButton(onClick = { + sendCommand(data.editCommand) + }) { + Icon( + painter = painterResource(id = R.drawable.ic_pencil), + contentDescription = stringResource(id = R.string.edit) + ) + } + } + } + ) + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + ) { paddings -> + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddings) + .consumeWindowInsets(paddings) + .padding( + horizontal = LocalDimensions.current.spacing, + ) + .verticalScroll(rememberScrollState()), + horizontalAlignment = CenterHorizontally + ) { + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // Profile picture + AvatarQrWidget( + modifier = Modifier.qaTag(R.string.qa_conversation_settings_avatar), + showQR = data.showQR, + expandedAvatar = data.expandedAvatar, + showBadge = data.qrAddress != null, + avatarUIData = data.avatarUIData, + address = data.qrAddress ?: "", + toggleQR = { sendCommand(ToggleQR) }, + toggleAvatarExpand = { sendCommand(ToggleAvatarExpand) } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + + // name and edit icon + AnnotatedTextWithIcon( + modifier = Modifier + .fillMaxWidth() + .safeContentWidth() + .then( + // make the component clickable is there is an edit action + if (data.editCommand != null) Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { sendCommand(data.editCommand) } + ) + else Modifier + ), + text = data.name, + iconRes = if(data.showProBadge ) R.drawable.ic_pro_badge else null, + onIconClick = if(data.proBadgeClickable) {{ + sendCommand(ShowProBadgeCTA) + }} else null, + iconSize = 58.sp to 24.sp, + style = LocalType.current.h4, + ) + + // description or display name + if (!data.description.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) + ExpandableText( + modifier = Modifier + .fillMaxWidth() + .safeContentWidth() + .qaTag(data.descriptionQaTag), + text = data.description, + textStyle = LocalType.current.base, + textColor = LocalColors.current.textSecondary, + buttonTextStyle = LocalType.current.base.bold(), + buttonTextColor = LocalColors.current.textSecondary, + textAlign = TextAlign.Center, + ) + } + + // account Id header + if(!data.displayAccountIdHeader.isNullOrEmpty()){ + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + AccountIdHeader( + text = data.displayAccountIdHeader + ) + } + + // account ID + if (!data.displayAccountId.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + val haptics = LocalHapticFeedback.current + val longPressLabel = stringResource(R.string.accountIDCopy) + val onLongPress = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + sendCommand(CopyAccountId) + } + Text( + modifier = Modifier + .qaTag(R.string.qa_conversation_settings_account_id) + .safeContentWidth() + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { onLongPress() } + ) + } + .semantics { + onLongClick(label = longPressLabel) { + onLongPress() + true + } + }, + text = data.displayAccountId, + textAlign = TextAlign.Center, + style = LocalType.current.base.monospace(), + color = LocalColors.current.text + ) + } + + // settings options + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + data.categories.forEachIndexed { index, optionsCategory -> + ConversationSettingsCategory( + data = optionsCategory + ) + + // add spacing + when (index) { + data.categories.lastIndex -> Spacer( + modifier = Modifier.height( + LocalDimensions.current.spacing + ) + ) + + else -> Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + } + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + } + + // Dialogs + ConversationSettingsDialogs( + dialogsState = dialogsState, + sendCommand = sendCommand + ) + + // Loading + if (data.showLoading) { + LoadingDialog() + } + } +} +@Composable +fun ConversationSettingsCategory( + modifier: Modifier = Modifier, + data: ConversationSettingsViewModel.OptionsCategory, +) { + Column( + modifier = modifier.fillMaxWidth() + ) { + if (!data.name.isNullOrEmpty()) { + Text( + modifier = Modifier.padding( + start = LocalDimensions.current.smallSpacing, + bottom = LocalDimensions.current.smallSpacing + ), + text = data.name, + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + } + + data.items.forEachIndexed { index, items -> + ConversationSettingsSubCategory( + data = items + ) + + // add spacing, except on the last one + if (index < data.items.lastIndex) { + Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) + } + } + } +} + +@Composable +fun ConversationSettingsSubCategory( + modifier: Modifier = Modifier, + data: ConversationSettingsViewModel.OptionsSubCategory, +) { + Cell( + modifier = modifier.fillMaxWidth(), + ) { + Column { + data.items.forEachIndexed { index, option -> + LargeItemButton( + modifier = Modifier.qaTag(option.qaTag), + text = option.name, + subtitle = option.subtitle, + subtitleQaTag = option.subtitleQaTag, + enabled = option.enabled, + icon = option.icon, + shape = when (index) { + 0 -> getCellTopShape() + data.items.lastIndex -> getCellBottomShape() + else -> RectangleShape + }, + colors = if(data.danger) dangerButtonColors() + else transparentButtonColors(), + onClick = option.onClick, + ) + + if(index != data.items.lastIndex) Divider() + } + } + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@SuppressLint("UnusedContentLambdaTargetStateParameter") +@Preview +@Composable +private fun ConversationSettings1on1Preview() { + PreviewTheme { + ConversationSettings( + sendCommand = {}, + onBack = {}, + data = ConversationSettingsViewModel.UIState( + name = "Nickname", + editCommand = ShowGroupEditDialog, + description = "(Real name)", + displayAccountId = "05000000000000000000000000000000000000000000000000000000000000000", + displayAccountIdHeader = "You Account ID", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TO", + color = primaryBlue + ) + ) + ), + categories = listOf( + ConversationSettingsViewModel.OptionsCategory( + items = listOf( + ConversationSettingsViewModel.OptionsSubCategory( + items = listOf( + ConversationSettingsViewModel.OptionsItem( + name = "Search", + icon = R.drawable.ic_search, + onClick = {} + ), + ConversationSettingsViewModel.OptionsItem( + name = "Notifications", + subtitle = "All Messages", + icon = R.drawable.ic_volume_2, + onClick = {} + ) + ) + ) + ) + ), + ConversationSettingsViewModel.OptionsCategory( + name = "Admin", + items = listOf( + ConversationSettingsViewModel.OptionsSubCategory( + items = listOf( + ConversationSettingsViewModel.OptionsItem( + name = "Invite Contacts", + icon = R.drawable.ic_user_round_plus, + onClick = {} + ), + ConversationSettingsViewModel.OptionsItem( + name = "Manage members", + icon = R.drawable.ic_users_group_custom, + onClick = {} + ) + ) + ), + ConversationSettingsViewModel.OptionsSubCategory( + danger = true, + items = listOf( + ConversationSettingsViewModel.OptionsItem( + name = "Clear Messages", + icon = R.drawable.ic_message_square, + onClick = {} + ), + ConversationSettingsViewModel.OptionsItem( + name = "Delete Group", + icon = R.drawable.ic_trash_2, + onClick = {} + ) + ) + ) + ) + ) + ), + ), + dialogsState = ConversationSettingsViewModel.DialogsState() + ) + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@SuppressLint("UnusedContentLambdaTargetStateParameter") +@Preview(locale = "en") +@Preview(locale = "ar") +@Composable +private fun ConversationSettings1on1LongNamePreview() { + PreviewTheme { + ConversationSettings( + sendCommand = {}, + onBack = {}, + data = ConversationSettingsViewModel.UIState( + name = "Nickname that is very long but the text shouldn't be cut off because there is no limit to the display here so it should show the whole thing", + editCommand = ShowGroupEditDialog, + description = "This is a long description with a lot of text that should be more than 2 lines and should be truncated but you never know, it depends on size and such things dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk lkasdjfalsdkfjasdklfj lsadkfjalsdkfjsadklf lksdjfalsdkfjasdlkfjasdlkf asldkfjasdlkfja and this is the end", + displayAccountId = "05000000000000000000000000000000000000000000000000000000000000000", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TO", + color = primaryBlue + ) + ) + ), + categories = listOf( + ConversationSettingsViewModel.OptionsCategory( + items = listOf( + ConversationSettingsViewModel.OptionsSubCategory( + items = listOf( + ConversationSettingsViewModel.OptionsItem( + name = "Search", + icon = R.drawable.ic_search, + onClick = {} + ), + ConversationSettingsViewModel.OptionsItem( + name = "Notifications", + subtitle = "All Messages", + icon = R.drawable.ic_volume_2, + onClick = {} + ) + ) + ) + ) + ), + ConversationSettingsViewModel.OptionsCategory( + name = "Admin", + items = listOf( + ConversationSettingsViewModel.OptionsSubCategory( + items = listOf( + ConversationSettingsViewModel.OptionsItem( + name = "Invite Contacts", + icon = R.drawable.ic_user_round_plus, + onClick = {} + ), + ConversationSettingsViewModel.OptionsItem( + name = "Manage members", + icon = R.drawable.ic_users_group_custom, + onClick = {} + ) + ) + ), + ConversationSettingsViewModel.OptionsSubCategory( + items = listOf( + ConversationSettingsViewModel.OptionsItem( + name = "Clear Messages", + icon = R.drawable.ic_message_square, + onClick = {} + ), + ConversationSettingsViewModel.OptionsItem( + name = "Delete Group", + icon = R.drawable.ic_trash_2, + onClick = {} + ) + ) + ) + ) + ) + ), + ), + dialogsState = ConversationSettingsViewModel.DialogsState() + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt new file mode 100644 index 0000000000..52c5721aca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -0,0 +1,1586 @@ +package org.thoughtcrime.securesms.conversation.v2.settings + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.icu.text.BreakIterator +import android.widget.Toast +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity.CLIPBOARD_SERVICE +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.cash.copper.flow.observeQuery +import com.bumptech.glide.Glide +import com.squareup.phrase.Phrase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import network.loki.messenger.R +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.libsession_util.util.GroupInfo +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.messaging.open_groups.OpenGroup +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.ConfigUpdateNotification +import org.session.libsession.utilities.ExpirationUtil +import org.session.libsession.utilities.StringSubstitutionConstants.COMMUNITY_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.getGroup +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.textSizeInBytes +import org.thoughtcrime.securesms.database.DatabaseContentProviders +import org.thoughtcrime.securesms.database.LokiThreadDatabase +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.dependencies.ConfigFactory.Companion.MAX_GROUP_DESCRIPTION_BYTES +import org.thoughtcrime.securesms.dependencies.ConfigFactory.Companion.MAX_NAME_BYTES +import org.thoughtcrime.securesms.groups.OpenGroupManager +import org.thoughtcrime.securesms.home.HomeActivity +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.repository.ConversationRepository +import org.thoughtcrime.securesms.ui.SimpleDialogData +import org.thoughtcrime.securesms.ui.UINavigator +import org.thoughtcrime.securesms.ui.getSubbedString +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUtils +import org.thoughtcrime.securesms.util.avatarOptions +import org.thoughtcrime.securesms.util.observeChanges +import kotlin.math.min + + +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) +@HiltViewModel(assistedFactory = ConversationSettingsViewModel.Factory::class) +class ConversationSettingsViewModel @AssistedInject constructor( + @Assisted private val threadId: Long, + @ApplicationContext private val context: Context, + private val avatarUtils: AvatarUtils, + private val repository: ConversationRepository, + private val configFactory: ConfigFactoryProtocol, + private val storage: StorageProtocol, + private val conversationRepository: ConversationRepository, + private val textSecurePreferences: TextSecurePreferences, + private val navigator: UINavigator, + private val threadDb: ThreadDatabase, + private val groupManagerV2: GroupManagerV2, + private val prefs: TextSecurePreferences, + private val lokiThreadDatabase: LokiThreadDatabase, + private val groupManager: GroupManagerV2, + private val openGroupManager: OpenGroupManager, + private val proStatusManager: ProStatusManager, +) : ViewModel() { + + private val _uiState: MutableStateFlow = MutableStateFlow( + UIState( + avatarUIData = AvatarUIData(emptyList()) + ) + ) + val uiState: StateFlow = _uiState + + private val _dialogState: MutableStateFlow = MutableStateFlow(DialogsState()) + val dialogState: StateFlow = _dialogState + + private var recipient: Recipient? = null + + private var groupV2: GroupInfo.ClosedGroupInfo? = null + + private val community: OpenGroup? by lazy { + storage.getOpenGroup(threadId) + } + + private val optionCopyAccountId: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.accountIDCopy), + icon = R.drawable.ic_copy, + qaTag = R.string.qa_conversation_settings_copy_account, + onClick = ::copyAccountId + ) + } + + private val optionSearch: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.searchConversation), + icon = R.drawable.ic_search, + qaTag = R.string.qa_conversation_settings_search, + onClick = ::goBackToSearch + ) + } + + + private fun optionDisappearingMessage(subtitle: String?): OptionsItem { + return OptionsItem( + name = context.getString(R.string.disappearingMessages), + subtitle = subtitle, + icon = R.drawable.ic_timer, + qaTag = R.string.qa_conversation_settings_disappearing, + subtitleQaTag = R.string.qa_conversation_settings_disappearing_sub, + onClick = { + navigateTo(ConversationSettingsDestination.RouteDisappearingMessages) + } + ) + } + + private val optionPin: OptionsItem by lazy { + OptionsItem( + name = context.getString(R.string.pinConversation), + icon = R.drawable.ic_pin, + qaTag = R.string.qa_conversation_settings_pin, + onClick = ::pinConversation + ) + } + + private val optionUnpin: OptionsItem by lazy { + OptionsItem( + name = context.getString(R.string.pinUnpinConversation), + icon = R.drawable.ic_pin_off, + qaTag = R.string.qa_conversation_settings_pin, + onClick = ::unpinConversation + ) + } + + private fun optionNotifications(iconRes: Int, subtitle: String?): OptionsItem { + return OptionsItem( + name = context.getString(R.string.sessionNotifications), + subtitle = subtitle, + icon = iconRes, + qaTag = R.string.qa_conversation_settings_notifications, + subtitleQaTag = R.string.qa_conversation_settings_notifications_sub, + onClick = { + navigateTo(ConversationSettingsDestination.RouteNotifications) + } + ) + } + + private val optionAttachments: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.attachments), + icon = R.drawable.ic_file, + qaTag = R.string.qa_conversation_settings_attachments, + onClick = { + navigateTo(ConversationSettingsDestination.RouteAllMedia) + } + ) + } + + private val optionBlock: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.block), + icon = R.drawable.ic_user_round_x, + qaTag = R.string.qa_conversation_settings_block, + onClick = ::confirmBlockUser + ) + } + + private val optionUnblock: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.blockUnblock), + icon = R.drawable.ic_user_round_tick, + qaTag = R.string.qa_conversation_settings_block, + onClick = ::confirmUnblockUser + ) + } + + private val optionClearMessages: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.clearMessages), + icon = R.drawable.ic_message_trash_custom, + qaTag = R.string.qa_conversation_settings_clear_messages, + onClick = ::confirmClearMessages + ) + } + + private val optionDeleteConversation: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.conversationsDelete), + icon = R.drawable.ic_trash_2, + qaTag = R.string.qa_conversation_settings_delete_conversation, + onClick = ::confirmDeleteConversation + ) + } + + private val optionDeleteContact: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.contactDelete), + icon = R.drawable.ic_user_round_trash, + qaTag = R.string.qa_conversation_settings_delete_contact, + onClick = ::confirmDeleteContact + ) + } + + private val optionHideNTS: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.noteToSelfHide), + icon = R.drawable.ic_eye_off, + qaTag = R.string.qa_conversation_settings_hide_nts, + onClick = ::confirmHideNTS + ) + } + + private val optionShowNTS: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.showNoteToSelf), + icon = R.drawable.ic_eye, + qaTag = R.string.qa_conversation_settings_hide_nts, + onClick = ::confirmShowNTS + ) + } + + // Groups + private val optionGroupMembers: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.groupMembers), + icon = R.drawable.ic_users_round, + qaTag = R.string.qa_conversation_settings_group_members, + onClick = { + navigateTo(ConversationSettingsDestination.RouteGroupMembers( + groupId = groupV2?.groupAccountId ?: "") + ) + } + ) + } + + private val optionInviteMembers: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.membersInvite), + icon = R.drawable.ic_user_round_plus, + qaTag = R.string.qa_conversation_settings_invite_contacts, + onClick = { + navigateTo(ConversationSettingsDestination.RouteInviteToCommunity( + communityUrl = community?.joinURL ?: "" + )) + } + ) + } + + private val optionManageMembers: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.manageMembers), + icon = R.drawable.ic_user_round_pen, + qaTag = R.string.qa_conversation_settings_manage_members, + onClick = { + navigateTo(ConversationSettingsDestination.RouteManageMembers( + groupId = groupV2?.groupAccountId ?: "") + ) + } + ) + } + + private val optionLeaveGroup: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.groupLeave), + icon = R.drawable.ic_log_out, + qaTag = R.string.qa_conversation_settings_leave_group, + onClick = ::confirmLeaveGroup + ) + } + + private val optionDeleteGroup: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.groupDelete), + icon = R.drawable.ic_trash_2, + qaTag = R.string.qa_conversation_settings_delete_group, + onClick = ::confirmLeaveGroup + ) + } + + // Community + private val optionCopyCommunityURL: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.communityUrlCopy), + icon = R.drawable.ic_copy, + qaTag = R.string.qa_conversation_settings_copy_community_url, + onClick = ::copyCommunityUrl + ) + } + + private val optionLeaveCommunity: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.communityLeave), + icon = R.drawable.ic_log_out, + qaTag = R.string.qa_conversation_settings_leave_community, + onClick = ::confirmLeaveCommunity + ) + } + + init { + // update data when we have a recipient and update when there are changes from the thread or recipient + viewModelScope.launch(Dispatchers.Default) { + repository.recipientUpdateFlow(threadId) // get the recipient + .flatMapLatest { recipient -> // get updates from the thread or recipient + merge( + context.contentResolver + .observeQuery(DatabaseContentProviders.Recipient.CONTENT_URI), // recipient updates + (context.contentResolver.observeChanges( + DatabaseContentProviders.Conversation.getUriForThread(threadId) + ) as Flow<*>), // thread updates + configFactory.configUpdateNotifications.filterIsInstance() + .filter { it.groupId.hexString == recipient?.address?.toString() } + ).map { + recipient // return the recipient + } + .debounce(200L) + .onStart { emit(recipient) } // make sure there's a value straight away + } + .collect { + recipient = it + getStateFromRecipient() + } + } + } + + fun onResume(){ + // check the mute timing in case it has changed when coming back to the screen + val conversation = recipient ?: return + + // Check if notification item exists first + val hasNotificationItem = _uiState.value.categories + .flatMap { it.items } + .flatMap { it.items } + .firstOrNull { it.qaTag == R.string.qa_conversation_settings_notifications } + + // no need to do anything if the state doesn't have any notification item + if (hasNotificationItem == null) return + + // get the new values + val (notificationIconRes, notificationSubtitle) = getNotificationsData(conversation) + + // if they are the same as what we already have, no need to go further + if (notificationIconRes == hasNotificationItem.icon && + notificationSubtitle == hasNotificationItem.subtitle) return + + // otherwise update the text + _uiState.update { currentState -> + // Update the item + currentState.copy( + categories = currentState.categories.map { category -> + category.copy( + items = category.items.map { subCategory -> + subCategory.copy( + items = subCategory.items.map { item -> + if (item.qaTag == R.string.qa_conversation_settings_notifications) { + item.copy(subtitle = notificationSubtitle, icon = notificationIconRes) + } else item + } + ) + } + ) + } + ) + } + } + + private suspend fun getStateFromRecipient(){ + val conversation = recipient ?: return + val configContact = configFactory.withUserConfigs { configs -> + configs.contacts.get(conversation.address.toString()) + } + + groupV2 = if(conversation.isGroupV2Recipient) configFactory.getGroup(AccountId(conversation.address.toString())) + else null + + // admin + val isAdmin: Boolean = when { + // for Groups V2 + conversation.isGroupV2Recipient -> groupV2?.hasAdminKey() == true + + // for communities the the `isUserModerator` field + conversation.isCommunityRecipient -> isCommunityAdmin() + + // false in other cases + else -> false + } + + // edit name - Can edit name for 1on1, or if admin of a groupV2 + val editCommand = when { + conversation.is1on1 -> Commands.ShowNicknameDialog + conversation.isGroupV2Recipient && isAdmin -> Commands.ShowGroupEditDialog + else -> null + } + + // description / display name with QA tags + val (description: String?, descriptionQaTag: String?) = when{ + // for 1on1, if the user has a nickname it should be displayed as the + // main name, and the description should show the real name in parentheses + conversation.is1on1 -> { + if(configContact?.nickname?.isNotEmpty() == true && configContact.name.isNotEmpty()) { + ( + "(${configContact.name})" to // description + context.getString(R.string.qa_conversation_settings_description_1on1) // description qa tag + ) + } else (null to null) + } + + conversation.isGroupV2Recipient -> { + if(groupV2 == null) (null to null) + else { + ( + configFactory.withGroupConfigs(AccountId(groupV2!!.groupAccountId)){ + it.groupInfo.getDescription() + } to // description + context.getString(R.string.qa_conversation_settings_description_groups) // description qa tag + ) + } + } + + conversation.isCommunityRecipient -> { + ( + community?.description to // description + context.getString(R.string.qa_conversation_settings_description_community) // description qa tag + ) + } + + else -> (null to null) + } + + // name + val name = when { + conversation.isLocalNumber -> context.getString(R.string.noteToSelf) + + conversation.isGroupV2Recipient -> getGroupName() + + else -> conversation.name + } + + // account ID + val (accountId, accountIdHeader) = when{ + conversation.is1on1 -> conversation.address.toString() to context.getString(R.string.accountId) + conversation.isLocalNumber -> conversation.address.toString() to context.getString(R.string.accountIdYours) + else -> null to null + } + + // QR Account ID + val qrAddress = when { + conversation.is1on1 -> conversation.address.toString() + conversation.isCommunityRecipient -> community?.joinURL + else -> null + } + + // disappearing message type + val expiration = storage.getExpirationConfiguration(threadId) + val disappearingSubtitle = if(expiration?.isEnabled == true) { + // Get the type of disappearing message and the abbreviated duration.. + val dmTypeString = when (expiration.expiryMode) { + is ExpiryMode.AfterRead -> R.string.disappearingMessagesDisappearAfterReadState + else -> R.string.disappearingMessagesDisappearAfterSendState + } + val durationAbbreviated = + ExpirationUtil.getExpirationAbbreviatedDisplayValue(expiration.expiryMode.expirySeconds) + + // ..then substitute into the string.. + context.getSubbedString( + dmTypeString, + TIME_KEY to durationAbbreviated + ) + } else context.getString(R.string.off) + + val pinned = threadDb.isPinned(threadId) + + val (notificationIconRes, notificationSubtitle) = getNotificationsData(conversation) + + // organise the setting options + val optionData = options@when { + conversation.isLocalNumber -> { + val mainOptions = mutableListOf() + val dangerOptions = mutableListOf() + + val ntsHidden = prefs.hasHiddenNoteToSelf() + + mainOptions.addAll(listOf( + optionCopyAccountId, + optionSearch, + optionDisappearingMessage(disappearingSubtitle), + if(pinned) optionUnpin else optionPin, + optionAttachments, + )) + + if(ntsHidden) mainOptions.add(optionShowNTS) + else dangerOptions.add(optionHideNTS) + + dangerOptions.addAll(listOf( + optionClearMessages, + )) + + listOf( + OptionsCategory( + items = listOf( + OptionsSubCategory(items = mainOptions), + OptionsSubCategory( + danger = true, + items = dangerOptions + ) + ) + ) + ) + } + + conversation.is1on1 -> { + val mainOptions = mutableListOf() + val dangerOptions = mutableListOf() + + mainOptions.addAll(listOf( + optionCopyAccountId, + optionSearch + )) + + // these options are only for users who aren't blocked + if(!conversation.isBlocked) { + mainOptions.addAll(listOf( + optionDisappearingMessage(disappearingSubtitle), + if(pinned) optionUnpin else optionPin, + optionNotifications(notificationIconRes, notificationSubtitle), + )) + } + + // finally add attachments + mainOptions.add(optionAttachments) + + dangerOptions.addAll(listOf( + if(recipient?.isBlocked == true) optionUnblock else optionBlock, + optionClearMessages, + optionDeleteConversation, + optionDeleteContact + )) + + listOf( + OptionsCategory( + items = listOf( + OptionsSubCategory(items = mainOptions), + OptionsSubCategory( + danger = true, + items = dangerOptions + ) + ) + ) + ) + } + + conversation.isGroupV2Recipient -> { + // if the user is kicked or the group destroyed, only show "Delete Group" + if(groupV2 != null && groupV2?.shouldPoll == false){ + listOf( + OptionsCategory( + items = listOf( + OptionsSubCategory( + danger = true, + items = listOf(optionDeleteGroup) + ) + ) + ) + ) + } else { + val mainOptions = mutableListOf() + val adminOptions = mutableListOf() + val dangerOptions = mutableListOf() + + mainOptions.add(optionSearch) + + // for non admins, disappearing messages is in the non admin section + if (!isAdmin) { + mainOptions.add(optionDisappearingMessage(disappearingSubtitle)) + } + + mainOptions.addAll( + listOf( + if (pinned) optionUnpin else optionPin, + optionNotifications(notificationIconRes, notificationSubtitle), + optionGroupMembers, + optionAttachments, + ) + ) + + // apply different options depending on admin status + if (isAdmin) { + dangerOptions.addAll( + listOf( + optionClearMessages, + optionDeleteGroup + ) + ) + + // admin options + adminOptions.addAll( + listOf( + optionManageMembers, + optionDisappearingMessage(disappearingSubtitle) + ) + ) + + // the returned options for group admins + listOf( + OptionsCategory( + items = listOf( + OptionsSubCategory(items = mainOptions), + ) + ), + OptionsCategory( + name = context.getString(R.string.adminSettings), + items = listOf( + OptionsSubCategory(items = adminOptions), + OptionsSubCategory( + danger = true, + items = dangerOptions + ) + ) + ) + ) + } else { + dangerOptions.addAll( + listOf( + optionClearMessages, + optionLeaveGroup + ) + ) + + // the returned options for group non-admins + listOf( + OptionsCategory( + items = listOf( + OptionsSubCategory(items = mainOptions), + OptionsSubCategory( + danger = true, + items = dangerOptions + ) + ) + ) + ) + } + } + } + + conversation.isCommunityRecipient -> { + val mainOptions = mutableListOf() + val dangerOptions = mutableListOf() + + mainOptions.addAll(listOf( + optionCopyCommunityURL, + optionSearch, + if(pinned) optionUnpin else optionPin, + optionNotifications(notificationIconRes, notificationSubtitle), + optionInviteMembers, + optionAttachments, + )) + + dangerOptions.addAll(listOf( + optionClearMessages, + optionLeaveCommunity + )) + + listOf( + OptionsCategory( + items = listOf( + OptionsSubCategory(items = mainOptions), + OptionsSubCategory( + danger = true, + items = dangerOptions + ) + ) + ) + ) + } + + else -> emptyList() + } + + val showProBadge = proStatusManager.shouldShowProBadge(conversation.address) + && !conversation.isLocalNumber + + // if it's a one on one convo and the user isn't pro themselves + val proBadgeClickable = if(conversation.is1on1 && proStatusManager.isCurrentUserPro()) false + else showProBadge // otherwise whenever the badge is shown + + val avatarData = avatarUtils.getUIDataFromRecipient(conversation) + _uiState.update { + _uiState.value.copy( + name = name, + nameQaTag = when { + conversation.isLocalNumber -> context.getString(R.string.qa_conversation_settings_display_name_nts) + conversation.is1on1 -> context.getString(R.string.qa_conversation_settings_display_name_1on1) + conversation.isGroupV2Recipient -> context.getString(R.string.qa_conversation_settings_display_name_groups) + conversation.isCommunityRecipient -> context.getString(R.string.qa_conversation_settings_display_name_community) + else -> null + }, + editCommand = editCommand, + description = description, + descriptionQaTag = descriptionQaTag, + displayAccountId = accountId, + avatarUIData = avatarData, + categories = optionData, + showProBadge = showProBadge, + proBadgeClickable = proBadgeClickable, + displayAccountIdHeader = accountIdHeader, + qrAddress = qrAddress, + ) + } + + // also preload the larger version of the avatar in case the user goes to the fullscreen avatar + avatarData.elements.mapNotNull { it.contactPhoto }.forEach { + val loadSize = min(context.resources.displayMetrics.widthPixels, context.resources.displayMetrics.heightPixels) + Glide.with(context).load(it) + .avatarOptions( + sizePx = loadSize, + freezeFrame = proStatusManager.freezeFrameForUser(recipient?.address) + ) + .preload(loadSize, loadSize) + } + } + + private fun getNotificationsData(conversation: Recipient): Pair { + return when{ + conversation.isMuted -> R.drawable.ic_volume_off to context.getString(R.string.notificationsMuted) + conversation.notifyType == RecipientDatabase.NOTIFY_TYPE_MENTIONS -> + R.drawable.ic_at_sign to context.getString(R.string.notificationsMentionsOnly) + else -> R.drawable.ic_volume_2 to context.getString(R.string.notificationsAllMessages) + } + } + + private fun copyAccountId(){ + val accountID = recipient?.address?.toString() ?: "" + val clip = ClipData.newPlainText("Account ID", accountID) + val manager = context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + manager.setPrimaryClip(clip) + Toast.makeText(context, R.string.copied, Toast.LENGTH_SHORT).show() + } + + private fun copyCommunityUrl(){ + val url = community?.joinURL ?: return + val clip = ClipData.newPlainText(context.getString(R.string.communityUrl), url) + val manager = context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + manager.setPrimaryClip(clip) + Toast.makeText(context, R.string.copied, Toast.LENGTH_SHORT).show() + } + + private fun isCommunityAdmin(): Boolean { + if(community == null) return false + else{ + val userPublicKey = textSecurePreferences.getLocalNumber() ?: return false + val keyPair = storage.getUserED25519KeyPair() ?: return false + val blindedPublicKey = community!!.publicKey.let { + BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = keyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(it), + )?.pubKey?.data } + ?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString + return openGroupManager.isUserModerator(community!!.id, userPublicKey, blindedPublicKey) + } + } + + private fun pinConversation(){ + // check the pin limit before continuing + val totalPins = storage.getTotalPinned() + val maxPins = proStatusManager.getPinnedConversationLimit() + if(totalPins >= maxPins){ + // the user has reached the pin limit, show the CTA + _dialogState.update { + it.copy(pinCTA = PinProCTA(overTheLimit = totalPins > maxPins)) + } + } else { + viewModelScope.launch { + storage.setPinned(threadId, true) + } + } + } + + private fun unpinConversation(){ + viewModelScope.launch { + storage.setPinned(threadId, false) + } + } + + private fun confirmBlockUser(){ + _dialogState.update { + it.copy( + showSimpleDialog = SimpleDialogData( + title = context.getString(R.string.block), + message = Phrase.from(context, R.string.blockDescription) + .put(NAME_KEY, recipient?.name ?: "") + .format(), + positiveText = context.getString(R.string.block), + negativeText = context.getString(R.string.cancel), + positiveQaTag = context.getString(R.string.qa_conversation_settings_dialog_block_confirm), + negativeQaTag = context.getString(R.string.qa_conversation_settings_dialog_block_cancel), + onPositive = ::blockUser, + onNegative = {} + ) + ) + } + } + + private fun confirmUnblockUser(){ + _dialogState.update { + it.copy( + showSimpleDialog = SimpleDialogData( + title = context.getString(R.string.blockUnblock), + message = Phrase.from(context, R.string.blockUnblockName) + .put(NAME_KEY, recipient?.name ?: "") + .format(), + positiveText = context.getString(R.string.blockUnblock), + negativeText = context.getString(R.string.cancel), + positiveQaTag = context.getString(R.string.qa_conversation_settings_dialog_unblock_confirm), + negativeQaTag = context.getString(R.string.qa_conversation_settings_dialog_unblock_cancel), + onPositive = ::unblockUser, + onNegative = {} + ) + ) + } + } + + private fun blockUser() { + val conversation = recipient ?: return + viewModelScope.launch { + if (conversation.isContactRecipient || conversation.isGroupV2Recipient) { + repository.setBlocked(conversation, true) + } + + if (conversation.isGroupV2Recipient) { + groupManagerV2.onBlocked(AccountId(conversation.address.toString())) + } + } + } + + private fun unblockUser() { + if(recipient == null) return + viewModelScope.launch { + repository.setBlocked(recipient!!, false) + } + } + + private fun confirmHideNTS(){ + _dialogState.update { + it.copy( + showSimpleDialog = SimpleDialogData( + title = context.getString(R.string.noteToSelfHide), + message = context.getText(R.string.hideNoteToSelfDescription), + positiveText = context.getString(R.string.hide), + negativeText = context.getString(R.string.cancel), + positiveQaTag = context.getString(R.string.qa_conversation_settings_dialog_hide_nts_confirm), + negativeQaTag = context.getString(R.string.qa_conversation_settings_dialog_hide_nts_cancel), + onPositive = ::hideNoteToSelf, + onNegative = {} + ) + ) + } + } + + private fun confirmShowNTS(){ + _dialogState.update { + it.copy( + showSimpleDialog = SimpleDialogData( + title = context.getString(R.string.showNoteToSelf), + message = context.getText(R.string.showNoteToSelfDescription), + positiveText = context.getString(R.string.show), + negativeText = context.getString(R.string.cancel), + positiveQaTag = context.getString(R.string.qa_conversation_settings_dialog_show_nts_confirm), + negativeQaTag = context.getString(R.string.qa_conversation_settings_dialog_show_nts_cancel), + positiveStyleDanger = false, + onPositive = ::showNoteToSelf, + onNegative = {} + ) + ) + } + } + + private fun hideNoteToSelf() { + prefs.setHasHiddenNoteToSelf(true) + configFactory.withMutableUserConfigs { + it.userProfile.setNtsPriority(PRIORITY_HIDDEN) + } + // update state to reflect the change + viewModelScope.launch { + getStateFromRecipient() + } + } + + fun showNoteToSelf() { + prefs.setHasHiddenNoteToSelf(false) + configFactory.withMutableUserConfigs { + it.userProfile.setNtsPriority(PRIORITY_VISIBLE) + } + // update state to reflect the change + viewModelScope.launch { + getStateFromRecipient() + } + } + + private fun confirmDeleteContact(){ + _dialogState.update { + it.copy( + showSimpleDialog = SimpleDialogData( + title = context.getString(R.string.contactDelete), + message = Phrase.from(context, R.string.deleteContactDescription) + .put(NAME_KEY, recipient?.name ?: "") + .put(NAME_KEY, recipient?.name ?: "") + .format(), + positiveText = context.getString(R.string.delete), + negativeText = context.getString(R.string.cancel), + positiveQaTag = context.getString(R.string.qa_conversation_settings_dialog_delete_contact_confirm), + negativeQaTag = context.getString(R.string.qa_conversation_settings_dialog_delete_contact_cancel), + onPositive = ::deleteContact, + onNegative = {} + ) + ) + } + } + + private fun deleteContact() { + val conversation = recipient ?: return + viewModelScope.launch { + showLoading() + withContext(Dispatchers.Default) { + storage.deleteContactAndSyncConfig(conversation.address.toString()) + } + + hideLoading() + goBackHome() + } + } + + private fun confirmDeleteConversation(){ + _dialogState.update { + it.copy( + showSimpleDialog = SimpleDialogData( + title = context.getString(R.string.conversationsDelete), + message = Phrase.from(context, R.string.deleteConversationDescription) + .put(NAME_KEY, recipient?.name ?: "") + .format(), + positiveText = context.getString(R.string.delete), + negativeText = context.getString(R.string.cancel), + positiveQaTag = context.getString(R.string.qa_conversation_settings_dialog_delete_conversation_confirm), + negativeQaTag = context.getString(R.string.qa_conversation_settings_dialog_delete_conversation_cancel), + onPositive = ::deleteConversation, + onNegative = {} + ) + ) + } + } + + private fun deleteConversation() { + viewModelScope.launch { + showLoading() + withContext(Dispatchers.Default) { + storage.deleteConversation(threadId) + } + + hideLoading() + goBackHome() + } + } + + private fun confirmLeaveCommunity(){ + _dialogState.update { + it.copy( + showSimpleDialog = SimpleDialogData( + title = context.getString(R.string.communityLeave), + message = Phrase.from(context, R.string.groupLeaveDescription) + .put(GROUP_NAME_KEY, recipient?.name ?: "") + .format(), + positiveText = context.getString(R.string.leave), + negativeText = context.getString(R.string.cancel), + positiveQaTag = context.getString(R.string.qa_conversation_settings_dialog_leave_community_confirm), + negativeQaTag = context.getString(R.string.qa_conversation_settings_dialog_leave_community_cancel), + onPositive = ::leaveCommunity, + onNegative = {} + ) + ) + } + } + + private fun leaveCommunity() { + viewModelScope.launch { + showLoading() + withContext(Dispatchers.Default) { + val community = lokiThreadDatabase.getOpenGroupChat(threadId) + if (community != null) { + openGroupManager.delete(community.server, community.room, context) + } + } + + hideLoading() + goBackHome() + } + } + + private fun confirmClearMessages(){ + val conversation = recipient ?: return + + // default to 1on1 + var message: CharSequence = Phrase.from(context, R.string.clearMessagesChatDescriptionUpdated) + .put(NAME_KEY,conversation.name) + .format() + + when{ + conversation.isGroupV2Recipient -> { + if(groupV2?.hasAdminKey() == true){ + // group admin clearing messages have a dedicated custom dialog + _dialogState.update { it.copy(groupAdminClearMessagesDialog = GroupAdminClearMessageDialog(getGroupName())) } + return + + } else { + message = Phrase.from(context, R.string.clearMessagesGroupDescriptionUpdated) + .put(GROUP_NAME_KEY, getGroupName()) + .format() + } + } + + conversation.isCommunityRecipient -> { + message = Phrase.from(context, R.string.clearMessagesCommunityUpdated) + .put(COMMUNITY_NAME_KEY, conversation.name) + .format() + } + + conversation.isLocalNumber -> { + message = context.getText(R.string.clearMessagesNoteToSelfDescriptionUpdated) + } + } + + _dialogState.update { + it.copy( + showSimpleDialog = SimpleDialogData( + title = context.getString(R.string.clearMessages), + message = message, + positiveText = context.getString(R.string.clear), + negativeText = context.getString(R.string.cancel), + positiveQaTag = context.getString(R.string.qa_conversation_settings_dialog_clear_messages_confirm), + negativeQaTag = context.getString(R.string.qa_conversation_settings_dialog_clear_messages_cancel), + onPositive = { clearMessages(false) }, + onNegative = {} + ) + ) + } + } + + private fun clearMessages(clearForEveryoneGroupsV2: Boolean) { + viewModelScope.launch { + showLoading() + try { + withContext(Dispatchers.Default) { + conversationRepository.clearAllMessages( + threadId, + if (clearForEveryoneGroupsV2 && groupV2 != null) AccountId(groupV2!!.groupAccountId) else null + ) + } + + Toast.makeText(context, context.resources.getQuantityString( + R.plurals.deleteMessageDeleted, + 2, // as per the ACs, we decided to always show this message as plural + 2 + ), Toast.LENGTH_LONG).show() + } catch (e: Exception){ + Toast.makeText(context, context.resources.getQuantityString( + R.plurals.deleteMessageFailed, + 2, // we don't care about the number, just that it is multiple messages since we are doing "Clear All" + 2 + ), Toast.LENGTH_LONG).show() + } + + hideLoading() + } + } + + + private fun getGroupName(): String { + val conversation = recipient ?: return "" + val accountId = AccountId(conversation.address.toString()) + return configFactory.withGroupConfigs(accountId) { + it.groupInfo.getName() + } ?: groupV2?.name ?: "" + } + + private fun confirmLeaveGroup(){ + val groupData = groupV2 ?: return + _dialogState.update { state -> + val dialogData = groupManager.getLeaveGroupConfirmationDialogData( + AccountId(groupData.groupAccountId), + _uiState.value.name + ) ?: return + + state.copy( + showSimpleDialog = SimpleDialogData( + title = dialogData.title, + message = dialogData.message, + positiveText = context.getString(dialogData.positiveText), + negativeText = context.getString(dialogData.negativeText), + positiveQaTag = dialogData.positiveQaTag?.let{ context.getString(it) }, + negativeQaTag = dialogData.negativeQaTag?.let{ context.getString(it) }, + onPositive = ::leaveGroup, + onNegative = {} + ) + ) + } + } + + private fun leaveGroup() { + val conversation = recipient ?: return + viewModelScope.launch { + showLoading() + + try { + withContext(Dispatchers.Default) { + groupManagerV2.leaveGroup(AccountId(conversation.address.toString())) + } + hideLoading() + goBackHome() + } catch (e: Exception){ + hideLoading() + + val txt = Phrase.from(context, R.string.groupLeaveErrorFailed) + .put(GROUP_NAME_KEY, getGroupName()) + .format().toString() + Toast.makeText(context, txt, Toast.LENGTH_LONG).show() + } + } + } + + private suspend fun goBackHome(){ + navigator.navigateToIntent( + Intent(context, HomeActivity::class.java).apply { + // pop back to home activity + addFlags( + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + ) + } + ) + } + + private fun goBackToSearch(){ + viewModelScope.launch { + navigator.returnResult(ConversationActivityV2.SHOW_SEARCH, true) + } + } + + /** + * This returns the number of visible glyphs in a string, instead of its underlying length + * For example: 👨🏻‍❤️‍💋‍👨🏻 has a length of 15 as a string, but would return 1 here as it is only one visible element + */ + private fun getDisplayedCharacterSize(text: String): Int { + val iterator = BreakIterator.getCharacterInstance() + iterator.setText(text) + var count = 0 + while (iterator.next() != BreakIterator.DONE) { + count++ + } + return count + } + + fun onCommand(command: Commands) { + when (command) { + is Commands.CopyAccountId -> copyAccountId() + + is Commands.HideSimpleDialog -> _dialogState.update { + it.copy(showSimpleDialog = null) + } + + is Commands.HideGroupAdminClearMessagesDialog -> _dialogState.update { + it.copy(groupAdminClearMessagesDialog = null) + } + + is Commands.ClearMessagesGroupDeviceOnly -> clearMessages(false) + is Commands.ClearMessagesGroupEveryone -> clearMessages(true) + + is Commands.HideNicknameDialog -> hideNicknameDialog() + + is Commands.ShowNicknameDialog -> showNicknameDialog() + + is Commands.ShowGroupEditDialog -> showGroupEditDialog() + + is Commands.HideGroupEditDialog -> hideGroupEditDialog() + + is Commands.HidePinCTADialog -> { + _dialogState.update { it.copy(pinCTA = null) } + } + + is Commands.RemoveNickname -> { + setNickname(null) + + hideNicknameDialog() + } + + is Commands.SetNickname -> { + setNickname(_dialogState.value.nicknameDialog?.inputNickname?.trim()) + + hideNicknameDialog() + } + + is Commands.UpdateNickname -> { + val trimmedName = command.nickname.trim() + + val error: String? = when { + trimmedName.textSizeInBytes() > MAX_NAME_BYTES -> context.getString(R.string.nicknameErrorShorter) + + else -> null + } + + _dialogState.update { + it.copy( + nicknameDialog = it.nicknameDialog?.copy( + inputNickname = command.nickname, + setEnabled = trimmedName.isNotEmpty() && // can save if we have an input + trimmedName != it.nicknameDialog.currentNickname && // ... and it isn't the same as what is already saved + error == null, // ... and there are no errors + error = error + ) + ) + } + } + + is Commands.UpdateGroupName -> { + val trimmedName = command.name.trim() + + val error: String? = when { + trimmedName.textSizeInBytes() > MAX_NAME_BYTES -> context.getString(R.string.groupNameEnterShorter) + + else -> null + } + + _dialogState.update { + it.copy( + groupEditDialog = it.groupEditDialog?.copy( + inputName = command.name, + saveEnabled = trimmedName.isNotEmpty() && // can save if we have an input + trimmedName != it.groupEditDialog.currentName && // ... and it isn't the same as what is already saved + error == null && // ... and there are no name errors + it.groupEditDialog.errorDescription == null, // ... and there are no description errors + errorName = error + ) + ) + } + } + + is Commands.UpdateGroupDescription -> { + val trimmedDescription = command.description.trim() + + val error: String? = when { + // description should be less than 200 characters + getDisplayedCharacterSize(trimmedDescription) > 200 -> context.getString(R.string.updateGroupInformationEnterShorterDescription) + + // description should be less than max bytes + trimmedDescription.textSizeInBytes() > MAX_GROUP_DESCRIPTION_BYTES -> context.getString(R.string.updateGroupInformationEnterShorterDescription) + + else -> null + } + + _dialogState.update { + it.copy( + groupEditDialog = it.groupEditDialog?.copy( + inputtedDescription = command.description, + saveEnabled = trimmedDescription != it.groupEditDialog.currentName && // ... and it isn't the same as what is already saved + error == null && // ... and there are no description errors + it.groupEditDialog.inputName?.trim()?.isNotEmpty() == true && // ... and there is a name input + it.groupEditDialog.errorName == null, // ... and there are no name errors + errorDescription = error + ) + ) + } + } + + is Commands.SetGroupText -> { + val groupData = groupV2 ?: return + val dialogData = _dialogState.value.groupEditDialog ?: return + + showLoading() + hideGroupEditDialog() + viewModelScope.launch { + // save name if needed + if(dialogData.inputName != dialogData.currentName) { + groupManager.setName( + AccountId(groupData.groupAccountId), + dialogData.inputName ?: dialogData.currentName + ) + } + + // save description if needed + if(dialogData.inputtedDescription != dialogData.currentDescription) { + groupManager.setDescription( + AccountId(groupData.groupAccountId), + dialogData.inputtedDescription ?: "" + ) + } + + hideLoading() + } + } + + is Commands.ToggleQR -> { + _uiState.update { + it.copy(showQR = !it.showQR) + } + } + + is Commands.ToggleAvatarExpand -> { + _uiState.update { + it.copy(expandedAvatar = !it.expandedAvatar) + } + } + + is Commands.ShowProBadgeCTA -> { + _dialogState.update { + it.copy( + proBadgeCTA = if(recipient?.isGroupV2Recipient == true) ProBadgeCTA.Group + else ProBadgeCTA.Generic + ) + } + } + + is Commands.HideProBadgeCTA -> { + _dialogState.update { it.copy(proBadgeCTA = null) } + } + } + } + + private fun setNickname(nickname: String?){ + val conversation = recipient ?: return + + viewModelScope.launch(Dispatchers.Default) { + val publicKey = conversation.address.toString() + + val contact = storage.getContactWithAccountID(publicKey) ?: Contact(publicKey) + contact.nickname = nickname + storage.setContact(contact) + } + } + + private fun showNicknameDialog(){ + val conversation = recipient ?: return + + val configContact = configFactory.withUserConfigs { configs -> + configs.contacts.get(conversation.address.toString()) + } + + _dialogState.update { + it.copy( + nicknameDialog = NicknameDialogData( + name = configContact?.name ?: "", + currentNickname = configContact?.nickname, + inputNickname = configContact?.nickname, + setEnabled = false, + removeEnabled = configContact?.nickname?.isEmpty() == false, // can only remove is we have a nickname already + error = null + ), + groupEditDialog = null + ) + } + } + + private fun showGroupEditDialog(){ + val groupName = _uiState.value.name + val groupDescription = _uiState.value.description + + _dialogState.update { + it.copy(groupEditDialog = GroupEditDialog( + currentName = groupName, + inputName = groupName, + currentDescription = groupDescription, + inputtedDescription = groupDescription, + saveEnabled = false, + errorName = null, + errorDescription = null + )) + } + } + + private fun hideNicknameDialog(){ + _dialogState.update { + it.copy(nicknameDialog = null) + } + } + + private fun hideGroupEditDialog(){ + _dialogState.update { + it.copy(groupEditDialog = null) + } + } + + private fun showLoading(){ + _uiState.update { + it.copy(showLoading = true) + } + } + + private fun hideLoading(){ + _uiState.update { + it.copy(showLoading = false) + } + } + + private fun navigateTo(destination: ConversationSettingsDestination){ + viewModelScope.launch { + navigator.navigate(destination) + } + } + + fun inviteContactsToCommunity(contacts: Set
) { + showLoading() + viewModelScope.launch { + try { + withContext(Dispatchers.Default) { + val recipients = contacts.map { contact -> + Recipient.from(context, (contact), true) + } + + repository.inviteContactsToCommunity(threadId, recipients) + } + + hideLoading() + + // show confirmation toast + Toast.makeText(context, context.resources.getQuantityString( + R.plurals.groupInviteSending, + contacts.size, + contacts.size + ), Toast.LENGTH_LONG).show() + } catch (e: Exception){ + Log.w("", "Error sending community invites", e) + hideLoading() + Toast.makeText(context, R.string.errorUnknown, Toast.LENGTH_LONG).show() + } + } + } + + sealed interface Commands { + data object CopyAccountId : Commands + data object HideSimpleDialog : Commands + data object HideGroupAdminClearMessagesDialog : Commands + data object ClearMessagesGroupDeviceOnly : Commands + data object ClearMessagesGroupEveryone : Commands + + // dialogs + data object ShowNicknameDialog : Commands + data object HideNicknameDialog : Commands + data object RemoveNickname : Commands + data object SetNickname: Commands + data class UpdateNickname(val nickname: String): Commands + data class UpdateGroupName(val name: String): Commands + data class UpdateGroupDescription(val description: String): Commands + data object SetGroupText: Commands + + data object ShowGroupEditDialog : Commands + data object HideGroupEditDialog : Commands + + data object HidePinCTADialog: Commands + + object ToggleAvatarExpand: Commands + object ToggleQR: Commands + + object ShowProBadgeCTA: Commands + object HideProBadgeCTA: Commands + } + + @AssistedFactory + interface Factory { + fun create(threadId: Long): ConversationSettingsViewModel + } + + data class UIState( + val avatarUIData: AvatarUIData, + val name: String = "", + val nameQaTag: String? = null, + val description: String? = null, + val descriptionQaTag: String? = null, + val displayAccountId: String? = null, // account id to display directly on the screen + val showLoading: Boolean = false, + val showProBadge: Boolean = false, + val proBadgeClickable: Boolean = false, + val editCommand: Commands? = null, + + val displayAccountIdHeader: String? = null, + val qrAddress: String? = null, // address to display as a qr code + val expandedAvatar: Boolean = false, + val showQR: Boolean = false, + + val categories: List = emptyList() + ) + + data class OptionsCategory( + val name: String? = null, + val items: List = emptyList() + ) + + data class OptionsSubCategory( + val danger: Boolean = false, + val items: List = emptyList() + ) + + data class OptionsItem( + val name: String, + @DrawableRes val icon: Int, + @StringRes val qaTag: Int? = null, + val subtitle: String? = null, + @StringRes val subtitleQaTag: Int? = null, + val enabled: Boolean = true, + val onClick: () -> Unit + ) + + data class DialogsState( + val pinCTA: PinProCTA? = null, + val showSimpleDialog: SimpleDialogData? = null, + val nicknameDialog: NicknameDialogData? = null, + val groupEditDialog: GroupEditDialog? = null, + val groupAdminClearMessagesDialog: GroupAdminClearMessageDialog? = null, + val proBadgeCTA: ProBadgeCTA? = null + ) + + data class PinProCTA( + val overTheLimit: Boolean + ) + + sealed interface ProBadgeCTA { + data object Generic: ProBadgeCTA + data object Group: ProBadgeCTA + } + + data class NicknameDialogData( + val name: String, + val currentNickname: String?, // the currently saved nickname, if any + val inputNickname: String?, // the nickname being inputted + val setEnabled: Boolean, + val removeEnabled: Boolean, + val error: String? + ) + + data class GroupEditDialog( + val currentName: String, // the currently saved name + val inputName: String?, // the name being inputted + val currentDescription: String?, + val inputtedDescription: String?, + val saveEnabled: Boolean, + val errorName: String?, + val errorDescription: String?, + ) + + data class GroupAdminClearMessageDialog( + val groupName: String + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsActivity.kt new file mode 100644 index 0000000000..713af5a710 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsActivity.kt @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.conversation.v2.settings.notification + +import androidx.compose.runtime.Composable +import androidx.hilt.navigation.compose.hiltViewModel +import dagger.hilt.android.AndroidEntryPoint +import org.thoughtcrime.securesms.FullComposeScreenLockActivity +import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity + +/** + * Forced to add an activity entry point for this screen + * (which is otherwise accessed without an activity through the ConversationSettingsNavHost) + * because this is navigated to from the conversation app bar + */ +@AndroidEntryPoint +class NotificationSettingsActivity: FullComposeScreenLockActivity() { + + private val threadId: Long by lazy { + intent.getLongExtra(DisappearingMessagesActivity.THREAD_ID, -1) + } + + @Composable + override fun ComposeContent() { + val viewModel = + hiltViewModel { factory -> + factory.create(threadId) + } + + NotificationSettingsScreen( + viewModel = viewModel, + onBack = { finish() } + ) + } + + companion object { + const val THREAD_ID = "thread_id" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt new file mode 100644 index 0000000000..9aad4006cd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsScreen.kt @@ -0,0 +1,155 @@ +package org.thoughtcrime.securesms.conversation.v2.settings.notification + + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.OptionsCard +import org.thoughtcrime.securesms.ui.OptionsCardData +import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.AccentOutlineButton +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.PreviewTheme + + +@Composable +fun NotificationSettingsScreen( + viewModel: NotificationSettingsViewModel, + onBack: () -> Unit +) { + val state by viewModel.uiState.collectAsState() + + NotificationSettings( + state = state, + onOptionSelected = viewModel::onOptionSelected, + onSetClicked = viewModel::onSetClicked, + onBack = onBack + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationSettings( + state: NotificationSettingsViewModel.UiState, + onOptionSelected: (Any) -> Unit, + onSetClicked: suspend () -> Unit, + onBack: () -> Unit +) { + Scaffold( + topBar = { + BackAppBar( + title = LocalContext.current.getString(R.string.sessionNotifications), + onBack = onBack + ) + }, + ) { paddings -> + Column( + modifier = Modifier.padding(paddings).consumeWindowInsets(paddings) + ) { + BottomFadingEdgeBox(modifier = Modifier.weight(1f)) { bottomContentPadding -> + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = LocalDimensions.current.spacing) + ) { + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + // notification options + if(state.notificationTypes != null) { + OptionsCard(state.notificationTypes, onOptionSelected) + } + + // mute types + if(state.muteTypes != null) { + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + OptionsCard(state.muteTypes, onOptionSelected) + } + + Spacer(modifier = Modifier.height(bottomContentPadding)) + } + } + + val coroutineScope = rememberCoroutineScope() + AccentOutlineButton( + stringResource(R.string.set), + modifier = Modifier + .qaTag(R.string.AccessibilityId_setButton) + .align(Alignment.CenterHorizontally) + .padding(bottom = LocalDimensions.current.spacing), + enabled = state.enableButton, + onClick = { + coroutineScope.launch { + onSetClicked() + onBack() // leave screen once value is set + } + } + ) + } + } +} + +@Preview +@Composable +fun PreviewNotificationSettings(){ + PreviewTheme { + NotificationSettings( + state = NotificationSettingsViewModel.UiState( + notificationTypes = OptionsCardData( + title = null, + options = listOf( + RadioOption( + value = NotificationSettingsViewModel.NotificationType.All, + title = GetString("All"), + selected = true + ), + RadioOption( + value = NotificationSettingsViewModel.NotificationType.All, + title = GetString("Mentions Only"), + selected = false + ), + ) + ), + muteTypes = OptionsCardData( + title = GetString("Other Options"), + options = listOf( + RadioOption( + value = Long.MAX_VALUE, + title = GetString("Something"), + selected = false + ), + RadioOption( + value = Long.MAX_VALUE, + title = GetString("Something Else"), + selected = false + ), + ) + ), + enableButton = true + ), + onOptionSelected = {}, + onSetClicked = {}, + onBack = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt new file mode 100644 index 0000000000..6cfb6b0fe7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsViewModel.kt @@ -0,0 +1,300 @@ +package org.thoughtcrime.securesms.conversation.v2.settings.notification + +import android.content.Context +import android.widget.Toast +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.squareup.phrase.Phrase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import network.loki.messenger.BuildConfig +import network.loki.messenger.R +import org.session.libsession.LocalisedTimeUtil +import org.session.libsession.utilities.StringSubstitutionConstants.DATE_TIME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL +import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_MENTIONS +import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_NONE +import org.thoughtcrime.securesms.repository.ConversationRepository +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.OptionsCardData +import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.getSubbedString +import org.thoughtcrime.securesms.util.DateUtils +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.milliseconds + +@HiltViewModel(assistedFactory = NotificationSettingsViewModel.Factory::class) +class NotificationSettingsViewModel @AssistedInject constructor( + @Assisted private val threadId: Long, + @ApplicationContext private val context: Context, + private val recipientDatabase: RecipientDatabase, + private val repository: ConversationRepository, + private val dateUtils: DateUtils, +) : ViewModel() { + private var thread: Recipient? = null + + private val durationForever: Long = Long.MAX_VALUE + + // the options the user is currently using + private var currentOption: NotificationType = NotificationType.All + private var currentMutedUntil: Long? = null + + // the option selected on this screen + private var selectedOption: NotificationType = NotificationType.All + private var selectedMuteDuration: Long? = null + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState + + init { + // update data when we have a recipient and update when there are changes from the thread or recipient + viewModelScope.launch(Dispatchers.Default) { + repository.recipientUpdateFlow(threadId).collect { + thread = it + + // update the user's current choice of notification + currentMutedUntil = if(it?.isMuted == true) it.mutedUntil else null + val hasMutedUntil = currentMutedUntil != null && currentMutedUntil!! > 0L + + currentOption = when{ + hasMutedUntil -> NotificationType.Mute + it?.notifyType == NOTIFY_TYPE_MENTIONS -> NotificationType.MentionsOnly + else -> NotificationType.All + } + + // set our default selection to those + selectedOption = currentOption + // default selection for mute is either our custom "Muted Until" or "Forever" if nothing is pre picked + selectedMuteDuration = if(hasMutedUntil) currentMutedUntil else durationForever + + updateState() + } + } + } + + private fun updateState(){ + // start with the default options + val defaultOptions = OptionsCardData( + title = null, + options = listOf( + // All + RadioOption( + value = NotificationType.All, + title = GetString(R.string.notificationsAllMessages), + iconRes = R.drawable.ic_volume_2, + qaTag = GetString(R.string.qa_conversation_settings_notifications_radio_all), + selected = selectedOption is NotificationType.All + ), + // Mentions Only + RadioOption( + value = NotificationType.MentionsOnly, + title = GetString(R.string.notificationsMentionsOnly), + iconRes = R.drawable.ic_at_sign, + qaTag = GetString(R.string.qa_conversation_settings_notifications_radio_mentions), + selected = selectedOption is NotificationType.MentionsOnly + ), + // Mute + RadioOption( + value = NotificationType.Mute, + title = GetString(R.string.notificationsMute), + iconRes = R.drawable.ic_volume_off, + qaTag = GetString(R.string.qa_conversation_settings_notifications_radio_mute), + selected = selectedOption is NotificationType.Mute + ), + ) + ) + + var muteOptions: OptionsCardData? = null + + // add the mute options if necessary + if(selectedOption is NotificationType.Mute) { + val muteRadioOptions = mutableListOf>() + + // if the user is currently "muting until", and that muting is not forever, + // then add a new option that specifies how much longer the mute is on for + if(currentMutedUntil != null && currentMutedUntil!! > 0L && + currentMutedUntil!! < System.currentTimeMillis() + TimeUnit.DAYS.toMillis(14)){ // more than two weeks from now means forever + val title = Phrase.from(context.getString(R.string.notificationsMutedForTime)) + .put(DATE_TIME_KEY, formatTime(currentMutedUntil!!)) + .format().toString() + muteRadioOptions.add( + RadioOption( + value = currentMutedUntil!!, + title = GetString(title), + qaTag = GetString(R.string.qa_conversation_settings_notifications_radio_muted_until), + selected = selectedMuteDuration == currentMutedUntil + ) + ) + } + + // add debug options on non prod builds + if (BuildConfig.BUILD_TYPE != "release") { + muteRadioOptions.addAll( + debugMuteDurations.map { + RadioOption( + value = it.first, + title = GetString( + LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( + context, + it.first.milliseconds + ) + ), + subtitle = GetString("For testing purposes"), + qaTag = GetString(it.second), + selected = selectedMuteDuration == it.first + ) + } + ) + } + + // add the regular options + muteRadioOptions.addAll( + muteDurations.map { + RadioOption( + value = it.first, + title = + if(it.first == durationForever) GetString(R.string.forever) + else GetString( + LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( + context, + it.first.milliseconds + ) + ), + qaTag = GetString(it.second), + selected = selectedMuteDuration == it.first + ) + } + ) + + muteOptions = OptionsCardData( + title = GetString(R.string.disappearingMessagesTimer), + options = muteRadioOptions + ) + } + + _uiState.update { + UiState( + notificationTypes = defaultOptions, + muteTypes = muteOptions, + enableButton = shouldEnableSetButton() + ) + } + } + + private fun formatTime(timestamp: Long): String{ + return dateUtils.formatTime(timestamp, "HH:mm dd/MM/yy") + } + + private fun shouldEnableSetButton(): Boolean { + return when{ + selectedOption is NotificationType.Mute -> selectedMuteDuration != currentMutedUntil + else -> selectedOption != currentOption + } + } + + suspend fun onSetClicked() { + when(selectedOption){ + is NotificationType.All, is NotificationType.MentionsOnly -> { + unmute() + setNotifyType(selectedOption.notifyType) + } + + else -> { + val muteDuration = selectedMuteDuration ?: return + + mute(if(muteDuration == durationForever) muteDuration else System.currentTimeMillis() + muteDuration) + + // also show a toast in this case + val toastString = if(muteDuration == durationForever) { + context.getString(R.string.notificationsMuted) + } else { + context.getSubbedString( + R.string.notificationsMutedFor, + TIME_LARGE_KEY to LocalisedTimeUtil.getDurationWithSingleLargestTimeUnit( + context, + muteDuration.milliseconds + ) + ) + } + + Toast.makeText(context, toastString, Toast.LENGTH_LONG).show() + } + } + } + + fun onOptionSelected(value: Any) { + when(value){ + is Long -> selectedMuteDuration = value + + is NotificationType -> selectedOption = value + } + + updateState() + } + + private suspend fun unmute() { + val conversation = thread ?: return + withContext(Dispatchers.Default) { + recipientDatabase.setMuted(conversation, 0) + } + } + + private suspend fun mute(until: Long) { + val conversation = thread ?: return + withContext(Dispatchers.Default) { + recipientDatabase.setMuted(conversation, until) + } + } + + private suspend fun setNotifyType(notifyType: Int) { + val conversation = thread ?: return + withContext(Dispatchers.Default) { + recipientDatabase.setNotifyType(conversation, notifyType) + } + } + + data class UiState( + val notificationTypes: OptionsCardData? = null, + val muteTypes: OptionsCardData? = null, + val enableButton: Boolean = false, + ) + + sealed class NotificationType(val notifyType: Int) { + data object All: NotificationType(NOTIFY_TYPE_ALL) + data object MentionsOnly: NotificationType(NOTIFY_TYPE_MENTIONS) + data object Mute: NotificationType(NOTIFY_TYPE_NONE) + } + + private val debugMuteDurations = listOf( + TimeUnit.MINUTES.toMillis(1) to R.string.qa_conversation_settings_notifications_radio_1m, + TimeUnit.MINUTES.toMillis(5) to R.string.qa_conversation_settings_notifications_radio_5m, + ) + + private val muteDurations = listOf( + durationForever to R.string.qa_conversation_settings_notifications_radio_forever, + TimeUnit.HOURS.toMillis(1) to R.string.qa_conversation_settings_notifications_radio_1h, + TimeUnit.HOURS.toMillis(2) to R.string.qa_conversation_settings_notifications_radio_2h, + TimeUnit.DAYS.toMillis(1) to R.string.qa_conversation_settings_notifications_radio_1d, + TimeUnit.DAYS.toMillis(7) to R.string.qa_conversation_settings_notifications_radio_1w, + ) + + @AssistedFactory + interface Factory { + fun create(threadId: Long): NotificationSettingsViewModel + } +} \ No newline at end of file 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 ccbba13b3a..5e92dd913f 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 @@ -34,6 +34,7 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.bumptech.glide.RequestManager; import com.squareup.phrase.Phrase; import java.io.IOException; import java.util.Iterator; @@ -50,7 +51,6 @@ import org.thoughtcrime.securesms.mms.AudioSlide; import org.thoughtcrime.securesms.mms.DocumentSlide; import org.thoughtcrime.securesms.mms.GifSlide; -import com.bumptech.glide.RequestManager; import org.thoughtcrime.securesms.mms.ImageSlide; import org.thoughtcrime.securesms.mms.MediaConstraints; import org.thoughtcrime.securesms.mms.PartAuthority; @@ -58,369 +58,370 @@ import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.mms.VideoSlide; import org.thoughtcrime.securesms.permissions.Permissions; -import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.providers.BlobUtils; +import org.thoughtcrime.securesms.util.FilenameUtils; import org.thoughtcrime.securesms.util.MediaUtil; public class AttachmentManager { - private final static String TAG = AttachmentManager.class.getSimpleName(); + private final static String TAG = AttachmentManager.class.getSimpleName(); - // Max attachment size is 10MB, above which we display a warning toast rather than sending the msg - private final long MAX_ATTACHMENTS_FILE_SIZE_BYTES = 10 * 1024 * 1024; + // Max attachment size is 10MB, above which we display a warning toast rather than sending the msg + private final long MAX_ATTACHMENTS_FILE_SIZE_BYTES = 10 * 1024 * 1024; - private final @NonNull Context context; - private final @NonNull AttachmentListener attachmentListener; + private final @NonNull Context context; + private final @NonNull AttachmentListener attachmentListener; - private @NonNull List garbage = new LinkedList<>(); - private @NonNull Optional slide = Optional.absent(); - private @Nullable Uri captureUri; + private @NonNull List garbage = new LinkedList<>(); + private @NonNull Optional slide = Optional.absent(); + private @Nullable Uri captureUri; - public AttachmentManager(@NonNull Activity activity, @NonNull AttachmentListener listener) { - this.context = activity; - this.attachmentListener = listener; - } + public AttachmentManager(@NonNull Activity activity, @NonNull AttachmentListener listener) { + this.context = activity; + this.attachmentListener = listener; + } - public void clear() { - markGarbage(getSlideUri()); - slide = Optional.absent(); - attachmentListener.onAttachmentChanged(); - } + public void clear() { + markGarbage(getSlideUri()); + slide = Optional.absent(); + attachmentListener.onAttachmentChanged(); + } - public void cleanup() { - cleanup(captureUri); - cleanup(getSlideUri()); + public void cleanup() { + cleanup(captureUri); + cleanup(getSlideUri()); - captureUri = null; - slide = Optional.absent(); + captureUri = null; + slide = Optional.absent(); - Iterator iterator = garbage.listIterator(); + Iterator iterator = garbage.listIterator(); - while (iterator.hasNext()) { - cleanup(iterator.next()); - iterator.remove(); + while (iterator.hasNext()) { + cleanup(iterator.next()); + iterator.remove(); + } } - } - private void cleanup(final @Nullable Uri uri) { - if (uri != null && BlobProvider.isAuthority(uri)) { - BlobProvider.getInstance().delete(context, uri); + private void cleanup(final @Nullable Uri uri) { + if (uri != null && BlobUtils.isAuthority(uri)) { + BlobUtils.getInstance().delete(context, uri); + } } - } - private void markGarbage(@Nullable Uri uri) { - if (uri != null && BlobProvider.isAuthority(uri)) { - Log.d(TAG, "Marking garbage that needs cleaning: " + uri); - garbage.add(uri); + private void markGarbage(@Nullable Uri uri) { + if (uri != null && BlobUtils.isAuthority(uri)) { + Log.d(TAG, "Marking garbage that needs cleaning: " + uri); + garbage.add(uri); + } } - } - private void setSlide(@NonNull Slide slide) { - if (getSlideUri() != null) { - cleanup(getSlideUri()); - } + private void setSlide(@NonNull Slide slide) { + if (getSlideUri() != null) { + cleanup(getSlideUri()); + } - if (captureUri != null && !captureUri.equals(slide.getUri())) { - cleanup(captureUri); - captureUri = null; - } + if (captureUri != null && !captureUri.equals(slide.getUri())) { + cleanup(captureUri); + captureUri = null; + } - this.slide = Optional.of(slide); - } + this.slide = Optional.of(slide); + } - @SuppressLint("StaticFieldLeak") - public ListenableFuture setMedia(@NonNull final RequestManager glideRequests, - @NonNull final Uri uri, - @NonNull final MediaType mediaType, - @NonNull final MediaConstraints constraints, - final int width, - final int height) - { - final SettableFuture result = new SettableFuture<>(); + @SuppressLint("StaticFieldLeak") + public ListenableFuture setMedia(@NonNull final RequestManager glideRequests, + @NonNull final Uri uri, + @NonNull final MediaType mediaType, + @NonNull final MediaConstraints constraints, + final int width, + final int height) + { + final SettableFuture result = new SettableFuture<>(); + + new AsyncTask() { + @Override + protected void onPreExecute() { /* Nothing */ } + + @Override + protected @Nullable Slide doInBackground(Void... params) { + try { + if (PartAuthority.isLocalUri(uri)) { + return getManuallyCalculatedSlideInfo(uri, width, height); + } else { + Slide result = getContentResolverSlideInfo(uri, width, height); + if (result == null) return getManuallyCalculatedSlideInfo(uri, width, height); + else return result; + } + } catch (IOException e) { + Log.w(TAG, e); + return null; + } + } - new AsyncTask() { - @Override - protected void onPreExecute() { + @Override + protected void onPostExecute(@Nullable final Slide slide) { + if (slide == null) { + result.set(false); + } else if (!areConstraintsSatisfied(context, slide, constraints)) { + result.set(false); + } else { + setSlide(slide); + result.set(true); + attachmentListener.onAttachmentChanged(); + } + } - } + private @Nullable Slide getContentResolverSlideInfo(Uri uri, int width, int height) { + Cursor cursor = null; + long start = System.currentTimeMillis(); - @Override - protected @Nullable Slide doInBackground(Void... params) { - try { - if (PartAuthority.isLocalUri(uri)) { - return getManuallyCalculatedSlideInfo(uri, width, height); - } else { - Slide result = getContentResolverSlideInfo(uri, width, height); - - if (result == null) return getManuallyCalculatedSlideInfo(uri, width, height); - else return result; - } - } catch (IOException e) { - Log.w(TAG, e); - return null; - } - } - - @Override - protected void onPostExecute(@Nullable final Slide slide) { - if (slide == null) { - result.set(false); - } else if (!areConstraintsSatisfied(context, slide, constraints)) { - result.set(false); - } else { - setSlide(slide); - result.set(true); - attachmentListener.onAttachmentChanged(); - } - } + try { + cursor = context.getContentResolver().query(uri, null, null, null, null); - private @Nullable Slide getContentResolverSlideInfo(Uri uri, int width, int height) { - Cursor cursor = null; - long start = System.currentTimeMillis(); + if (cursor != null && cursor.moveToFirst()) { + long fileSize = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); + String mimeType = context.getContentResolver().getType(uri); - try { - cursor = context.getContentResolver().query(uri, null, null, null, null); + if (width == 0 || height == 0) { + Pair dimens = MediaUtil.getDimensions(context, mimeType, uri); + width = dimens.first; + height = dimens.second; + } - if (cursor != null && cursor.moveToFirst()) { - String fileName = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)); - long fileSize = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); - String mimeType = context.getContentResolver().getType(uri); + Log.d(TAG, "remote slide with size " + fileSize + " took " + (System.currentTimeMillis() - start) + "ms"); + return mediaType.createSlide(context, uri, mimeType, fileSize, width, height); + } + } finally { + if (cursor != null) cursor.close(); + } - if (width == 0 || height == 0) { - Pair dimens = MediaUtil.getDimensions(context, mimeType, uri); - width = dimens.first; - height = dimens.second; + return null; } - Log.d(TAG, "remote slide with size " + fileSize + " took " + (System.currentTimeMillis() - start) + "ms"); - return mediaType.createSlide(context, uri, fileName, mimeType, fileSize, width, height); - } - } finally { - if (cursor != null) cursor.close(); - } + private @NonNull Slide getManuallyCalculatedSlideInfo(Uri uri, int width, int height) throws IOException { + long start = System.currentTimeMillis(); + Long mediaSize = null; + String mimeType = null; - return null; - } + if (PartAuthority.isLocalUri(uri)) { + mediaSize = PartAuthority.getAttachmentSize(context, uri); + mimeType = PartAuthority.getAttachmentContentType(context, uri); + } - private @NonNull Slide getManuallyCalculatedSlideInfo(Uri uri, int width, int height) throws IOException { - long start = System.currentTimeMillis(); - Long mediaSize = null; - String fileName = null; - String mimeType = null; + if (mediaSize == null) { mediaSize = MediaUtil.getMediaSize(context, uri); } + if (mimeType == null) { mimeType = MediaUtil.getMimeType(context, uri); } - if (PartAuthority.isLocalUri(uri)) { - mediaSize = PartAuthority.getAttachmentSize(context, uri); - fileName = PartAuthority.getAttachmentFileName(context, uri); - mimeType = PartAuthority.getAttachmentContentType(context, uri); - } + if (width == 0 || height == 0) { + Pair dimens = MediaUtil.getDimensions(context, mimeType, uri); + width = dimens.first; + height = dimens.second; + } - if (mediaSize == null) { - mediaSize = MediaUtil.getMediaSize(context, uri); - } + Log.d(TAG, "local slide with size " + mediaSize + " took " + (System.currentTimeMillis() - start) + "ms"); + return mediaType.createSlide(context, uri, mimeType, mediaSize, width, height); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - if (mimeType == null) { - mimeType = MediaUtil.getMimeType(context, uri); - } + return result; + } - if (width == 0 || height == 0) { - Pair dimens = MediaUtil.getDimensions(context, mimeType, uri); - width = dimens.first; - height = dimens.second; - } + public @NonNull + SlideDeck buildSlideDeck() { + SlideDeck deck = new SlideDeck(); + if (slide.isPresent()) deck.addSlide(slide.get()); + return deck; + } - Log.d(TAG, "local slide with size " + mediaSize + " took " + (System.currentTimeMillis() - start) + "ms"); - return mediaType.createSlide(context, uri, fileName, mimeType, mediaSize, width, height); - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - - return result; - } - - public @NonNull - SlideDeck buildSlideDeck() { - SlideDeck deck = new SlideDeck(); - if (slide.isPresent()) deck.addSlide(slide.get()); - return deck; - } - - public static void selectDocument(Activity activity, int requestCode) { - Permissions.PermissionsBuilder builder = Permissions.with(activity); - Context c = activity.getApplicationContext(); - - // 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) - .withRationaleDialog( - Phrase.from(c, R.string.permissionsMusicAudio) - .put(APP_NAME_KEY, c.getString(R.string.app_name)).format().toString() - ) - .withPermanentDenialDialog( + public static void selectDocument(Activity activity, int requestCode) { + Permissions.PermissionsBuilder builder = Permissions.with(activity); + Context c = activity.getApplicationContext(); + + // 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, + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_AUDIO) + .withRationaleDialog( + Phrase.from(c, R.string.permissionsMusicAudio) + .put(APP_NAME_KEY, c.getString(R.string.app_name)).format().toString() + ) + .withPermanentDenialDialog( Phrase.from(c, R.string.permissionMusicAudioDenied) .put(APP_NAME_KEY, c.getString(R.string.app_name)) .format().toString() - ); - } else { - builder = builder.request(Manifest.permission.READ_EXTERNAL_STORAGE) - .withPermanentDenialDialog( - Phrase.from(c, R.string.permissionsStorageDeniedLegacy) - .put(APP_NAME_KEY, c.getString(R.string.app_name)) - .format().toString() - ); + ); + } else { + builder = builder.request(Manifest.permission.READ_EXTERNAL_STORAGE) + .withPermanentDenialDialog( + Phrase.from(c, R.string.permissionsStorageDeniedLegacy) + .put(APP_NAME_KEY, c.getString(R.string.app_name)) + .format().toString() + ); + } + + builder.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(); } - builder.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) { - - Context c = activity.getApplicationContext(); - - 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) - .withPermanentDenialDialog( - Phrase.from(c, R.string.permissionsStorageDenied) - .put(APP_NAME_KEY, c.getString(R.string.app_name)) - .format().toString() - ); - } else { - builder = builder.request(Manifest.permission.READ_EXTERNAL_STORAGE) - .withPermanentDenialDialog( - Phrase.from(c, R.string.permissionsStorageDeniedLegacy) - .put(APP_NAME_KEY, c.getString(R.string.app_name)) - .format().toString() - ); + public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull long threadId, @NonNull String body) { + + Context c = activity.getApplicationContext(); + + Permissions.PermissionsBuilder builder = Permissions.with(activity); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // API 34+ + builder = builder.request(Manifest.permission.READ_MEDIA_VIDEO, + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) + .withPermanentDenialDialog( + Phrase.from(c, R.string.permissionsStorageDenied) + .put(APP_NAME_KEY, c.getString(R.string.app_name)) + .format().toString() + ); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // API 33 + builder = builder.request(Manifest.permission.READ_MEDIA_VIDEO, + Manifest.permission.READ_MEDIA_IMAGES) + .withPermanentDenialDialog( + Phrase.from(c, R.string.permissionsStorageDenied) + .put(APP_NAME_KEY, c.getString(R.string.app_name)) + .format().toString() + ); + } else { + builder = builder.request(Manifest.permission.READ_EXTERNAL_STORAGE) + .withPermanentDenialDialog( + Phrase.from(c, R.string.permissionsStorageDeniedLegacy) + .put(APP_NAME_KEY, c.getString(R.string.app_name)) + .format().toString() + ); + } + + builder.onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, threadId, body), requestCode)) + .execute(); } - builder.onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode)) - .execute(); - } - - public static void selectAudio(Activity activity, int requestCode) { - selectMediaType(activity, "audio/*", null, requestCode); - } - - public static void selectGif(Activity activity, int requestCode) { - Intent intent = new Intent(activity, GiphyActivity.class); - intent.putExtra(GiphyActivity.EXTRA_IS_MMS, false); - activity.startActivityForResult(intent, requestCode); - } - - private @Nullable Uri getSlideUri() { - return slide.isPresent() ? slide.get().getUri() : null; - } - - public @Nullable Uri getCaptureUri() { - return captureUri; - } - - public void capturePhoto(Activity activity, int requestCode, Recipient recipient) { - - String cameraPermissionDeniedTxt = Phrase.from(context, R.string.permissionsCameraDenied) - .put(APP_NAME_KEY, context.getString(R.string.app_name)) - .format().toString(); - - Permissions.with(activity) - .request(Manifest.permission.CAMERA) - .withPermanentDenialDialog(cameraPermissionDeniedTxt) - .onAllGranted(() -> { - Intent captureIntent = MediaSendActivity.buildCameraIntent(activity, recipient); - if (captureIntent.resolveActivity(activity.getPackageManager()) != null) { - activity.startActivityForResult(captureIntent, requestCode); - } - }) - .execute(); - } - - private static void selectMediaType(Activity activity, @NonNull String type, @Nullable String[] extraMimeType, int requestCode) { - final Intent intent = new Intent(); - intent.setType(type); - - if (extraMimeType != null) { - intent.putExtra(Intent.EXTRA_MIME_TYPES, extraMimeType); + + public static void selectAudio(Activity activity, int requestCode) { + selectMediaType(activity, "audio/*", null, requestCode); } - intent.setAction(Intent.ACTION_OPEN_DOCUMENT); - try { - activity.startActivityForResult(intent, requestCode); - return; - } catch (ActivityNotFoundException anfe) { - Log.w(TAG, "couldn't complete ACTION_OPEN_DOCUMENT, no activity found. falling back."); + public static void selectGif(Activity activity, int requestCode) { + Intent intent = new Intent(activity, GiphyActivity.class); + intent.putExtra(GiphyActivity.EXTRA_IS_MMS, false); + activity.startActivityForResult(intent, requestCode); } - intent.setAction(Intent.ACTION_GET_CONTENT); + private @Nullable Uri getSlideUri() { + return slide.isPresent() ? slide.get().getUri() : null; + } - try { - activity.startActivityForResult(intent, requestCode); - } catch (ActivityNotFoundException anfe) { - Log.w(TAG, "couldn't complete ACTION_GET_CONTENT intent, no activity found. falling back."); - Toast.makeText(activity, R.string.attachmentsErrorNoApp, Toast.LENGTH_LONG).show(); + public @Nullable Uri getCaptureUri() { + return captureUri; } - } - - private boolean areConstraintsSatisfied(final @NonNull Context context, - final @Nullable Slide slide, - final @NonNull MediaConstraints constraints) - { - // Null attachment? Not satisfied. - if (slide == null) return false; - - // Attachments are excessively large? Not satisfied. - // Note: This file size test must come BEFORE the `constraints.isSatisfied` check below because - // it is a more specific type of check. - if (slide.asAttachment().getSize() > MAX_ATTACHMENTS_FILE_SIZE_BYTES) { - Toast.makeText(context, R.string.attachmentsErrorSize, Toast.LENGTH_SHORT).show(); - return false; + + public void capturePhoto(Activity activity, int requestCode, Recipient recipient, @NonNull long threadId, @NonNull String body) { + + String cameraPermissionDeniedTxt = Phrase.from(context, R.string.permissionsCameraDenied) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format().toString(); + + Permissions.with(activity) + .request(Manifest.permission.CAMERA) + .withPermanentDenialDialog(cameraPermissionDeniedTxt) + .onAllGranted(() -> { + Intent captureIntent = MediaSendActivity.buildCameraIntent(activity, recipient, threadId, body); + if (captureIntent.resolveActivity(activity.getPackageManager()) != null) { + activity.startActivityForResult(captureIntent, requestCode); + } + }) + .execute(); } - // Otherwise we return whether our constraints are satisfied OR if we can resize the attachment - // (in the case of one or more images) - either one will be acceptable, but if both aren't then - // we fail the constraint test. - return constraints.isSatisfied(context, slide.asAttachment()) || constraints.canResize(slide.asAttachment()); - } - - public interface AttachmentListener { - void onAttachmentChanged(); - } - - public enum MediaType { - IMAGE, GIF, AUDIO, VIDEO, DOCUMENT, VCARD; - - public @NonNull Slide createSlide(@NonNull Context context, - @NonNull Uri uri, - @Nullable String fileName, - @Nullable String mimeType, - long dataSize, - int width, - int height) + private static void selectMediaType(Activity activity, @NonNull String type, @Nullable String[] extraMimeType, int requestCode) { + final Intent intent = new Intent(); + intent.setType(type); + + if (extraMimeType != null) { + intent.putExtra(Intent.EXTRA_MIME_TYPES, extraMimeType); + } + + intent.setAction(Intent.ACTION_OPEN_DOCUMENT); + try { + activity.startActivityForResult(intent, requestCode); + return; + } catch (ActivityNotFoundException anfe) { + Log.w(TAG, "couldn't complete ACTION_OPEN_DOCUMENT, no activity found. falling back."); + } + + intent.setAction(Intent.ACTION_GET_CONTENT); + + try { + activity.startActivityForResult(intent, requestCode); + } catch (ActivityNotFoundException anfe) { + Log.w(TAG, "couldn't complete ACTION_GET_CONTENT intent, no activity found. falling back."); + Toast.makeText(activity, R.string.attachmentsErrorNoApp, Toast.LENGTH_LONG).show(); + } + } + + private boolean areConstraintsSatisfied(final @NonNull Context context, + final @Nullable Slide slide, + final @NonNull MediaConstraints constraints) { - if (mimeType == null) { - mimeType = "application/octet-stream"; - } - - switch (this) { - case IMAGE: return new ImageSlide(context, uri, dataSize, width, height); - case GIF: return new GifSlide(context, uri, dataSize, width, height); - case AUDIO: return new AudioSlide(context, uri, dataSize, false); - case VIDEO: return new VideoSlide(context, uri, dataSize); - case VCARD: - case DOCUMENT: return new DocumentSlide(context, uri, mimeType, dataSize, fileName); - default: throw new AssertionError("unrecognized enum"); - } + // Null attachment? Not satisfied. + if (slide == null) return false; + + // Attachments are excessively large? Not satisfied. + // Note: This file size test must come BEFORE the `constraints.isSatisfied` check below because + // it is a more specific type of check. + if (slide.asAttachment().getSize() > MAX_ATTACHMENTS_FILE_SIZE_BYTES) { + Toast.makeText(context, R.string.attachmentsErrorSize, Toast.LENGTH_SHORT).show(); + return false; + } + + // Otherwise we return whether our constraints are satisfied OR if we can resize the attachment + // (in the case of one or more images) - either one will be acceptable, but if both aren't then + // we fail the constraint test. + return constraints.isSatisfied(context, slide.asAttachment()) || constraints.canResize(slide.asAttachment()); + } + + public interface AttachmentListener { + void onAttachmentChanged(); } - public static @Nullable MediaType from(final @Nullable String mimeType) { - if (TextUtils.isEmpty(mimeType)) return null; - if (MediaUtil.isGif(mimeType)) return GIF; - if (MediaUtil.isImageType(mimeType)) return IMAGE; - if (MediaUtil.isAudioType(mimeType)) return AUDIO; - if (MediaUtil.isVideoType(mimeType)) return VIDEO; - if (MediaUtil.isVcard(mimeType)) return VCARD; + public enum MediaType { + IMAGE, GIF, AUDIO, VIDEO, DOCUMENT, VCARD; + + public @NonNull Slide createSlide(@NonNull Context context, + @NonNull Uri uri, + @Nullable String mimeType, + long dataSize, + int width, + int height) + { + if (mimeType == null) { mimeType = "application/octet-stream"; } + + // Try to extract a filename from the Uri if we weren't provided one + String extractedFilename = FilenameUtils.getFilenameFromUri(context, uri, mimeType); + + switch (this) { + case IMAGE: return new ImageSlide(context, uri, extractedFilename, dataSize, width, height, null); + case AUDIO: return new AudioSlide(context, uri, extractedFilename, dataSize, false, -1L); + case VIDEO: return new VideoSlide(context, uri, extractedFilename, dataSize); + case VCARD: + case DOCUMENT: return new DocumentSlide(context, uri, extractedFilename, mimeType, dataSize); + case GIF: return new GifSlide(context, uri, extractedFilename, dataSize, width, height, null); + default: throw new AssertionError("unrecognized enum"); + } + } - return DOCUMENT; + public static @Nullable MediaType from(final @Nullable String mimeType) { + if (TextUtils.isEmpty(mimeType)) return null; + if (MediaUtil.isGif(mimeType)) return GIF; + if (MediaUtil.isImageType(mimeType)) return IMAGE; + if (MediaUtil.isAudioType(mimeType)) return AUDIO; + if (MediaUtil.isVideoType(mimeType)) return VIDEO; + if (MediaUtil.isVcard(mimeType)) return VCARD; + + return DOCUMENT; + } } - } } 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 39301cd69f..4d5f995456 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 @@ -4,18 +4,21 @@ import android.content.Context import android.graphics.Typeface import android.text.Spannable import android.text.SpannableString +import android.text.SpannableStringBuilder import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan import android.util.Range import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.BlindKeyAPI import nl.komponents.kovenant.combine.Tuple2 import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.open_groups.OpenGroup -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.truncateIdForDisplay +import org.thoughtcrime.securesms.conversation.v2.mention.MentionEditable +import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.RoundedBackgroundSpan import org.thoughtcrime.securesms.util.getAccentColor @@ -23,7 +26,35 @@ import java.util.regex.Pattern object MentionUtilities { - private val pattern by lazy { Pattern.compile("@[0-9a-fA-F]*") } + private val ACCOUNT_ID = Regex("@([0-9a-fA-F]{66})") + private val pattern by lazy { Pattern.compile(ACCOUNT_ID.pattern) } + + /** + * In-place replacement on the *live* MentionEditable that the + * input-bar is already using. + * + * It swaps every "@<64-hex>" token for "@DisplayName" **and** + * attaches a MentionSpan so later normalisation still works. + */ + fun substituteIdsInPlace( + editable: MentionEditable, + membersById: Map + ) { + ACCOUNT_ID.findAll(editable) + .toList() // avoid index shifts + .asReversed() // back-to-front replacement + .forEach { m -> + val id = m.groupValues[1] + val member = membersById[id] ?: return@forEach + + val start = m.range.first + val end = m.range.last + 1 // inclusive ➜ exclusive + + editable.replace(start, end, "@${member.name}") + editable.addMention(member, start .. start + member.name.length + 1) + } + } + /** * Highlights mentions in a given text. @@ -65,7 +96,7 @@ object MentionUtilities { } else { val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey) @Suppress("NAME_SHADOWING") val context = if (openGroup != null) Contact.ContactContext.OPEN_GROUP else Contact.ContactContext.REGULAR - contact?.displayName(context) ?: truncateIdForDisplay(publicKey) + contact?.displayName(context) } if (userDisplayName != null) { val mention = "@$userDisplayName" @@ -158,7 +189,13 @@ object MentionUtilities { } private fun isYou(mentionedPublicKey: String, userPublicKey: String, openGroup: OpenGroup?): Boolean { - val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.accountId(userPublicKey, mentionedPublicKey, it.publicKey) } ?: false + val isUserBlindedPublicKey = openGroup?.let { + BlindKeyAPI.sessionIdMatchesBlindedId( + sessionId = userPublicKey, + blindedId = mentionedPublicKey, + serverPubKey = it.publicKey + ) + } ?: false return mentionedPublicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey } } \ No newline at end of file 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 deleted file mode 100644 index f012f925ed..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/NotificationUtils.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.utilities - -import android.content.Context -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) { - context.showSessionDialog { - title(R.string.sessionNotifications) - singleChoiceItems( - context.resources.getStringArray(R.array.notify_types), - thread.notifyType - ) { notifyTypeHandler(it) } - } - } -} 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 7aa9924957..3fb6a77bac 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 @@ -19,7 +19,7 @@ object ResendMessageUtilities { fun resend(context: Context, messageRecord: MessageRecord, userBlindedKey: String?, isResync: Boolean = false) { val recipient: Recipient = messageRecord.recipient val message = VisibleMessage() - message.id = messageRecord.getId() + message.id = messageRecord.messageId if (messageRecord.isOpenGroupInvitation) { val openGroupInvitation = OpenGroupInvitation() UpdateMessageData.fromJSON(messageRecord.body)?.let { updateMessageData -> @@ -37,7 +37,7 @@ object ResendMessageUtilities { if (recipient.isGroupOrCommunityRecipient) { message.groupPublicKey = recipient.address.toGroupString() } else { - message.recipient = messageRecord.recipient.address.serialize() + message.recipient = messageRecord.recipient.address.toString() } message.threadID = messageRecord.threadId if (messageRecord.isMms && messageRecord is MmsMessageRecord) { @@ -55,10 +55,10 @@ object ResendMessageUtilities { val sender = MessagingModuleConfiguration.shared.storage.getUserPublicKey() if (sentTimestamp != null && sender != null) { if (isResync) { - MessagingModuleConfiguration.shared.storage.markAsResyncing(sentTimestamp, sender) + MessagingModuleConfiguration.shared.storage.markAsResyncing(messageRecord.messageId) MessageSender.sendNonDurably(message, Destination.from(recipient.address), isSyncMessage = true) } else { - MessagingModuleConfiguration.shared.storage.markAsSending(sentTimestamp, sender) + MessagingModuleConfiguration.shared.storage.markAsSending(messageRecord.messageId) MessageSender.send(message, recipient.address) } } 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 1f103d924c..37a7f14f30 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 @@ -59,4 +59,17 @@ object TextUtilities { fun String.textSizeInBytes(): Int = this.toByteArray(Charsets.UTF_8).size + fun String.breakAt(vararg lengths: Int): String { + var cursor = 0 + val out = StringBuilder() + for (len in lengths) { + val end = (cursor + len).coerceAtMost(length) + out.append(substring(cursor, end)) + if (end < length) out.append('\n') + cursor = end + } + if (cursor < length) out.append('\n').append(substring(cursor)) + return out.toString() + } + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt index b7103b9c23..1df19790f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt @@ -6,29 +6,26 @@ import android.graphics.Outline import android.graphics.drawable.Drawable import android.net.Uri import android.util.AttributeSet -import android.util.TypedValue import android.view.View import android.view.ViewOutlineProvider -import android.view.ViewTreeObserver import android.widget.FrameLayout +import androidx.core.content.res.ResourcesCompat import androidx.core.view.isVisible +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.RequestManager import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.resource.bitmap.CenterCrop -import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.bumptech.glide.request.RequestOptions import network.loki.messenger.R import network.loki.messenger.databinding.ThumbnailViewBinding -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress import org.session.libsession.utilities.Util.equals +import org.session.libsession.utilities.getColorFromAttr import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.SettableFuture import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri -import com.bumptech.glide.RequestBuilder -import com.bumptech.glide.RequestManager -import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.ui.afterMeasured import java.lang.Float.min @@ -48,12 +45,18 @@ open class ThumbnailView @JvmOverloads constructor( // region Lifecycle - val loadIndicator: View by lazy { binding.thumbnailLoadIndicator } - private val dimensDelegate = ThumbnailDimensDelegate() + val loadIndicator: View by lazy { binding.thumbnailLoadIndicator } + private var slide: Slide? = null + private val errorDrawable by lazy { + val drawable = ResourcesCompat.getDrawable(resources, R.drawable.ic_triangle_alert, context.theme) + drawable?.setTint(context.getColorFromAttr(android.R.attr.textColorTertiary)) + drawable + } + init { attrs?.let { context.theme.obtainStyledAttributes(it, R.styleable.ThumbnailView, 0, 0) } ?.apply { @@ -119,7 +122,7 @@ open class ThumbnailView @JvmOverloads constructor( naturalHeight: Int ): ListenableFuture { val showPlayOverlay = (slide.thumbnailUri != null && slide.hasPlayOverlay() && - (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview)) + (slide.isDone || isPreview)) if(showPlayOverlay) { binding.playOverlay.isVisible = true // The views are poorly constructed at the moment and there is no good way to know @@ -144,8 +147,6 @@ open class ThumbnailView @JvmOverloads constructor( this.slide = slide binding.thumbnailLoadIndicator.isVisible = slide.isInProgress - binding.thumbnailDownloadIcon.isVisible = - slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED dimensDelegate.setDimens(naturalWidth, naturalHeight) invalidate() @@ -177,8 +178,8 @@ open class ThumbnailView @JvmOverloads constructor( .diskCacheStrategy(DiskCacheStrategy.NONE) .overrideDimensions() .transition(DrawableTransitionOptions.withCrossFade()) - .transform(CenterCrop()) - .missingThumbnailPicture(slide.isInProgress) + .optionalTransform(CenterCrop()) + .missingThumbnailPicture(slide.isInProgress, errorDrawable) private fun buildPlaceholderGlideRequest( glide: RequestManager, @@ -186,8 +187,6 @@ open class ThumbnailView @JvmOverloads constructor( ): RequestBuilder = glide.asBitmap() .load(slide.getPlaceholderRes(context.theme)) .diskCacheStrategy(DiskCacheStrategy.NONE) - .overrideDimensions() - .fitCenter() open fun clear(glideRequests: RequestManager) { glideRequests.clear(binding.thumbnailImage) @@ -206,7 +205,7 @@ open class ThumbnailView @JvmOverloads constructor( private fun RequestBuilder.intoDrawableTargetAsFuture() = SettableFuture().also { binding.run { - GlideDrawableListeningTarget(thumbnailImage, thumbnailLoadIndicator, it) + GlideDrawableListeningTarget(thumbnailImage, binding.thumbnailLoadIndicator, it) }.let { into(it) } } @@ -217,5 +216,6 @@ open class ThumbnailView @JvmOverloads constructor( } private fun RequestBuilder.missingThumbnailPicture( - inProgress: Boolean -) = takeIf { inProgress } ?: apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture)) + inProgress: Boolean, + errorDrawable: Drawable? +) = takeIf { inProgress } ?: apply(RequestOptions.errorOf(errorDrawable)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/BiometricSecretProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/crypto/BiometricSecretProvider.kt index 1329ad0dc3..d37c739028 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/BiometricSecretProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/BiometricSecretProvider.kt @@ -4,6 +4,9 @@ import android.content.Context import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.Util import java.security.InvalidKeyException @@ -11,6 +14,7 @@ import java.security.KeyPairGenerator import java.security.KeyStore import java.security.PrivateKey import java.security.Signature +import java.security.UnrecoverableKeyException class BiometricSecretProvider { @@ -22,7 +26,7 @@ class BiometricSecretProvider { fun getRandomData() = Util.getSecretBytes(32) - private fun createAsymmetricKey(context: Context) { + private fun createAsymmetricKey() { val keyGenerator = KeyPairGenerator.getInstance( KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEYSTORE ) @@ -38,47 +42,82 @@ class BiometricSecretProvider { .setUserAuthenticationRequired(true) .setUserAuthenticationValidityDurationSeconds(-1) + // Unlocked device required for enhanced security on Android P+ (e.g., API 28+) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { builder.setUnlockedDeviceRequired(true) } + // If biometrics are removed, keys become invalid builder.setInvalidatedByBiometricEnrollment(true) keyGenerator.initialize(builder.build()) keyGenerator.generateKeyPair() } - fun getOrCreateBiometricSignature(context: Context): Signature { + /** + * Returns a Signature object initialized for signing with a private key protected by biometrics. + * + * If no biometrics are enrolled, returns null instead of throwing an exception. The caller should + * handle this scenario by falling back to device credentials or skipping biometric-based logic. + */ + fun getOrCreateBiometricSignature(context: Context): Signature? { + // Check if biometrics are available and enrolled + val biometricManager = BiometricManager.from(context) + val canAuthenticate = biometricManager.canAuthenticate(BIOMETRIC_STRONG) + if (canAuthenticate != BIOMETRIC_SUCCESS) { + // No biometrics enrolled or hardware unavailable; return null to signal fallback + return null + } + val ks = KeyStore.getInstance(ANDROID_KEYSTORE) ks.load(null) - if (!ks.containsAlias(BIOMETRIC_ASYM_KEY_ALIAS) - || !ks.entryInstanceOf(BIOMETRIC_ASYM_KEY_ALIAS, KeyStore.PrivateKeyEntry::class.java) - || !TextSecurePreferences.getFingerprintKeyGenerated(context) + + // Check if the key already exists and if we've flagged that it's been generated + if (!ks.containsAlias(BIOMETRIC_ASYM_KEY_ALIAS) || + !ks.entryInstanceOf(BIOMETRIC_ASYM_KEY_ALIAS, KeyStore.PrivateKeyEntry::class.java) || + !TextSecurePreferences.getFingerprintKeyGenerated(context) ) { - createAsymmetricKey(context) - TextSecurePreferences.setFingerprintKeyGenerated(context) + // Create the key if it doesn't exist or isn't properly tracked + try { + createAsymmetricKey() + TextSecurePreferences.setFingerprintKeyGenerated(context) + } catch (e: Exception) { + // If key generation fails (e.g. due to no biometrics), return null + return null + } } - val signature = try { + + // Attempt to initialize the signature with the private key + return try { val key = ks.getKey(BIOMETRIC_ASYM_KEY_ALIAS, null) as PrivateKey val signature = Signature.getInstance(SIGNATURE_ALGORITHM) signature.initSign(key) signature } catch (e: InvalidKeyException) { + // If the key is invalid for some reason, recreate it ks.deleteEntry(BIOMETRIC_ASYM_KEY_ALIAS) - createAsymmetricKey(context) - TextSecurePreferences.setFingerprintKeyGenerated(context) - val key = ks.getKey(BIOMETRIC_ASYM_KEY_ALIAS, null) as PrivateKey - val signature = Signature.getInstance(SIGNATURE_ALGORITHM) - signature.initSign(key) - signature + return try { + createAsymmetricKey() + TextSecurePreferences.setFingerprintKeyGenerated(context) + val key = ks.getKey(BIOMETRIC_ASYM_KEY_ALIAS, null) as PrivateKey + val signature = Signature.getInstance(SIGNATURE_ALGORITHM) + signature.initSign(key) + signature + } catch (ex: Exception) { + // If key creation fails again, return null + null + } + } catch (e: UnrecoverableKeyException) { + // Handle key being unrecoverable (rare scenario) + ks.deleteEntry(BIOMETRIC_ASYM_KEY_ALIAS) + return null } - return signature } fun verifySignature(data: ByteArray, signedData: ByteArray): Boolean { val ks = KeyStore.getInstance(ANDROID_KEYSTORE) ks.load(null) - val certificate = ks.getCertificate(BIOMETRIC_ASYM_KEY_ALIAS) + val certificate = ks.getCertificate(BIOMETRIC_ASYM_KEY_ALIAS) ?: return false val signature = Signature.getInstance(SIGNATURE_ALGORITHM) signature.initVerify(certificate) signature.update(data) diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecretProvider.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecretProvider.java index ab79f7ea55..ffa50493fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecretProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecretProvider.java @@ -11,6 +11,12 @@ import java.io.IOException; +import javax.inject.Inject; +import javax.inject.Singleton; + +import dagger.hilt.android.qualifiers.ApplicationContext; + +@Singleton public class DatabaseSecretProvider { @SuppressWarnings("unused") @@ -18,7 +24,8 @@ public class DatabaseSecretProvider { private final Context context; - public DatabaseSecretProvider(@NonNull Context context) { + @Inject + public DatabaseSecretProvider(@ApplicationContext @NonNull Context context) { this.context = context.getApplicationContext(); } 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 e2fe41b625..52826a2838 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java @@ -27,6 +27,8 @@ import org.session.libsignal.crypto.IdentityKey; import org.session.libsignal.crypto.IdentityKeyPair; import org.session.libsignal.crypto.ecc.Curve; +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.crypto.ecc.ECPrivateKey; import org.session.libsignal.crypto.ecc.ECPublicKey; @@ -40,6 +42,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow; import kotlinx.coroutines.flow.MutableStateFlow; import kotlinx.coroutines.flow.SharedFlowKt; +import network.loki.messenger.libsession_util.Curve25519; +import network.loki.messenger.libsession_util.util.KeyPair; /** * Utility class for working with identity keys. @@ -117,11 +121,14 @@ public static void checkUpdate(Context context) { } public static void generateIdentityKeyPair(@NonNull Context context) { - ECKeyPair keyPair = Curve.generateKeyPair(); - ECPublicKey publicKey = keyPair.getPublicKey(); - ECPrivateKey privateKey = keyPair.getPrivateKey(); - save(context, IDENTITY_PUBLIC_KEY_PREF, Base64.encodeBytes(publicKey.serialize())); - save(context, IDENTITY_PRIVATE_KEY_PREF, Base64.encodeBytes(privateKey.serialize())); + KeyPair keyPair = Curve25519.INSTANCE.generateKeyPair(); + ECKeyPair ecKeyPair = new ECKeyPair( + new DjbECPublicKey(keyPair.getPubKey().getData()), + new DjbECPrivateKey(keyPair.getSecretKey().getData()) + ); + + save(context, IDENTITY_PUBLIC_KEY_PREF, Base64.encodeBytes(ecKeyPair.getPublicKey().serialize())); + save(context, IDENTITY_PRIVATE_KEY_PREF, Base64.encodeBytes(ecKeyPair.getPrivateKey().serialize())); } public static String retrieve(Context context, String key) { 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 f4887e1adb..bd90022bad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyPairUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyPairUtilities.kt @@ -1,52 +1,46 @@ package org.thoughtcrime.securesms.crypto import android.content.Context -import com.goterl.lazysodium.utils.Key -import com.goterl.lazysodium.utils.KeyPair -import org.session.libsession.messaging.utilities.SodiumUtilities.sodium +import network.loki.messenger.libsession_util.Curve25519 +import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.util.KeyPair 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.utilities.Base64 import org.session.libsignal.utilities.Hex +import java.security.SecureRandom object KeyPairUtilities { fun generate(): KeyPairGenerationResult { - val seed = sodium.randomBytesBuf(16) - try { - return generate(seed) - } catch (exception: Exception) { - return generate() + val seed = ByteArray(16).also { + SecureRandom().nextBytes(it) } + + return generate(seed) } fun generate(seed: ByteArray): KeyPairGenerationResult { - val padding = ByteArray(16) { 0 } - val ed25519KeyPair = sodium.cryptoSignSeedKeypair(seed + padding) - val sodiumX25519KeyPair = sodium.convertKeyPairEd25519ToCurve25519(ed25519KeyPair) - val x25519KeyPair = ECKeyPair(DjbECPublicKey(sodiumX25519KeyPair.publicKey.asBytes), DjbECPrivateKey(sodiumX25519KeyPair.secretKey.asBytes)) - return KeyPairGenerationResult(seed, ed25519KeyPair, x25519KeyPair) + val paddedSeed = seed + ByteArray(16) + val ed25519KeyPair = ED25519.generate(paddedSeed) + val x25519KeyPair = Curve25519.fromED25519(ed25519KeyPair) + return KeyPairGenerationResult(seed, ed25519KeyPair, + ECKeyPair(DjbECPublicKey(x25519KeyPair.pubKey.data), DjbECPrivateKey(x25519KeyPair.secretKey.data))) } fun store(context: Context, seed: ByteArray, ed25519KeyPair: KeyPair, x25519KeyPair: ECKeyPair) { IdentityKeyUtil.save(context, IdentityKeyUtil.LOKI_SEED, Hex.toStringCondensed(seed)) IdentityKeyUtil.save(context, IdentityKeyUtil.IDENTITY_PUBLIC_KEY_PREF, Base64.encodeBytes(x25519KeyPair.publicKey.serialize())) IdentityKeyUtil.save(context, IdentityKeyUtil.IDENTITY_PRIVATE_KEY_PREF, Base64.encodeBytes(x25519KeyPair.privateKey.serialize())) - IdentityKeyUtil.save(context, IdentityKeyUtil.ED25519_PUBLIC_KEY, Base64.encodeBytes(ed25519KeyPair.publicKey.asBytes)) - IdentityKeyUtil.save(context, IdentityKeyUtil.ED25519_SECRET_KEY, Base64.encodeBytes(ed25519KeyPair.secretKey.asBytes)) - } - - fun hasV2KeyPair(context: Context): Boolean { - return (IdentityKeyUtil.retrieve(context, IdentityKeyUtil.ED25519_SECRET_KEY) != null) + IdentityKeyUtil.save(context, IdentityKeyUtil.ED25519_PUBLIC_KEY, Base64.encodeBytes(ed25519KeyPair.pubKey.data)) + IdentityKeyUtil.save(context, IdentityKeyUtil.ED25519_SECRET_KEY, Base64.encodeBytes(ed25519KeyPair.secretKey.data)) } fun getUserED25519KeyPair(context: Context): KeyPair? { val base64EncodedED25519PublicKey = IdentityKeyUtil.retrieve(context, IdentityKeyUtil.ED25519_PUBLIC_KEY) ?: return null val base64EncodedED25519SecretKey = IdentityKeyUtil.retrieve(context, IdentityKeyUtil.ED25519_SECRET_KEY) ?: return null - val ed25519PublicKey = Key.fromBytes(Base64.decode(base64EncodedED25519PublicKey)) - val ed25519SecretKey = Key.fromBytes(Base64.decode(base64EncodedED25519SecretKey)) - return KeyPair(ed25519PublicKey, ed25519SecretKey) + return KeyPair(pubKey = Base64.decode(base64EncodedED25519PublicKey), secretKey = Base64.decode(base64EncodedED25519SecretKey)) } data class KeyPairGenerationResult( 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 3663d147f2..080bc90243 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java @@ -23,7 +23,6 @@ import android.graphics.Bitmap; import android.media.MediaMetadataRetriever; import android.net.Uri; -import android.os.Build; import android.text.TextUtils; import android.util.Pair; @@ -40,7 +39,7 @@ import org.json.JSONException; 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.AttachmentTransferProgress; +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState; import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment; import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachmentAudioExtras; import org.session.libsession.utilities.MediaTypes; @@ -70,6 +69,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; @@ -81,6 +81,8 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; +import javax.inject.Provider; + import kotlin.jvm.Synchronized; public class AttachmentDatabase extends Database { @@ -130,9 +132,8 @@ public class AttachmentDatabase extends Database { SIZE, FILE_NAME, THUMBNAIL, THUMBNAIL_ASPECT_RATIO, UNIQUE_ID, DIGEST, FAST_PREFLIGHT_ID, VOICE_NOTE, QUOTE, DATA_RANDOM, THUMBNAIL_RANDOM, WIDTH, HEIGHT, - CAPTION, STICKER_PACK_ID, STICKER_PACK_KEY, STICKER_ID, URL}; - - private static final String[] PROJECTION_AUDIO_EXTRAS = new String[] {AUDIO_VISUAL_SAMPLES, AUDIO_DURATION}; + CAPTION, STICKER_PACK_ID, STICKER_PACK_KEY, STICKER_ID, URL, + AUDIO_DURATION}; public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ROW_ID + " INTEGER PRIMARY KEY, " + MMS_ID + " INTEGER, " + "seq" + " INTEGER DEFAULT 0, " + @@ -155,11 +156,11 @@ public class AttachmentDatabase extends Database { "CREATE INDEX IF NOT EXISTS part_sticker_pack_id_index ON " + TABLE_NAME + " (" + STICKER_PACK_ID + ");", }; - private final ExecutorService thumbnailExecutor = Util.newSingleThreadedLifoExecutor(); + final ExecutorService thumbnailExecutor = Util.newSingleThreadedLifoExecutor(); private final AttachmentSecret attachmentSecret; - public AttachmentDatabase(Context context, SQLCipherOpenHelper databaseHelper, AttachmentSecret attachmentSecret) { + public AttachmentDatabase(Context context, Provider databaseHelper, AttachmentSecret attachmentSecret) { super(context, databaseHelper); this.attachmentSecret = attachmentSecret; } @@ -196,20 +197,9 @@ public AttachmentDatabase(Context context, SQLCipherOpenHelper databaseHelper, A } } - public void setTransferProgressFailed(AttachmentId attachmentId, long mmsId) - throws MmsException - { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); - ContentValues values = new ContentValues(); - values.put(TRANSFER_STATE, AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED); - - database.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings()); - notifyConversationListeners(DatabaseComponent.get(context).mmsDatabase().getThreadIdForMessage(mmsId)); - } - public @Nullable DatabaseAttachment getAttachment(@NonNull AttachmentId attachmentId) { - SQLiteDatabase database = databaseHelper.getReadableDatabase(); + SQLiteDatabase database = getReadableDatabase(); Cursor cursor = null; try { @@ -231,7 +221,7 @@ public void setTransferProgressFailed(AttachmentId attachmentId, long mmsId) } public @NonNull List getAttachmentsForMessage(long mmsId) { - SQLiteDatabase database = databaseHelper.getReadableDatabase(); + SQLiteDatabase database = getReadableDatabase(); List results = new LinkedList<>(); Cursor cursor = null; @@ -254,95 +244,60 @@ public void setTransferProgressFailed(AttachmentId attachmentId, long mmsId) } } - public @NonNull List getPendingAttachments() { - final SQLiteDatabase database = databaseHelper.getReadableDatabase(); - final List attachments = new LinkedList<>(); - + public @NonNull List getAllAttachments() { + SQLiteDatabase database = getReadableDatabase(); Cursor cursor = null; - try { - cursor = database.query(TABLE_NAME, PROJECTION, TRANSFER_STATE + " = ?", new String[] {String.valueOf(AttachmentTransferProgress.TRANSFER_PROGRESS_STARTED)}, null, null, null); - while (cursor != null && cursor.moveToNext()) { - attachments.addAll(getAttachment(cursor)); - } - } finally { - if (cursor != null) cursor.close(); - } - - return attachments; - } + List attachments = new ArrayList<>(); - void deleteAttachmentsForMessages(String[] messageIds) { - StringBuilder queryBuilder = new StringBuilder(); - for (int i = 0; i < messageIds.length; i++) { - queryBuilder.append(MMS_ID+" = ").append(messageIds[i]); - if (i+1 < messageIds.length) { - queryBuilder.append(" OR "); - } - } - String idsAsString = queryBuilder.toString(); - SQLiteDatabase database = databaseHelper.getReadableDatabase(); - Cursor cursor = null; - List attachmentInfos = new ArrayList<>(); try { - cursor = database.query(TABLE_NAME, new String[] { DATA, THUMBNAIL, CONTENT_TYPE}, idsAsString, null, null, null, null); + // Query all rows in the attachment table. + cursor = database.query(TABLE_NAME, PROJECTION, null, null, null, null, null); + while (cursor != null && cursor.moveToNext()) { - attachmentInfos.add(new MmsAttachmentInfo(cursor.getString(0), cursor.getString(1), cursor.getString(2))); + List list = getAttachment(cursor); + if (list != null && !list.isEmpty()) { + attachments.addAll(list); + } } } finally { if (cursor != null) { cursor.close(); } } - deleteAttachmentsOnDisk(attachmentInfos); - database.delete(TABLE_NAME, idsAsString, null); - notifyAttachmentListeners(); + + return attachments; } - @SuppressWarnings("ResultOfMethodCallIgnored") - void deleteAttachmentsForMessage(long mmsId) { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); - Cursor cursor = null; + void deleteAttachmentsForMessages(@NonNull Collection mmsMessageIDs) { + final String sql = "DELETE FROM " + TABLE_NAME + " " + + "WHERE " + MMS_ID + " IN (SELECT value FROM json_each(?)) " + + "RETURNING " + DATA + ", " + THUMBNAIL + ", " + CONTENT_TYPE; - try { - cursor = database.query(TABLE_NAME, new String[] {DATA, THUMBNAIL, CONTENT_TYPE}, MMS_ID + " = ?", - new String[] {mmsId+""}, null, null, null); + final String arg = new JSONArray(mmsMessageIDs).toString(); - while (cursor != null && cursor.moveToNext()) { - deleteAttachmentOnDisk(cursor.getString(0), cursor.getString(1), cursor.getString(2)); + final List deletedAttachments; + + try (final Cursor cursor = getWritableDatabase().rawQuery(sql, arg)) { + deletedAttachments = new ArrayList<>(cursor.getCount()); + while (cursor.moveToNext()) { + deletedAttachments.add(new MmsAttachmentInfo(cursor.getString(0), cursor.getString(1), cursor.getString(2))); } - } finally { - if (cursor != null) - cursor.close(); } - database.delete(TABLE_NAME, MMS_ID + " = ?", new String[] {mmsId + ""}); - 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); + deleteAttachmentsOnDisk(deletedAttachments); - while (cursor != null && cursor.moveToNext()) { - deleteAttachmentOnDisk(cursor.getString(0), cursor.getString(1), cursor.getString(2)); - } - } finally { - if (cursor != null) - cursor.close(); + if (!deletedAttachments.isEmpty()) { + notifyAttachmentListeners(); } + } - database.delete(TABLE_NAME, MMS_ID + " IN (?)", new String[] {mmsIdString}); - notifyAttachmentListeners(); + @SuppressWarnings("ResultOfMethodCallIgnored") + void deleteAttachmentsForMessage(long mmsId) { + deleteAttachmentsForMessages(Collections.singletonList(mmsId)); } public void deleteAttachment(@NonNull AttachmentId id) { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); + SQLiteDatabase database = getWritableDatabase(); try (Cursor cursor = database.query(TABLE_NAME, new String[]{DATA, THUMBNAIL, CONTENT_TYPE}, @@ -366,21 +321,6 @@ public void deleteAttachment(@NonNull AttachmentId id) { } } - @SuppressWarnings("ResultOfMethodCallIgnored") - void deleteAllAttachments() { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); - database.delete(TABLE_NAME, null, null); - - File attachmentsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); - File[] attachments = attachmentsDirectory.listFiles(); - - for (File attachment : attachments) { - attachment.delete(); - } - - notifyAttachmentListeners(); - } - private void deleteAttachmentsOnDisk(List mmsAttachmentInfos) { for (MmsAttachmentInfo info : mmsAttachmentInfos) { if (info.getDataFile() != null && !TextUtils.isEmpty(info.getDataFile())) { @@ -424,7 +364,7 @@ public void insertAttachmentsForPlaceholder(long mmsId, @NonNull AttachmentId at throws MmsException { DatabaseAttachment placeholder = getAttachment(attachmentId); - SQLiteDatabase database = databaseHelper.getWritableDatabase(); + SQLiteDatabase database = getWritableDatabase(); ContentValues values = new ContentValues(); DataInfo dataInfo = setAttachmentData(inputStream); @@ -437,7 +377,7 @@ public void insertAttachmentsForPlaceholder(long mmsId, @NonNull AttachmentId at values.put(DATA_RANDOM, dataInfo.random); } - values.put(TRANSFER_STATE, AttachmentTransferProgress.TRANSFER_PROGRESS_DONE); + values.put(TRANSFER_STATE, AttachmentState.DONE.getValue()); values.put(CONTENT_LOCATION, (String)null); values.put(CONTENT_DISPOSITION, (String)null); values.put(DIGEST, (byte[])null); @@ -457,10 +397,10 @@ public void insertAttachmentsForPlaceholder(long mmsId, @NonNull AttachmentId at } public void updateAttachmentAfterUploadSucceeded(@NonNull AttachmentId id, @NonNull Attachment attachment) { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); + SQLiteDatabase database = getWritableDatabase(); ContentValues values = new ContentValues(); - values.put(TRANSFER_STATE, AttachmentTransferProgress.TRANSFER_PROGRESS_DONE); + values.put(TRANSFER_STATE, AttachmentState.DONE.getValue()); values.put(CONTENT_LOCATION, attachment.getLocation()); values.put(DIGEST, attachment.getDigest()); values.put(CONTENT_DISPOSITION, attachment.getKey()); @@ -473,29 +413,32 @@ public void updateAttachmentAfterUploadSucceeded(@NonNull AttachmentId id, @NonN } public void handleFailedAttachmentUpload(@NonNull AttachmentId id) { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); + SQLiteDatabase database = getWritableDatabase(); ContentValues values = new ContentValues(); - values.put(TRANSFER_STATE, AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED); + values.put(TRANSFER_STATE, AttachmentState.FAILED.getValue()); database.update(TABLE_NAME, values, PART_ID_WHERE, id.toStrings()); } - @NonNull Map insertAttachmentsForMessage(long mmsId, @NonNull List attachments, @NonNull List quoteAttachment) - throws MmsException - { + @NonNull Map insertAttachmentsForMessage( + long mmsId, + @NonNull List attachments, + @NonNull List quoteAttachment, + @NonNull List thumbnailJobsCollector + ) throws MmsException { Log.d(TAG, "insertParts(" + attachments.size() + ")"); Map insertedAttachments = new HashMap<>(); for (Attachment attachment : attachments) { - AttachmentId attachmentId = insertAttachment(mmsId, attachment, attachment.isQuote()); + AttachmentId attachmentId = insertAttachment(mmsId, attachment, attachment.isQuote(), thumbnailJobsCollector); insertedAttachments.put(attachment, attachmentId); Log.i(TAG, "Inserted attachment at ID: " + attachmentId); } for (Attachment attachment : quoteAttachment) { - AttachmentId attachmentId = insertAttachment(mmsId, attachment, true); + AttachmentId attachmentId = insertAttachment(mmsId, attachment, true, thumbnailJobsCollector); insertedAttachments.put(attachment, attachmentId); Log.i(TAG, "Inserted quoted attachment at ID: " + attachmentId); } @@ -503,35 +446,11 @@ public void handleFailedAttachmentUpload(@NonNull AttachmentId id) { return insertedAttachments; } - /** - * Insert attachments in database and return the IDs of the inserted attachments - * - * @param mmsId message ID - * @param attachments attachments to persist - * @return IDs of the persisted attachments - * @throws MmsException - */ - @NonNull List insertAttachments(long mmsId, @NonNull List attachments) - throws MmsException - { - Log.d(TAG, "insertParts(" + attachments.size() + ")"); - - List insertedAttachmentsIDs = new LinkedList<>(); - - for (Attachment attachment : attachments) { - AttachmentId attachmentId = insertAttachment(mmsId, attachment, attachment.isQuote()); - insertedAttachmentsIDs.add(attachmentId.getRowId()); - Log.i(TAG, "Inserted attachment at ID: " + attachmentId); - } - - return insertedAttachmentsIDs; - } - public @NonNull Attachment updateAttachmentData(@NonNull Attachment attachment, @NonNull MediaStream mediaStream) throws MmsException { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); + SQLiteDatabase database = getWritableDatabase(); DatabaseAttachment databaseAttachment = (DatabaseAttachment) attachment; DataInfo dataInfo = getAttachmentDataFileInfo(databaseAttachment.getAttachmentId(), DATA); @@ -539,7 +458,9 @@ public void handleFailedAttachmentUpload(@NonNull AttachmentId id) { throw new MmsException("No attachment data found!"); } - dataInfo = setAttachmentData(dataInfo.file, mediaStream.getStream()); + final File oldFile = dataInfo.file; + + dataInfo = setAttachmentData(mediaStream.getStream()); ContentValues contentValues = new ContentValues(); contentValues.put(SIZE, dataInfo.length); @@ -547,9 +468,18 @@ public void handleFailedAttachmentUpload(@NonNull AttachmentId id) { contentValues.put(WIDTH, mediaStream.getWidth()); contentValues.put(HEIGHT, mediaStream.getHeight()); contentValues.put(DATA_RANDOM, dataInfo.random); + contentValues.put(DATA, dataInfo.file.getAbsolutePath()); database.update(TABLE_NAME, contentValues, PART_ID_WHERE, databaseAttachment.getAttachmentId().toStrings()); + if (oldFile != null && oldFile.exists()) { + try { + oldFile.delete(); + } catch (Exception e) { + Log.w(TAG, "Error deleting an old attachment file", e); + } + } + return new DatabaseAttachment(databaseAttachment.getAttachmentId(), databaseAttachment.getMmsId(), databaseAttachment.hasData(), @@ -557,7 +487,7 @@ public void handleFailedAttachmentUpload(@NonNull AttachmentId id) { mediaStream.getMimeType(), databaseAttachment.getTransferState(), dataInfo.length, - databaseAttachment.getFileName(), + databaseAttachment.getFilename(), databaseAttachment.getLocation(), databaseAttachment.getKey(), databaseAttachment.getRelay(), @@ -568,14 +498,15 @@ public void handleFailedAttachmentUpload(@NonNull AttachmentId id) { mediaStream.getHeight(), databaseAttachment.isQuote(), databaseAttachment.getCaption(), - databaseAttachment.getUrl()); + databaseAttachment.getUrl(), + databaseAttachment.getAudioDurationMs()); } public void markAttachmentUploaded(long messageId, Attachment attachment) { ContentValues values = new ContentValues(1); - SQLiteDatabase database = databaseHelper.getWritableDatabase(); + SQLiteDatabase database = getWritableDatabase(); - values.put(TRANSFER_STATE, AttachmentTransferProgress.TRANSFER_PROGRESS_DONE); + values.put(TRANSFER_STATE, AttachmentState.DONE.getValue()); database.update(TABLE_NAME, values, PART_ID_WHERE, ((DatabaseAttachment)attachment).getAttachmentId().toStrings()); notifyConversationListeners(DatabaseComponent.get(context).mmsDatabase().getThreadIdForMessage(messageId)); @@ -584,7 +515,7 @@ public void markAttachmentUploaded(long messageId, Attachment attachment) { public void setTransferState(long messageId, @NonNull AttachmentId attachmentId, int transferState) { final ContentValues values = new ContentValues(1); - final SQLiteDatabase database = databaseHelper.getWritableDatabase(); + final SQLiteDatabase database = getWritableDatabase(); values.put(TRANSFER_STATE, transferState); database.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings()); @@ -623,7 +554,7 @@ public void setTransferState(long messageId, @NonNull AttachmentId attachmentId, private @Nullable DataInfo getAttachmentDataFileInfo(@NonNull AttachmentId attachmentId, @NonNull String dataType) { - SQLiteDatabase database = databaseHelper.getReadableDatabase(); + SQLiteDatabase database = getReadableDatabase(); Cursor cursor = null; String randomColumn; @@ -673,20 +604,12 @@ public void setTransferState(long messageId, @NonNull AttachmentId attachmentId, try { File partsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); File dataFile = File.createTempFile("part", ".mms", partsDirectory); - return setAttachmentData(dataFile, in); - } catch (IOException e) { - throw new MmsException(e); - } - } - private @NonNull DataInfo setAttachmentData(@NonNull File destination, @NonNull InputStream in) - throws MmsException - { - try { - Pair out = ModernEncryptingPartOutputStream.createFor(attachmentSecret, destination, false); + Log.d("AttachmentDatabase", "Writing attachment data to: " + dataFile.getAbsolutePath()); + Pair out = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false); long length = Util.copy(in, out.second); - return new DataInfo(destination, length, out.first); + return new DataInfo(dataFile, length, out.first); } catch (IOException e) { throw new MmsException(e); } @@ -724,13 +647,15 @@ public List getAttachment(@NonNull Cursor cursor) { object.getInt(HEIGHT), object.getInt(QUOTE) == 1, object.getString(CAPTION), - "")); // TODO: Not sure if this will break something + "", // TODO: Not sure if this will break something + object.getLong(AUDIO_DURATION))); } } return new ArrayList<>(result); } else { int urlIndex = cursor.getColumnIndex(URL); + int audioDurationIndex = cursor.getColumnIndexOrThrow(AUDIO_DURATION); return Collections.singletonList(new DatabaseAttachment(new AttachmentId(cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID)), cursor.getLong(cursor.getColumnIndexOrThrow(UNIQUE_ID))), cursor.getLong(cursor.getColumnIndexOrThrow(MMS_ID)), @@ -750,7 +675,9 @@ public List getAttachment(@NonNull Cursor cursor) { cursor.getInt(cursor.getColumnIndexOrThrow(HEIGHT)), cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE)) == 1, cursor.getString(cursor.getColumnIndexOrThrow(CAPTION)), - urlIndex > 0 ? cursor.getString(urlIndex) : "")); + urlIndex > 0 ? cursor.getString(urlIndex) : "", + cursor.isNull(audioDurationIndex) ? -1L : cursor.getLong(audioDurationIndex)) + ); } } catch (JSONException e) { throw new AssertionError(e); @@ -758,12 +685,15 @@ public List getAttachment(@NonNull Cursor cursor) { } - private AttachmentId insertAttachment(long mmsId, Attachment attachment, boolean quote) - throws MmsException - { + private AttachmentId insertAttachment( + long mmsId, + Attachment attachment, + boolean quote, + @NonNull List thumbnailJobsCollector + ) throws MmsException { Log.d(TAG, "Inserting attachment for mms id: " + mmsId); - SQLiteDatabase database = databaseHelper.getWritableDatabase(); + SQLiteDatabase database = getWritableDatabase(); DataInfo dataInfo = null; long uniqueId = System.currentTimeMillis(); @@ -781,7 +711,7 @@ private AttachmentId insertAttachment(long mmsId, Attachment attachment, boolean contentValues.put(DIGEST, attachment.getDigest()); contentValues.put(CONTENT_DISPOSITION, attachment.getKey()); contentValues.put(NAME, attachment.getRelay()); - contentValues.put(FILE_NAME, ExternalStorageUtil.getCleanFileName(attachment.getFileName())); + contentValues.put(FILE_NAME, ExternalStorageUtil.getCleanFileName(attachment.getFilename())); contentValues.put(SIZE, attachment.getSize()); contentValues.put(FAST_PREFLIGHT_ID, attachment.getFastPreflightId()); contentValues.put(VOICE_NOTE, attachment.isVoiceNote() ? 1 : 0); @@ -790,6 +720,10 @@ private AttachmentId insertAttachment(long mmsId, Attachment attachment, boolean contentValues.put(QUOTE, quote); contentValues.put(CAPTION, attachment.getCaption()); contentValues.put(URL, attachment.getUrl()); + long audioDuration = attachment.getAudioDurationMs(); + if (audioDuration > 0) { + contentValues.put(AUDIO_DURATION, audioDuration); + } if (dataInfo != null) { contentValues.put(DATA, dataInfo.file.getAbsolutePath()); @@ -811,28 +745,30 @@ private AttachmentId insertAttachment(long mmsId, Attachment attachment, boolean dimens = BitmapUtil.getDimensions(attachmentStream); } updateAttachmentThumbnail(attachmentId, - PartAuthority.getAttachmentStream(context, thumbnailUri), - (float) dimens.first / (float) dimens.second); + PartAuthority.getAttachmentStream(context, thumbnailUri), + (float) dimens.first / (float) dimens.second); hasThumbnail = true; } catch (IOException | BitmapDecodingException e) { Log.w(TAG, "Failed to save existing thumbnail.", e); } } + // collect the job if (!hasThumbnail && dataInfo != null) { if (MediaUtil.hasVideoThumbnail(attachment.getDataUri())) { Bitmap bitmap = MediaUtil.getVideoThumbnail(context, attachment.getDataUri()); - if (bitmap != null) { ThumbnailData thumbnailData = new ThumbnailData(bitmap); updateAttachmentThumbnail(attachmentId, thumbnailData.toDataStream(), thumbnailData.getAspectRatio()); } else { Log.w(TAG, "Retrieving video thumbnail failed, submitting thumbnail generation job..."); - thumbnailExecutor.submit(new ThumbnailFetchCallable(attachmentId)); + // Collect for later processing instead of immediate submission + thumbnailJobsCollector.add(attachmentId); } } else { Log.i(TAG, "Submitting thumbnail generation job..."); - thumbnailExecutor.submit(new ThumbnailFetchCallable(attachmentId)); + // Collect for later processing + thumbnailJobsCollector.add(attachmentId); } } @@ -848,7 +784,7 @@ protected void updateAttachmentThumbnail(AttachmentId attachmentId, InputStream DataInfo thumbnailFile = setAttachmentData(in); - SQLiteDatabase database = databaseHelper.getWritableDatabase(); + SQLiteDatabase database = getWritableDatabase(); ContentValues values = new ContentValues(2); values.put(THUMBNAIL, thumbnailFile.file.getAbsolutePath()); @@ -874,10 +810,10 @@ protected void updateAttachmentThumbnail(AttachmentId attachmentId, InputStream */ @Synchronized public @Nullable DatabaseAttachmentAudioExtras getAttachmentAudioExtras(@NonNull AttachmentId attachmentId) { - try (Cursor cursor = databaseHelper.getReadableDatabase() + try (Cursor cursor = getReadableDatabase() // We expect all the audio extra values to be present (not null) or reject the whole record. .query(TABLE_NAME, - PROJECTION_AUDIO_EXTRAS, + new String[] {AUDIO_VISUAL_SAMPLES, AUDIO_DURATION}, PART_ID_WHERE + " AND " + AUDIO_VISUAL_SAMPLES + " IS NOT NULL" + " AND " + AUDIO_DURATION + " IS NOT NULL" + @@ -904,7 +840,7 @@ public boolean setAttachmentAudioExtras(@NonNull DatabaseAttachmentAudioExtras e values.put(AUDIO_VISUAL_SAMPLES, extras.getVisualSamples()); values.put(AUDIO_DURATION, extras.getDurationMs()); - int alteredRows = databaseHelper.getWritableDatabase().update(TABLE_NAME, + int alteredRows = getWritableDatabase().update(TABLE_NAME, values, PART_ID_WHERE + " AND " + PART_AUDIO_ONLY_WHERE, extras.getAttachmentId().toStrings()); @@ -916,16 +852,6 @@ public boolean setAttachmentAudioExtras(@NonNull DatabaseAttachmentAudioExtras e return alteredRows > 0; } - /** - * Updates audio extra columns for the "audio/*" mime type attachments only. - * @return true if the update operation was successful. - */ - @Synchronized - public boolean setAttachmentAudioExtras(@NonNull DatabaseAttachmentAudioExtras extras) { - return setAttachmentAudioExtras(extras, -1); // -1 for no update - } - - @VisibleForTesting class ThumbnailFetchCallable implements Callable { private final AttachmentId attachmentId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/BlindedIdMappingDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/BlindedIdMappingDatabase.kt index a5919d4394..e4a007d47b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/BlindedIdMappingDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/BlindedIdMappingDatabase.kt @@ -6,8 +6,9 @@ import android.database.Cursor import androidx.core.database.getStringOrNull import org.session.libsession.messaging.BlindedIdMapping import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import javax.inject.Provider -class BlindedIdMappingDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { +class BlindedIdMappingDatabase(context: Context, helper: Provider) : Database(context, helper) { companion object { const val TABLE_NAME = "blinded_id_mapping" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt index d4c455308c..47d1cfcfb7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt @@ -5,13 +5,13 @@ import androidx.core.content.contentValuesOf import androidx.core.database.getBlobOrNull import androidx.core.database.getLongOrNull import androidx.sqlite.db.transaction -import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import javax.inject.Provider typealias ConfigVariant = String -class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) { +class ConfigDatabase(context: Context, helper: Provider): Database(context, helper) { companion object { private const val VARIANT = "variant" @@ -27,14 +27,14 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co private const val VARIANT_AND_PUBKEY_WHERE = "$VARIANT = ? AND $PUBKEY = ?" private const val VARIANT_IN_AND_PUBKEY_WHERE = "$VARIANT in (?) AND $PUBKEY = ?" - val CONTACTS_VARIANT: ConfigVariant = SharedConfigMessage.Kind.CONTACTS.name - val USER_GROUPS_VARIANT: ConfigVariant = SharedConfigMessage.Kind.GROUPS.name - val USER_PROFILE_VARIANT: ConfigVariant = SharedConfigMessage.Kind.USER_PROFILE.name - val CONVO_INFO_VARIANT: ConfigVariant = SharedConfigMessage.Kind.CONVO_INFO_VOLATILE.name + const val CONTACTS_VARIANT: ConfigVariant = "CONTACTS" + const val USER_GROUPS_VARIANT: ConfigVariant = "GROUPS" + const val USER_PROFILE_VARIANT: ConfigVariant = "USER_PROFILE" + const val CONVO_INFO_VARIANT: ConfigVariant = "CONVO_INFO_VOLATILE" - val KEYS_VARIANT: ConfigVariant = SharedConfigMessage.Kind.ENCRYPTION_KEYS.name - val INFO_VARIANT: ConfigVariant = SharedConfigMessage.Kind.CLOSED_GROUP_INFO.name - val MEMBER_VARIANT: ConfigVariant = SharedConfigMessage.Kind.CLOSED_GROUP_MEMBERS.name + const val KEYS_VARIANT: ConfigVariant = "ENCRYPTION_KEYS" + const val INFO_VARIANT: ConfigVariant = "CLOSED_GROUP_INFO" + const val MEMBER_VARIANT: ConfigVariant = "CLOSED_GROUP_MEMBERS" } fun storeConfig(variant: ConfigVariant, publicKey: String, data: ByteArray, timestamp: Long) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CursorList.java b/app/src/main/java/org/thoughtcrime/securesms/database/CursorList.java index 804e00f018..8d32b13fbc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CursorList.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CursorList.java @@ -201,4 +201,3 @@ public interface ModelBuilder { T build(@NonNull Cursor cursor); } } - 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 e490dae611..82b5b2319e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Database.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Database.java @@ -23,29 +23,32 @@ import android.os.Build; import androidx.annotation.NonNull; +import androidx.sqlite.db.SupportSQLiteDatabase; +import androidx.sqlite.db.SupportSQLiteOpenHelper; 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; import java.util.Arrays; import java.util.Set; +import javax.inject.Provider; + public abstract class Database { protected static final String ID_WHERE = "_id = ?"; protected static final String ID_IN = "_id IN (?)"; - protected SQLCipherOpenHelper databaseHelper; + private final Provider databaseHelper; protected final Context context; private final WindowDebouncer conversationListNotificationDebouncer; private final Runnable conversationListUpdater; @SuppressLint("WrongConstant") - public Database(Context context, SQLCipherOpenHelper databaseHelper) { + public Database(Context context, Provider databaseHelper) { this.context = context; this.conversationListUpdater = () -> { context.getContentResolver().notifyChange(DatabaseContentProviders.ConversationList.CONTENT_URI, null); @@ -111,16 +114,13 @@ protected void notifyAttachmentListeners() { context.getContentResolver().notifyChange(DatabaseContentProviders.Attachment.CONTENT_URI, null); } - public void reset(SQLCipherOpenHelper databaseHelper) { - this.databaseHelper = databaseHelper; - } protected SQLiteDatabase getReadableDatabase() { - return databaseHelper.getReadableDatabase(); + return databaseHelper.get().getReadableDatabase(); } protected SQLiteDatabase getWritableDatabase() { - return databaseHelper.getWritableDatabase(); + return databaseHelper.get().getWritableDatabase(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseContentProviders.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseContentProviders.java index 0f4d7c9f25..42fc3901c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseContentProviders.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseContentProviders.java @@ -8,6 +8,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import network.loki.messenger.BuildConfig; + /** * Starting in API 26, a {@link ContentProvider} needs to be defined for each authority you wish to * observe changes on. These classes essentially do nothing except exist so Android doesn't complain. @@ -15,31 +17,33 @@ public class DatabaseContentProviders { public static class ConversationList extends NoopContentProvider { - public static final Uri CONTENT_URI = Uri.parse("content://network.loki.securesms.database.conversationlist"); + public static final Uri CONTENT_URI = Uri.parse("content://network.loki.securesms.database.conversationlist" + BuildConfig.AUTHORITY_POSTFIX); } public static class Conversation extends NoopContentProvider { - private static final String CONTENT_URI_STRING = "content://network.loki.securesms.database.conversation/"; + public static final Uri CONTENT_URI = Uri.parse("content://network.loki.securesms.database.conversation" + BuildConfig.AUTHORITY_POSTFIX); public static Uri getUriForThread(long threadId) { - return Uri.parse(CONTENT_URI_STRING + threadId); + return CONTENT_URI.buildUpon() + .appendPath(String.valueOf(threadId)) + .build(); } } public static class Attachment extends NoopContentProvider { - public static final Uri CONTENT_URI = Uri.parse("content://network.loki.securesms.database.attachment"); + public static final Uri CONTENT_URI = Uri.parse("content://network.loki.securesms.database.attachment" + BuildConfig.AUTHORITY_POSTFIX); } public static class Sticker extends NoopContentProvider { - public static final Uri CONTENT_URI = Uri.parse("content://network.loki.securesms.database.sticker"); + public static final Uri CONTENT_URI = Uri.parse("content://network.loki.securesms.database.sticker" + BuildConfig.AUTHORITY_POSTFIX); } public static class StickerPack extends NoopContentProvider { - public static final Uri CONTENT_URI = Uri.parse("content://network.loki.securesms.database.stickerpack"); + public static final Uri CONTENT_URI = Uri.parse("content://network.loki.securesms.database.stickerpack" + BuildConfig.AUTHORITY_POSTFIX); } public static class Recipient extends NoopContentProvider { - public static final Uri CONTENT_URI = Uri.parse("content://network.loki.securesms.database.recipient"); + public static final Uri CONTENT_URI = Uri.parse("content://network.loki.securesms.database.recipient" + BuildConfig.AUTHORITY_POSTFIX); } private static abstract class NoopContentProvider extends ContentProvider { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java deleted file mode 100644 index 76fa8c5c0b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java +++ /dev/null @@ -1,33 +0,0 @@ - -/* - * Copyright (C) 2018 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 . - */ -package org.thoughtcrime.securesms.database; - -import android.content.Context; - -import net.zetetic.database.sqlcipher.SQLiteDatabase; - -import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; - -public class DatabaseFactory { - public static void upgradeRestored(Context context, SQLiteDatabase database){ - SQLCipherOpenHelper databaseHelper = DatabaseComponent.get(context).openHelper(); - databaseHelper.onUpgrade(database, database.getVersion(), -1); - databaseHelper.markCurrent(database); - } -} 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 be083256db..338144b3cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java @@ -9,6 +9,8 @@ import java.util.List; import java.util.Set; +import javax.inject.Provider; + public class DraftDatabase extends Database { public static final String TABLE_NAME = "drafts"; @@ -24,12 +26,12 @@ public class DraftDatabase extends Database { "CREATE INDEX IF NOT EXISTS draft_thread_index ON " + TABLE_NAME + " (" + THREAD_ID + ");", }; - public DraftDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + public DraftDatabase(Context context, Provider databaseHelper) { super(context, databaseHelper); } public void insertDrafts(long threadId, List drafts) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); for (Draft draft : drafts) { ContentValues values = new ContentValues(3); @@ -42,12 +44,12 @@ public void insertDrafts(long threadId, List drafts) { } public void clearDrafts(long threadId) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""}); } void clearDrafts(Set threadIds) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); StringBuilder where = new StringBuilder(); List arguments = new LinkedList<>(); @@ -63,12 +65,12 @@ void clearDrafts(Set threadIds) { } void clearAllDrafts() { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); db.delete(TABLE_NAME, null, null); } public List getDrafts(long threadId) { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SQLiteDatabase db = getReadableDatabase(); List results = new LinkedList<>(); Cursor cursor = null; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/EarlyReceiptCache.java b/app/src/main/java/org/thoughtcrime/securesms/database/EarlyReceiptCache.java index 2792a8fd3b..a26c60cc9f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/EarlyReceiptCache.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/EarlyReceiptCache.java @@ -15,7 +15,7 @@ public class EarlyReceiptCache { public synchronized void increment(long timestamp, Address origin) { Log.i(TAG, this+""); - Log.i(TAG, String.format("Early receipt: (%d, %s)", timestamp, origin.serialize())); + Log.i(TAG, String.format("Early receipt: (%d, %s)", timestamp, origin.toString())); Map receipts = cache.get(timestamp); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/EmojiSearchDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/EmojiSearchDatabase.kt index f6e389a47d..e4569ae5ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/EmojiSearchDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/EmojiSearchDatabase.kt @@ -5,13 +5,14 @@ import androidx.core.content.contentValuesOf import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.model.EmojiSearchData import org.thoughtcrime.securesms.util.CursorUtil +import javax.inject.Provider import kotlin.math.max import kotlin.math.roundToInt /** * Contains all info necessary for full-text search of emoji tags. */ -class EmojiSearchDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { +class EmojiSearchDatabase(context: Context, helper: Provider) : Database(context, helper) { companion object { const val TABLE_NAME = "emoji_search" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt index 6af3048e65..a8ff610ac6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt @@ -9,8 +9,9 @@ import org.session.libsession.utilities.GroupUtil.LEGACY_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 +import javax.inject.Provider -class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { +class ExpirationConfigurationDatabase(context: Context, helper: Provider) : Database(context, helper) { companion object { const val TABLE_NAME = "expiration_configuration" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationInfo.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationInfo.kt index 40d38e8b70..ddd5894b80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationInfo.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationInfo.kt @@ -1,11 +1,12 @@ package org.thoughtcrime.securesms.database +import org.thoughtcrime.securesms.database.model.MessageId + data class ExpirationInfo( - val id: Long, + val id: MessageId, 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/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index f5c71f1676..867f29e17d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -32,9 +32,11 @@ import java.util.LinkedList; import java.util.List; +import javax.inject.Provider; + /** * @deprecated This database table management is only used for - * legacy group management. It is not used in groupv2. For group v2 data, you generally need + * legacy group and community management. It is not used in groupv2. For group v2 data, you generally need * to query config system directly. The Storage class may also be more up-to-date. * */ @@ -100,12 +102,12 @@ public static String getCreateUpdatedTimestampCommand() { "ADD COLUMN " + UPDATED + " INTEGER DEFAULT 0;"; } - public GroupDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + public GroupDatabase(Context context, Provider databaseHelper) { super(context, databaseHelper); } public Optional getGroup(String groupId) { - try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, GROUP_ID + " = ?", + try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, null, GROUP_ID + " = ?", new String[] {groupId}, null, null, null)) { @@ -128,7 +130,7 @@ public boolean isUnknownGroup(String groupId) { public Reader getGroupsFilteredByTitle(String constraint) { @SuppressLint("Recycle") - Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, TITLE + " LIKE ?", + Cursor cursor = getReadableDatabase().query(TABLE_NAME, null, TITLE + " LIKE ?", new String[]{"%" + constraint + "%"}, null, null, null); @@ -137,7 +139,7 @@ public Reader getGroupsFilteredByTitle(String constraint) { public Reader getGroups() { @SuppressLint("Recycle") - Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null); + Cursor cursor = getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null); return new Reader(cursor); } @@ -152,35 +154,12 @@ public List getAllGroups(boolean includeInactive) { return groups; } - public Cursor getGroupsFilteredByMembers(List members) { - if (members == null || members.isEmpty()) { - return null; - } - - String[] queriesValues = new String[members.size()]; - - StringBuilder queries = new StringBuilder(); - for (int i=0; i < members.size(); i++) { - boolean isEnd = i == (members.size() - 1); - queries.append(MEMBERS + " LIKE ?"); - queriesValues[i] = "%"+members.get(i)+"%"; - if (!isEnd) { - queries.append(" OR "); - } - } - - return databaseHelper.getReadableDatabase().query(TABLE_NAME, null, - queries.toString(), - queriesValues, - null, null, null); - } - public @NonNull List getGroupMembers(String groupId, boolean includeSelf) { List
members = getCurrentMembers(groupId, false); List recipients = new LinkedList<>(); for (Address member : members) { - if (!includeSelf && Util.isOwnNumber(context, member.serialize())) + if (!includeSelf && Util.isOwnNumber(context, member.toString())) continue; if (member.isContact()) { @@ -243,7 +222,7 @@ public long create(@NonNull String groupId, @Nullable String title, @NonNull Lis contentValues.put(ADMINS, Address.toSerializedList(admins, ',')); } - long threadId = databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, contentValues); + long threadId = getWritableDatabase().insert(TABLE_NAME, null, contentValues); Recipient.applyCached(Address.fromSerialized(groupId), recipient -> { recipient.setName(title); @@ -257,7 +236,7 @@ public long create(@NonNull String groupId, @Nullable String title, @NonNull Lis } public boolean delete(@NonNull String groupId) { - int result = databaseHelper.getWritableDatabase().delete(TABLE_NAME, GROUP_ID + " = ?", new String[]{groupId}); + int result = getWritableDatabase().delete(TABLE_NAME, GROUP_ID + " = ?", new String[]{groupId}); if (result > 0) { Recipient.removeCached(Address.fromSerialized(groupId)); @@ -280,7 +259,7 @@ public void update(String groupId, String title, SignalServiceAttachmentPointer contentValues.put(AVATAR_URL, avatar.getUrl()); } - databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, + getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?", new String[] {groupId}); @@ -296,11 +275,16 @@ public void update(String groupId, String title, SignalServiceAttachmentPointer public void updateTitle(String groupID, String newValue) { ContentValues contentValues = new ContentValues(); contentValues.put(TITLE, newValue); - databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?", + getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?", new String[] {groupID}); Recipient recipient = Recipient.from(context, Address.fromSerialized(groupID), false); + final boolean nameChanged = !newValue.equals(recipient.getName()); recipient.setName(newValue); + + if (nameChanged) { + notifyConversationListListeners(); + } } public void updateProfilePicture(String groupID, Bitmap newValue) { @@ -319,7 +303,7 @@ public void updateProfilePicture(String groupID, byte[] newValue) { contentValues.put(AVATAR, newValue); contentValues.put(AVATAR_ID, avatarId); - databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?", + getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?", new String[] {groupID}); Recipient.applyCached(Address.fromSerialized(groupID), recipient -> recipient.setGroupAvatarId(avatarId == 0 ? null : avatarId)); @@ -328,7 +312,7 @@ public void updateProfilePicture(String groupID, byte[] newValue) { @Override public void removeProfilePicture(String groupID) { - databaseHelper.getWritableDatabase() + getWritableDatabase() .execSQL("UPDATE " + TABLE_NAME + " SET " + AVATAR + " = NULL, " + AVATAR_ID + " = NULL, " + @@ -346,7 +330,7 @@ public void removeProfilePicture(String groupID) { } public boolean hasDownloadedProfilePicture(String groupId) { - try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[]{AVATAR}, GROUP_ID + " = ?", + try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, new String[]{AVATAR}, GROUP_ID + " = ?", new String[] {groupId}, null, null, null)) { @@ -365,7 +349,7 @@ public void updateMembers(String groupId, List
members) { contents.put(MEMBERS, Address.toSerializedList(members, ',')); contents.put(ACTIVE, 1); - databaseHelper.getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", + getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", new String[] {groupId}); Recipient.applyCached(Address.fromSerialized(groupId), recipient -> { @@ -378,7 +362,7 @@ public void updateZombieMembers(String groupId, List
members) { ContentValues contents = new ContentValues(); contents.put(ZOMBIE_MEMBERS, Address.toSerializedList(members, ',')); - databaseHelper.getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", + getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", new String[] {groupId}); } @@ -389,21 +373,21 @@ public void updateAdmins(String groupId, List
admins) { contents.put(ADMINS, Address.toSerializedList(admins, ',')); contents.put(ACTIVE, 1); - databaseHelper.getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", new String[] {groupId}); + getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", new String[] {groupId}); } public void updateFormationTimestamp(String groupId, Long formationTimestamp) { ContentValues contents = new ContentValues(); contents.put(TIMESTAMP, formationTimestamp); - databaseHelper.getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", new String[] {groupId}); + getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", new String[] {groupId}); } public void updateTimestampUpdated(String groupId, Long updatedTimestamp) { ContentValues contents = new ContentValues(); contents.put(UPDATED, updatedTimestamp); - databaseHelper.getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", new String[] {groupId}); + getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", new String[] {groupId}); } public void removeMember(String groupId, Address source) { @@ -413,7 +397,7 @@ public void removeMember(String groupId, Address source) { ContentValues contents = new ContentValues(); contents.put(MEMBERS, Address.toSerializedList(currentMembers, ',')); - databaseHelper.getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", + getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", new String[] {groupId}); Recipient.applyCached(Address.fromSerialized(groupId), recipient -> { @@ -432,7 +416,7 @@ private List
getCurrentMembers(String groupId, boolean zombieMembers) { if (zombieMembers) membersColumn = ZOMBIE_MEMBERS; try { - cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[] {membersColumn}, + cursor = getReadableDatabase().query(TABLE_NAME, new String[] {membersColumn}, GROUP_ID + " = ?", new String[] {groupId}, null, null, null); @@ -460,14 +444,14 @@ public boolean isActive(String groupId) { } public void setActive(String groupId, boolean active) { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); + SQLiteDatabase database = getWritableDatabase(); ContentValues values = new ContentValues(); values.put(ACTIVE, active ? 1 : 0); database.update(TABLE_NAME, values, GROUP_ID + " = ?", new String[] {groupId}); } public boolean hasGroup(@NonNull String groupId) { - try (Cursor cursor = databaseHelper.getReadableDatabase().rawQuery( + try (Cursor cursor = getReadableDatabase().rawQuery( "SELECT 1 FROM " + TABLE_NAME + " WHERE " + GROUP_ID + " = ? LIMIT 1", new String[]{groupId} )) { @@ -479,7 +463,7 @@ public void migrateEncodedGroup(@NotNull String legacyEncodedGroupId, @NotNull S String query = GROUP_ID+" = ?"; ContentValues contentValues = new ContentValues(1); contentValues.put(GROUP_ID, newEncodedGroupId); - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); db.update(TABLE_NAME, contentValues, query, new String[]{legacyEncodedGroupId}); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt index e869f741c7..b8e10b3300 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupMemberDatabase.kt @@ -3,14 +3,19 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import android.database.Cursor +import androidx.collection.LruCache import org.json.JSONArray import org.session.libsession.messaging.open_groups.GroupMember import org.session.libsession.messaging.open_groups.GroupMemberRole import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.util.asSequence import java.util.EnumSet +import java.util.concurrent.locks.ReentrantReadWriteLock +import javax.inject.Provider +import kotlin.concurrent.read +import kotlin.concurrent.write -class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { +class GroupMemberDatabase(context: Context, helper: Provider) : Database(context, helper) { companion object { const val TABLE_NAME = "group_member" @@ -39,22 +44,37 @@ class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab } } - fun getGroupMemberRoles(groupId: String, profileId: String): List { + private val cacheByGroupId = LruCache>(100) + private val cacheLock = ReentrantReadWriteLock() + + fun getGroupMemberRole(groupId: String, profileId: String): GroupMemberRole? { + // Check cache first + cacheLock.read { + cacheByGroupId[groupId]?.let { members -> + return members[profileId] + } + } + val query = "$GROUP_ID = ? AND $PROFILE_ID = ?" val args = arrayOf(groupId, profileId) - val mappings: MutableList = mutableListOf() - readableDatabase.query(TABLE_NAME, allColumns, query, args, null, null, null).use { cursor -> - while (cursor.moveToNext()) { - mappings += readGroupMember(cursor) + if (cursor.moveToNext()) { + return readGroupMember(cursor).role } } - return mappings.map { it.role } + return null } - fun getGroupMembersRoles(groupId: String, memberIDs: Collection): Map> { + fun getGroupMembersRoles(groupId: String, memberIDs: Collection): Map { + // Check cache first + cacheLock.read { + cacheByGroupId[groupId]?.let { members -> + return members.filterKeys { it in memberIDs } + } + } + val sql = """ SELECT * FROM $TABLE_NAME WHERE $GROUP_ID = ? AND $PROFILE_ID IN (SELECT value FROM json_each(?)) @@ -63,38 +83,76 @@ class GroupMemberDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab return readableDatabase.rawQuery(sql, groupId, JSONArray(memberIDs).toString()).use { cursor -> cursor.asSequence() .map { readGroupMember(it) } - .groupBy(keySelector = { it.profileId }, valueTransform = { it.role }) + .associate { it.profileId to it.role } } } - fun setGroupMembers(members: List) { - writableDatabase.beginTransaction() - try { - val grouped = members.groupBy { it.role } - grouped.forEach { (role, members) -> - if (members.isEmpty()) return@forEach + fun getGroupMembersRoles(groupId: String): Map { + // Check cache first + cacheLock.read { + cacheByGroupId[groupId]?.let { members -> + return members + } + } + + val members = fetchGroupMembersFromDb(groupId) + + // Update cache + cacheLock.write { + cacheByGroupId.put(groupId, members) + } + + return members + } + + private fun fetchGroupMembersFromDb(groupId: String): Map { + return readableDatabase.query("SELECT $PROFILE_ID, $ROLE FROM $TABLE_NAME WHERE $GROUP_ID = ?", arrayOf(groupId)).use { cursor -> + buildMap { + while (cursor.moveToNext()) { + val profileId = cursor.getString(cursor.getColumnIndexOrThrow(PROFILE_ID)) + val role = + GroupMemberRole.valueOf(cursor.getString(cursor.getColumnIndexOrThrow(ROLE))) + put(profileId, role) + } + } + } + } - val toDeleteQuery = "$GROUP_ID = ? AND $ROLE = ?" - val toDeleteArgs = arrayOf(members.first().groupId, role.name) + fun updateGroupMembers( + groupId: String, + role: GroupMemberRole, + memberIDs: Collection + ) { + val values = ContentValues(3) - writableDatabase.delete(TABLE_NAME, toDeleteQuery, toDeleteArgs) + writableDatabase.beginTransaction() + try { + val toDeleteQuery = "$GROUP_ID = ? AND $ROLE = ?" + val toDeleteArgs = arrayOf(groupId, role.name) - members.forEach { member -> - val values = ContentValues().apply { - put(GROUP_ID, member.groupId) - put(PROFILE_ID, member.profileId) - put(ROLE, member.role.name) - } - val query = "$GROUP_ID = ? AND $PROFILE_ID = ?" - val args = arrayOf(member.groupId, member.profileId) + writableDatabase.delete(TABLE_NAME, toDeleteQuery, toDeleteArgs) - writableDatabase.insertOrUpdate(TABLE_NAME, values, query, args) + memberIDs.forEach { memberId -> + with(values) { + put(GROUP_ID, groupId) + put(PROFILE_ID, memberId) + put(ROLE, role.name) } - writableDatabase.setTransactionSuccessful() + writableDatabase.insertOrUpdate(TABLE_NAME, values, "$GROUP_ID = ? AND $PROFILE_ID = ?", arrayOf(groupId, memberId)) } + + writableDatabase.setTransactionSuccessful() } finally { writableDatabase.endTransaction() } + + updateCache(groupId) } + private fun updateCache(groupId: String) { + val members = fetchGroupMembersFromDb(groupId) + cacheLock.write { + cacheByGroupId.put(groupId, members) + } + } } \ No newline at end of file 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 a6fed5be83..30faee51ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptDatabase.java @@ -2,19 +2,19 @@ import android.content.ContentValues; import android.content.Context; -import android.database.Cursor; - -import androidx.annotation.NonNull; import net.zetetic.database.sqlcipher.SQLiteDatabase; -import org.apache.commons.lang3.StringUtils; +import org.json.JSONArray; +import org.jspecify.annotations.NonNull; import org.session.libsession.utilities.Address; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; -import java.util.LinkedList; +import java.util.Collection; import java.util.List; +import javax.inject.Provider; + public class GroupReceiptDatabase extends Database { public static final String TABLE_NAME = "group_receipts"; @@ -24,6 +24,8 @@ public class GroupReceiptDatabase extends Database { private static final String ADDRESS = "address"; private static final String STATUS = "status"; private static final String TIMESTAMP = "timestamp"; + + @Deprecated(forRemoval = true) private static final String UNIDENTIFIED = "unidentified"; public static final int STATUS_UNKNOWN = -1; @@ -38,17 +40,17 @@ public class GroupReceiptDatabase extends Database { "CREATE INDEX IF NOT EXISTS group_receipt_mms_id_index ON " + TABLE_NAME + " (" + MMS_ID + ");", }; - public GroupReceiptDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + public GroupReceiptDatabase(Context context, Provider databaseHelper) { super(context, databaseHelper); } public void insert(List
addresses, long mmsId, int status, long timestamp) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); for (Address address : addresses) { ContentValues values = new ContentValues(4); values.put(MMS_ID, mmsId); - values.put(ADDRESS, address.serialize()); + values.put(ADDRESS, address.toString()); values.put(STATUS, status); values.put(TIMESTAMP, timestamp); @@ -57,96 +59,19 @@ public void insert(List
addresses, long mmsId, int status, long timesta } public void update(Address address, long mmsId, int status, long timestamp) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); ContentValues values = new ContentValues(2); values.put(STATUS, status); values.put(TIMESTAMP, timestamp); db.update(TABLE_NAME, values, MMS_ID + " = ? AND " + ADDRESS + " = ? AND " + STATUS + " < ?", - new String[] {String.valueOf(mmsId), address.serialize(), String.valueOf(status)}); - } - - public void setUnidentified(Address address, long mmsId, boolean unidentified) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - ContentValues values = new ContentValues(1); - values.put(UNIDENTIFIED, unidentified ? 1 : 0); - - db.update(TABLE_NAME, values, MMS_ID + " = ? AND " + ADDRESS + " = ?", - new String[] {String.valueOf(mmsId), address.serialize()}); - - } - - public @NonNull List getGroupReceiptInfo(long mmsId) { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - List results = new LinkedList<>(); - - try (Cursor cursor = db.query(TABLE_NAME, null, MMS_ID + " = ?", new String[] {String.valueOf(mmsId)}, null, null, null)) { - while (cursor != null && cursor.moveToNext()) { - results.add(new GroupReceiptInfo(Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS))), - cursor.getInt(cursor.getColumnIndexOrThrow(STATUS)), - cursor.getLong(cursor.getColumnIndexOrThrow(TIMESTAMP)), - cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED)) == 1)); - } - } - - return results; - } - - void deleteRowsForMessages(String[] mmsIds) { - StringBuilder queryBuilder = new StringBuilder(); - for (int i = 0; i < mmsIds.length; i++) { - queryBuilder.append(MMS_ID+" = ").append(mmsIds[i]); - if (i+1 < mmsIds.length) { - queryBuilder.append(" OR "); - } - } - String idsAsString = queryBuilder.toString(); - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.delete(TABLE_NAME, idsAsString, null); + new String[] {String.valueOf(mmsId), address.toString(), String.valueOf(status)}); } - void deleteRowsForMessage(long mmsId) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - 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); - } - - public static class GroupReceiptInfo { - private final Address address; - private final int status; - private final long timestamp; - private final boolean unidentified; - - GroupReceiptInfo(Address address, int status, long timestamp, boolean unidentified) { - this.address = address; - this.status = status; - this.timestamp = timestamp; - this.unidentified = unidentified; - } + void deleteRowsForMessages(@NonNull Collection mmsIds) { + final String where = MMS_ID + " IN (SELECT value FROM json_each(?))"; + final String arg = new JSONArray(mmsIds).toString(); - public Address getAddress() { - return address; - } - - public int getStatus() { - return status; - } - - public long getTimestamp() { - return timestamp; - } - - public boolean isUnidentified() { - return unidentified; - } + getWritableDatabase().delete(TABLE_NAME, where, new String[]{arg}); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LastSentTimestampCache.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LastSentTimestampCache.kt deleted file mode 100644 index 46ada7aa9a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LastSentTimestampCache.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.thoughtcrime.securesms.database - -import org.session.libsession.messaging.LastSentTimestampCache -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class LastSentTimestampCache @Inject constructor( - val mmsSmsDatabase: MmsSmsDatabase -): LastSentTimestampCache { - - private val map = mutableMapOf() - - @Synchronized - override fun getTimestamp(threadId: Long): Long? = map[threadId] - - @Synchronized - override fun submitTimestamp(threadId: Long, timestamp: Long) { - if (map[threadId]?.let { timestamp <= it } == true) return - - map[threadId] = timestamp - } - - @Synchronized - override fun delete(threadId: Long, timestamps: List) { - if (map[threadId]?.let { it !in timestamps } == true) return - map.remove(threadId) - refresh(threadId) - } - - @Synchronized - override fun refresh(threadId: Long) { - if (map[threadId]?.let { it > 0 } == true) return - val lastOutgoingTimestamp = mmsSmsDatabase.getLastOutgoingTimestamp(threadId) - if (lastOutgoingTimestamp <= 0) return - map[threadId] = lastOutgoingTimestamp - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt index 1e9379bc8d..aad47f0635 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt @@ -17,8 +17,9 @@ import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import java.util.Date +import javax.inject.Provider -class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), LokiAPIDatabaseProtocol { +class LokiAPIDatabase(context: Context, helper: Provider) : Database(context, helper), LokiAPIDatabaseProtocol { companion object { // Shared @@ -170,7 +171,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun getSnodePool(): Set { - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.get(snodePoolTable, "${Companion.dummyKey} = ?", wrap("dummy_key")) { cursor -> val snodePoolAsString = cursor.getString(cursor.getColumnIndexOrThrow(snodePool)) snodePoolAsString.split(", ").mapNotNull(::Snode) @@ -178,7 +179,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun setSnodePool(newValue: Set) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val snodePoolAsString = newValue.joinToString(", ") { snode -> var string = "${snode.address}-${snode.port}" val keySet = snode.publicKeySet @@ -194,7 +195,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( override fun setOnionRequestPaths(newValue: List>) { // FIXME: This approach assumes either 1 or 2 paths of length 3 each. We should do better than this. - val database = databaseHelper.writableDatabase + val database = writableDatabase fun set(indexPath: String, snode: Snode) { var snodeAsString = "${snode.address}-${snode.port}" val keySet = snode.publicKeySet @@ -218,7 +219,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun getOnionRequestPaths(): List> { - val database = databaseHelper.readableDatabase + val database = readableDatabase fun get(indexPath: String): Snode? { return database.get(onionRequestPathTable, "${Companion.indexPath} = ?", wrap(indexPath)) { cursor -> Snode(cursor.getString(cursor.getColumnIndexOrThrow(snode))) @@ -237,12 +238,12 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun clearSnodePool() { - val database = databaseHelper.writableDatabase + val database = writableDatabase database.delete(snodePoolTable, null, null) } override fun clearOnionRequestPaths() { - val database = databaseHelper.writableDatabase + val database = writableDatabase fun delete(indexPath: String) { database.delete(onionRequestPathTable, "${Companion.indexPath} = ?", wrap(indexPath)) } @@ -252,7 +253,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun getSwarm(publicKey: String): Set? { - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.get(swarmTable, "${Companion.swarmPublicKey} = ?", wrap(publicKey)) { cursor -> val swarmAsString = cursor.getString(cursor.getColumnIndexOrThrow(swarm)) swarmAsString.split(", ").mapNotNull(::Snode) @@ -260,7 +261,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun setSwarm(publicKey: String, newValue: Set) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val swarmAsString = newValue.joinToString(", ") { target -> var string = "${target.address}-${target.port}" val keySet = target.publicKeySet @@ -274,7 +275,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun getLastMessageHashValue(snode: Snode, publicKey: String, namespace: Int): String? { - val database = databaseHelper.readableDatabase + val database = readableDatabase val query = "${Companion.snode} = ? AND ${Companion.publicKey} = ? AND $lastMessageHashNamespace = ?" return database.get(lastMessageHashValueTable2, query, arrayOf(snode.toString(), publicKey, namespace.toString())) { cursor -> cursor.getString(cursor.getColumnIndexOrThrow(lastMessageHashValue)) @@ -282,7 +283,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun setLastMessageHashValue(snode: Snode, publicKey: String, newValue: String, namespace: Int) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val row = wrap(mapOf( Companion.snode to snode.toString(), Companion.publicKey to publicKey, @@ -294,17 +295,23 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun clearLastMessageHashes(publicKey: String) { - databaseHelper.writableDatabase + writableDatabase .delete(lastMessageHashValueTable2, "${Companion.publicKey} = ?", arrayOf(publicKey)) } + override fun clearLastMessageHashesByNamespaces(vararg namespaces: Int) { + // Note that we don't use SQL parameter as the given namespaces are integer anyway so there's little chance of SQL injection + writableDatabase + .delete(lastMessageHashValueTable2, "$lastMessageHashNamespace IN (${namespaces.joinToString(",")})", null) + } + override fun clearAllLastMessageHashes() { - val database = databaseHelper.writableDatabase + val database = writableDatabase database.delete(lastMessageHashValueTable2, null, null) } override fun getReceivedMessageHashValues(publicKey: String, namespace: Int): Set? { - val database = databaseHelper.readableDatabase + val database = readableDatabase val query = "${Companion.publicKey} = ? AND ${Companion.receivedMessageHashNamespace} = ?" return database.get(receivedMessageHashValuesTable, query, arrayOf( publicKey, namespace.toString() )) { cursor -> val receivedMessageHashValuesAsString = cursor.getString(cursor.getColumnIndexOrThrow(Companion.receivedMessageHashValues)) @@ -313,7 +320,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun setReceivedMessageHashValues(publicKey: String, newValue: Set, namespace: Int) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val receivedMessageHashValuesAsString = newValue.joinToString("-") val row = wrap(mapOf( Companion.publicKey to publicKey, @@ -325,24 +332,30 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun clearReceivedMessageHashValues(publicKey: String) { - databaseHelper.writableDatabase + writableDatabase .delete(receivedMessageHashValuesTable, "${Companion.publicKey} = ?", arrayOf(publicKey)) } override fun clearReceivedMessageHashValues() { - val database = databaseHelper.writableDatabase + val database = writableDatabase database.delete(receivedMessageHashValuesTable, null, null) } + override fun clearReceivedMessageHashValuesByNamespaces(vararg namespaces: Int) { + // Note that we don't use SQL parameter as the given namespaces are integer anyway so there's little chance of SQL injection + writableDatabase + .delete(receivedMessageHashValuesTable, "$receivedMessageHashNamespace IN (${namespaces.joinToString(",")})", null) + } + override fun getAuthToken(server: String): String? { - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.get(openGroupAuthTokenTable, "${Companion.server} = ?", wrap(server)) { cursor -> cursor.getString(cursor.getColumnIndexOrThrow(token)) } } override fun setAuthToken(server: String, newValue: String?) { - val database = databaseHelper.writableDatabase + val database = writableDatabase if (newValue != null) { val row = wrap(mapOf( Companion.server to server, token to newValue )) database.insertOrUpdate(openGroupAuthTokenTable, row, "${Companion.server} = ?", wrap(server)) @@ -352,7 +365,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun getLastMessageServerID(room: String, server: String): Long? { - val database = databaseHelper.readableDatabase + val database = readableDatabase val index = "$server.$room" return database.get(lastMessageServerIDTable, "$lastMessageServerIDTableIndex = ?", wrap(index)) { cursor -> cursor.getInt(lastMessageServerID) @@ -360,20 +373,20 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun setLastMessageServerID(room: String, server: String, newValue: Long) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val index = "$server.$room" val row = wrap(mapOf( lastMessageServerIDTableIndex to index, lastMessageServerID to newValue.toString() )) database.insertOrUpdate(lastMessageServerIDTable, row, "$lastMessageServerIDTableIndex = ?", wrap(index)) } fun removeLastMessageServerID(room: String, server:String) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val index = "$server.$room" database.delete(lastMessageServerIDTable, "$lastMessageServerIDTableIndex = ?", wrap(index)) } override fun getLastDeletionServerID(room: String, server: String): Long? { - val database = databaseHelper.readableDatabase + val database = readableDatabase val index = "$server.$room" return database.get(lastDeletionServerIDTable, "$lastDeletionServerIDTableIndex = ?", wrap(index)) { cursor -> cursor.getInt(lastDeletionServerID) @@ -381,20 +394,20 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun setLastDeletionServerID(room: String, server: String, newValue: Long) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val index = "$server.$room" val row = wrap(mapOf(lastDeletionServerIDTableIndex to index, lastDeletionServerID to newValue.toString())) database.insertOrUpdate(lastDeletionServerIDTable, row, "$lastDeletionServerIDTableIndex = ?", wrap(index)) } fun removeLastDeletionServerID(room: String, server: String) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val index = "$server.$room" database.delete(lastDeletionServerIDTable, "$lastDeletionServerIDTableIndex = ?", wrap(index)) } override fun migrateLegacyOpenGroup(legacyServerId: String, newServerId: String) { - val database = databaseHelper.writableDatabase + val database = writableDatabase database.beginTransaction() val authRow = wrap(mapOf(server to newServerId)) database.update(openGroupAuthTokenTable, authRow, "$server = ?", wrap(legacyServerId)) @@ -419,7 +432,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun getLastLegacySenderAddress(threadRecipientAddress: String): String? = - databaseHelper.readableDatabase.get(LAST_LEGACY_MESSAGE_TABLE, LEGACY_THREAD_RECIPIENT_QUERY, wrap(threadRecipientAddress)) { cursor -> + readableDatabase.get(LAST_LEGACY_MESSAGE_TABLE, LEGACY_THREAD_RECIPIENT_QUERY, wrap(threadRecipientAddress)) { cursor -> cursor.getString(LAST_LEGACY_SENDER_RECIPIENT) } @@ -427,7 +440,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( threadRecipientAddress: String, senderRecipientAddress: String? ) { - val database = databaseHelper.writableDatabase + val database = writableDatabase if (senderRecipientAddress == null) { // delete database.delete(LAST_LEGACY_MESSAGE_TABLE, LEGACY_THREAD_RECIPIENT_QUERY, wrap(threadRecipientAddress)) @@ -444,7 +457,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } fun getUserCount(room: String, server: String): Int? { - val database = databaseHelper.readableDatabase + val database = readableDatabase val index = "$server.$room" return database.get(userCountTable, "$publicChatID = ?", wrap(index)) { cursor -> cursor.getInt(userCount) @@ -452,21 +465,21 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun setUserCount(room: String, server: String, newValue: Int) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val index = "$server.$room" val row = wrap(mapOf( publicChatID to index, userCount to newValue.toString() )) database.insertOrUpdate(userCountTable, row, "$publicChatID = ?", wrap(index)) } override fun getOpenGroupPublicKey(server: String): String? { - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.get(openGroupPublicKeyTable, "${LokiAPIDatabase.server} = ?", wrap(server)) { cursor -> cursor.getString(LokiAPIDatabase.publicKey) } } override fun setOpenGroupPublicKey(server: String, newValue: String) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val row = wrap(mapOf( LokiAPIDatabase.server to server, LokiAPIDatabase.publicKey to newValue )) database.insertOrUpdate(openGroupPublicKeyTable, row, "${LokiAPIDatabase.server} = ?", wrap(server)) } @@ -487,7 +500,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val index = "$groupPublicKey-$timestamp" val encryptionKeyPairPublicKey = encryptionKeyPair.publicKey.serialize().toHexString().removingIdPrefixIfNeeded() val encryptionKeyPairPrivateKey = encryptionKeyPair.privateKey.serialize().toHexString() @@ -497,7 +510,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun getClosedGroupEncryptionKeyPairs(groupPublicKey: String): List { - val database = databaseHelper.readableDatabase + val database = readableDatabase val timestampsAndKeyPairs = database.getAll(closedGroupEncryptionKeyPairsTable, "${Companion.closedGroupsEncryptionKeyPairIndex} LIKE ?", wrap("$groupPublicKey%")) { cursor -> val timestamp = cursor.getString(cursor.getColumnIndexOrThrow(Companion.closedGroupsEncryptionKeyPairIndex)).split("-").last() val encryptionKeyPairPublicKey = cursor.getString(cursor.getColumnIndexOrThrow(Companion.encryptionKeyPairPublicKey)) @@ -513,18 +526,18 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String) { - val database = databaseHelper.writableDatabase + val database = writableDatabase database.delete(closedGroupEncryptionKeyPairsTable, "${Companion.closedGroupsEncryptionKeyPairIndex} LIKE ?", wrap("$groupPublicKey%")) } fun addClosedGroupPublicKey(groupPublicKey: String) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val row = wrap(mapOf( Companion.groupPublicKey to groupPublicKey )) database.insertOrUpdate(closedGroupPublicKeysTable, row, "${Companion.groupPublicKey} = ?", wrap(groupPublicKey)) } fun getAllClosedGroupPublicKeys(): Set { - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.getAll(closedGroupPublicKeysTable, null, null) { cursor -> cursor.getString(cursor.getColumnIndexOrThrow(Companion.groupPublicKey)) }.toSet() @@ -536,59 +549,59 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } fun removeClosedGroupPublicKey(groupPublicKey: String) { - val database = databaseHelper.writableDatabase + val database = writableDatabase database.delete(closedGroupPublicKeysTable, "${Companion.groupPublicKey} = ?", wrap(groupPublicKey)) } fun setServerCapabilities(serverName: String, serverCapabilities: List) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val row = wrap(mapOf(server to serverName, capabilities to serverCapabilities.joinToString(","))) database.insertOrUpdate(serverCapabilitiesTable, row, "$server = ?", wrap(serverName)) } fun getServerCapabilities(serverName: String): List { - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.get(serverCapabilitiesTable, "$server = ?", wrap(serverName)) { cursor -> cursor.getString(capabilities) }?.split(",") ?: emptyList() } fun setLastInboxMessageId(serverName: String, newValue: Long) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val row = wrap(mapOf(server to serverName, lastInboxMessageServerId to newValue.toString())) database.insertOrUpdate(lastInboxMessageServerIdTable, row, "$server = ?", wrap(serverName)) } fun getLastInboxMessageId(serverName: String): Long? { - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.get(lastInboxMessageServerIdTable, "$server = ?", wrap(serverName)) { cursor -> cursor.getInt(lastInboxMessageServerId) }?.toLong() } fun removeLastInboxMessageId(serverName: String) { - databaseHelper.writableDatabase.delete(lastInboxMessageServerIdTable, "$server = ?", wrap(serverName)) + writableDatabase.delete(lastInboxMessageServerIdTable, "$server = ?", wrap(serverName)) } fun setLastOutboxMessageId(serverName: String, newValue: Long) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val row = wrap(mapOf(server to serverName, lastOutboxMessageServerId to newValue.toString())) database.insertOrUpdate(lastOutboxMessageServerIdTable, row, "$server = ?", wrap(serverName)) } fun getLastOutboxMessageId(serverName: String): Long? { - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.get(lastOutboxMessageServerIdTable, "$server = ?", wrap(serverName)) { cursor -> cursor.getInt(lastOutboxMessageServerId) }?.toLong() } fun removeLastOutboxMessageId(serverName: String) { - databaseHelper.writableDatabase.delete(lastOutboxMessageServerIdTable, "$server = ?", wrap(serverName)) + writableDatabase.delete(lastOutboxMessageServerIdTable, "$server = ?", wrap(serverName)) } override fun getForkInfo(): ForkInfo { - val database = databaseHelper.readableDatabase + val database = readableDatabase val queryCursor = database.query(FORK_INFO_TABLE, arrayOf(HF_VALUE, SF_VALUE), "$DUMMY_KEY = $DUMMY_VALUE", null, null, null, null) val forkInfo = queryCursor.use { cursor -> if (!cursor.moveToNext()) { @@ -601,7 +614,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun setForkInfo(forkInfo: ForkInfo) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val query = "$DUMMY_KEY = $DUMMY_VALUE" val contentValues = ContentValues(3) contentValues.put(DUMMY_KEY, DUMMY_VALUE) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiBackupFilesDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiBackupFilesDatabase.kt index 03e964de71..1b0b190a1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiBackupFilesDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiBackupFilesDatabase.kt @@ -7,13 +7,14 @@ import android.net.Uri import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import java.lang.IllegalArgumentException import java.util.* +import javax.inject.Provider import kotlin.collections.ArrayList /** * Keeps track of the backup files saved by the app. * Uses [BackupFileRecord] as an entry data projection. */ -class LokiBackupFilesDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) +class LokiBackupFilesDatabase(context: Context, databaseHelper: Provider) : Database(context, databaseHelper) { companion object { @@ -54,7 +55,7 @@ class LokiBackupFilesDatabase(context: Context, databaseHelper: SQLCipherOpenHel } fun getBackupFiles(): List { - databaseHelper.readableDatabase.query(TABLE_NAME, allColumns, null, null, null, null, null).use { + readableDatabase.query(TABLE_NAME, allColumns, null, null, null, null, null).use { val records = ArrayList() while (it != null && it.moveToNext()) { val record = mapCursorToRecord(it) @@ -66,13 +67,13 @@ class LokiBackupFilesDatabase(context: Context, databaseHelper: SQLCipherOpenHel fun insertBackupFile(record: BackupFileRecord): BackupFileRecord { val contentValues = mapRecordToValues(record) - val id = databaseHelper.writableDatabase.insertOrThrow(TABLE_NAME, null, contentValues) + val id = writableDatabase.insertOrThrow(TABLE_NAME, null, contentValues) return BackupFileRecord(id, record.uri, record.fileSize, record.timestamp) } fun getLastBackupFileTime(): Date? { // SELECT $COLUMN_TIMESTAMP FROM $TABLE_NAME ORDER BY $COLUMN_TIMESTAMP DESC LIMIT 1 - databaseHelper.readableDatabase.query( + readableDatabase.query( TABLE_NAME, arrayOf(COLUMN_TIMESTAMP), null, null, null, null, @@ -89,7 +90,7 @@ class LokiBackupFilesDatabase(context: Context, databaseHelper: SQLCipherOpenHel fun getLastBackupFile(): BackupFileRecord? { // SELECT * FROM $TABLE_NAME ORDER BY $COLUMN_TIMESTAMP DESC LIMIT 1 - databaseHelper.readableDatabase.query( + readableDatabase.query( TABLE_NAME, allColumns, null, null, null, null, @@ -112,6 +113,6 @@ class LokiBackupFilesDatabase(context: Context, databaseHelper: SQLCipherOpenHel if (id < 0) { throw IllegalArgumentException("ID must be zero or a positive number.") } - return databaseHelper.writableDatabase.delete(TABLE_NAME, "$COLUMN_ID = $id", null) > 0 + return writableDatabase.delete(TABLE_NAME, "$COLUMN_ID = $id", null) > 0 } } \ No newline at end of file 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 c74ae74ce0..2283612690 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt @@ -9,17 +9,19 @@ import org.session.libsession.database.ServerHashToMessageId import org.session.libsignal.database.LokiMessageDatabaseProtocol import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.util.asSequence +import javax.inject.Provider -class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), LokiMessageDatabaseProtocol { +class LokiMessageDatabase(context: Context, helper: Provider) : Database(context, helper), LokiMessageDatabaseProtocol { companion object { private const val messageIDTable = "loki_message_friend_request_database" private const val messageThreadMappingTable = "loki_message_thread_mapping_database" private const val errorMessageTable = "loki_error_message_database" private const val messageHashTable = "loki_message_hash_database" - private const val smsHashTable = "loki_sms_hash_database" - private const val mmsHashTable = "loki_mms_hash_database" + const val smsHashTable = "loki_sms_hash_database" + const val mmsHashTable = "loki_mms_hash_database" const val groupInviteTable = "loki_group_invites" private const val groupInviteDeleteTrigger = "group_invite_delete_trigger" @@ -39,7 +41,7 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab @JvmStatic val createMessageToThreadMappingTableCommand = "CREATE TABLE IF NOT EXISTS $messageThreadMappingTable ($messageID INTEGER PRIMARY KEY, $threadID INTEGER);" @JvmStatic - val createErrorMessageTableCommand = "CREATE TABLE IF NOT EXISTS $errorMessageTable ($messageID INTEGER PRIMARY KEY, $errorMessage STRING);" + val createErrorMessageTableCommand = "CREATE TABLE IF NOT EXISTS $errorMessageTable ($messageID INTEGER PRIMARY KEY, $messageType INTEGER NOT NULL, $errorMessage STRING);" @JvmStatic val updateMessageIDTableForType = "ALTER TABLE $messageIDTable ADD COLUMN $messageType INTEGER DEFAULT 0; ALTER TABLE $messageIDTable ADD CONSTRAINT PK_$messageIDTable PRIMARY KEY ($messageID, $serverID);" @JvmStatic @@ -54,39 +56,31 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab val createGroupInviteTableCommand = "CREATE TABLE IF NOT EXISTS $groupInviteTable ($threadID INTEGER PRIMARY KEY, $invitingSessionId STRING, $invitingMessageHash STRING);" @JvmStatic val createThreadDeleteTrigger = "CREATE TRIGGER IF NOT EXISTS $groupInviteDeleteTrigger AFTER DELETE ON ${ThreadDatabase.TABLE_NAME} BEGIN DELETE FROM $groupInviteTable WHERE $threadID = OLD.${ThreadDatabase.ID}; END;" + @JvmStatic + val updateErrorMessageTableCommand = "ALTER TABLE $errorMessageTable ADD COLUMN $messageType INTEGER DEFAULT 0" const val SMS_TYPE = 0 const val MMS_TYPE = 1 + private val MessageId.asMessageType: Int + get() = if (this.mms) MMS_TYPE else SMS_TYPE } - fun getServerID(messageID: Long): Long? { - val database = databaseHelper.readableDatabase - return database.get(messageIDTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor -> - cursor.getInt(serverID) - }?.toLong() - } - - fun getServerID(messageID: Long, isSms: Boolean): Long? { - val database = databaseHelper.readableDatabase - return database.get(messageIDTable, "${Companion.messageID} = ? AND $messageType = ?", arrayOf(messageID.toString(), if (isSms) SMS_TYPE.toString() else MMS_TYPE.toString())) { cursor -> - cursor.getInt(serverID) - }?.toLong() - } - - fun getMessageID(serverID: Long): Long? { - val database = databaseHelper.readableDatabase - return database.get(messageIDTable, "${Companion.serverID} = ?", arrayOf(serverID.toString())) { cursor -> - cursor.getInt(messageID) - }?.toLong() + fun getServerID(messageID: MessageId): Long? { + val database = readableDatabase + return database.get(messageIDTable, + "${Companion.messageID} = ? AND $messageType = ?", + arrayOf(messageID.id.toString(), messageID.asMessageType.toString())) { cursor -> + cursor.getLong(serverID) + } } - fun deleteMessage(messageID: Long, isSms: Boolean) { - val database = databaseHelper.writableDatabase + fun deleteMessage(messageID: MessageId) { + val database = writableDatabase val serverID = database.get(messageIDTable, "${Companion.messageID} = ? AND $messageType = ?", - arrayOf(messageID.toString(), (if (isSms) SMS_TYPE else MMS_TYPE).toString())) { cursor -> + arrayOf(messageID.id.toString(), messageID.asMessageType.toString())) { cursor -> cursor.getInt(serverID).toLong() } @@ -97,25 +91,27 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab database.beginTransaction() - database.delete(messageIDTable, "${Companion.messageID} = ? AND ${Companion.serverID} = ?", arrayOf(messageID.toString(), serverID.toString())) - database.delete(messageThreadMappingTable, "${Companion.messageID} = ? AND ${Companion.serverID} = ?", arrayOf(messageID.toString(), serverID.toString())) + database.delete(messageIDTable, "${Companion.messageID} = ? AND ${Companion.serverID} = ?", arrayOf(messageID.id.toString(), serverID.toString())) + database.delete(messageThreadMappingTable, "${Companion.messageID} = ? AND ${Companion.serverID} = ?", arrayOf(messageID.id.toString(), serverID.toString())) database.setTransactionSuccessful() database.endTransaction() } - fun deleteMessages(messageIDs: List) { - val database = databaseHelper.writableDatabase + fun deleteMessages(messageIDs: List, isSms: Boolean) { + val database = writableDatabase database.beginTransaction() + val messageTypeValue = if (isSms) SMS_TYPE else MMS_TYPE + database.delete( messageIDTable, - "${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})", + "${Companion.messageID} IN (${messageIDs.joinToString(",") { "?" }}) AND $messageType = $messageTypeValue", messageIDs.map { "$it" }.toTypedArray() ) database.delete( messageThreadMappingTable, - "${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})", + "${Companion.messageID} IN (${messageIDs.joinToString(",") { "?" }})", messageIDs.map { "$it" }.toTypedArray() ) @@ -123,11 +119,8 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab database.endTransaction() } - /** - * @return pair of sms or mms table-specific ID and whether it is in SMS table - */ - fun getMessageID(serverID: Long, threadID: Long): Pair? { - val database = databaseHelper.readableDatabase + fun getMessageID(serverID: Long, threadID: Long): MessageId? { + val database = readableDatabase val mappingResult = database.get(messageThreadMappingTable, "${Companion.serverID} = ? AND ${Companion.threadID} = ?", arrayOf(serverID.toString(), threadID.toString())) { cursor -> cursor.getInt(messageID) to cursor.getInt(Companion.serverID) @@ -138,12 +131,15 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab return database.get(messageIDTable, "$messageID = ? AND ${Companion.serverID} = ?", arrayOf(mappedID.toString(), mappedServerID.toString())) { cursor -> - cursor.getInt(messageID).toLong() to (cursor.getInt(messageType) == SMS_TYPE) + MessageId( + id = cursor.getInt(messageID).toLong(), + mms = cursor.getInt(messageType) == MMS_TYPE + ) } } fun getMessageIDs(serverIDs: List, threadID: Long): Pair, List> { - val database = databaseHelper.readableDatabase + val database = readableDatabase // Retrieve the message ids val messageIdCursor = database @@ -173,24 +169,17 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab return Pair(smsMessageIds, mmsMessageIds) } - override fun setServerID(messageID: Long, serverID: Long, isSms: Boolean) { - val database = databaseHelper.writableDatabase + override fun setServerID(messageID: MessageId, serverID: Long) { + val database = writableDatabase val contentValues = ContentValues(3) - contentValues.put(Companion.messageID, messageID) + contentValues.put(Companion.messageID, messageID.id) contentValues.put(Companion.serverID, serverID) - contentValues.put(messageType, if (isSms) SMS_TYPE else MMS_TYPE) + contentValues.put(messageType, messageID.asMessageType) database.insertWithOnConflict(messageIDTable, null, contentValues, CONFLICT_REPLACE) } - fun getOriginalThreadID(messageID: Long): Long { - val database = databaseHelper.readableDatabase - return database.get(messageThreadMappingTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor -> - cursor.getInt(threadID) - }?.toLong() ?: -1L - } - fun setOriginalThreadID(messageID: Long, serverID: Long, threadID: Long) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val contentValues = ContentValues(3) contentValues.put(Companion.messageID, messageID) contentValues.put(Companion.serverID, serverID) @@ -198,28 +187,32 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab database.insertWithOnConflict(messageThreadMappingTable, null, contentValues, CONFLICT_REPLACE) } - fun getErrorMessage(messageID: Long): String? { - val database = databaseHelper.readableDatabase - return database.get(errorMessageTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor -> - cursor.getString(errorMessage) - } + fun getErrorMessage(messageID: MessageId): String? { + val database = readableDatabase + return database.get(errorMessageTable, + "${Companion.messageID} = ? AND $messageType = ?", + arrayOf(messageID.id.toString(), messageID.asMessageType.toString())) + { cursor -> cursor.getString(errorMessage) } } - fun setErrorMessage(messageID: Long, errorMessage: String) { - val database = databaseHelper.writableDatabase + fun setErrorMessage(messageID: MessageId, errorMessage: String) { + val database = writableDatabase val contentValues = ContentValues(2) - contentValues.put(Companion.messageID, messageID) + contentValues.put(Companion.messageID, messageID.id) + contentValues.put(messageType, messageID.asMessageType) contentValues.put(Companion.errorMessage, errorMessage) - database.insertOrUpdate(errorMessageTable, contentValues, "${Companion.messageID} = ?", arrayOf(messageID.toString())) + database.insertOrUpdate(errorMessageTable, contentValues, "${Companion.messageID} = ? AND $messageType = ?", + arrayOf(messageID.id.toString(), messageID.asMessageType.toString())) } - fun clearErrorMessage(messageID: Long) { - val database = databaseHelper.writableDatabase - database.delete(errorMessageTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) + fun clearErrorMessage(messageID: MessageId) { + val database = writableDatabase + database.delete(errorMessageTable, "${Companion.messageID} = ? AND $messageType = ?", + arrayOf(messageID.id.toString(), messageID.asMessageType)) } fun deleteThread(threadId: Long) { - val database = databaseHelper.writableDatabase + val database = writableDatabase try { val messages = mutableSetOf>() database.get(messageThreadMappingTable, "$threadID = ?", arrayOf(threadId.toString())) { cursor -> @@ -270,15 +263,14 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab WHERE hash IN (SELECT value FROM json_each(:hashes)) """.trimIndent() - val result = databaseHelper.readableDatabase.query(query, arrayOf(threadId, JSONArray(hashes).toString())) + val result = readableDatabase.query(query, arrayOf(threadId, JSONArray(hashes).toString())) .use { cursor -> cursor.asSequence() .map { ServerHashToMessageId( serverHash = cursor.getString(0), - messageId = cursor.getLong(1), + messageId = MessageId(cursor.getLong(1), mms = cursor.getInt(4) == 0), sender = cursor.getString(2), - isSms = cursor.getInt(4) == 1, isOutgoing = MmsSmsColumns.Types.isOutgoingMessageType(cursor.getLong(3)) ) } @@ -288,30 +280,30 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab return result } - fun getMessageServerHash(messageID: Long, mms: Boolean): String? { - return databaseHelper.readableDatabase.get( - getMessageTable(mms), + fun getMessageServerHash(messageID: MessageId): String? { + return readableDatabase.get( + getMessageTable(messageID.mms), "${Companion.messageID} = ?", - arrayOf(messageID.toString())) { cursor -> cursor.getString(serverHash) } + arrayOf(messageID.id.toString())) { cursor -> cursor.getString(serverHash) } } - fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) { + fun setMessageServerHash(messageID: MessageId, serverHash: String) { val contentValues = ContentValues(2).apply { - put(Companion.messageID, messageID) + put(Companion.messageID, messageID.id) put(Companion.serverHash, serverHash) } - databaseHelper.writableDatabase.apply { - insertOrUpdate(getMessageTable(mms), contentValues, "${Companion.messageID} = ?", arrayOf(messageID.toString())) + writableDatabase.apply { + insertOrUpdate(getMessageTable(messageID.mms), contentValues, "${Companion.messageID} = ?", arrayOf(messageID.id.toString())) } } - fun deleteMessageServerHash(messageID: Long, mms: Boolean) { - databaseHelper.writableDatabase.delete(getMessageTable(mms), "${Companion.messageID} = ?", arrayOf(messageID.toString())) + fun deleteMessageServerHash(messageID: MessageId) { + writableDatabase.delete(getMessageTable(messageID.mms), "${Companion.messageID} = ?", arrayOf(messageID.id.toString())) } fun deleteMessageServerHashes(messageIDs: List, mms: Boolean) { - databaseHelper.writableDatabase.delete( + writableDatabase.delete( getMessageTable(mms), "${Companion.messageID} IN (${messageIDs.joinToString(",") { "?" }})", messageIDs.map { "$it" }.toTypedArray() @@ -324,25 +316,25 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab put(invitingSessionId, referrerSessionId) put(invitingMessageHash, messageHash) } - databaseHelper.writableDatabase.insertOrUpdate( + writableDatabase.insertOrUpdate( groupInviteTable, contentValues, "$threadID = ?", arrayOf(groupThreadId.toString()) ) } fun groupInviteReferrer(groupThreadId: Long): String? { - return databaseHelper.readableDatabase.get(groupInviteTable, "$threadID = ?", arrayOf(groupThreadId.toString())) {cursor -> + return readableDatabase.get(groupInviteTable, "$threadID = ?", arrayOf(groupThreadId.toString())) {cursor -> cursor.getString(invitingSessionId) } } fun groupInviteMessageHash(groupThreadId: Long): String? { - return databaseHelper.readableDatabase.get(groupInviteTable, "$threadID = ?", arrayOf(groupThreadId.toString())) { cursor -> + return readableDatabase.get(groupInviteTable, "$threadID = ?", arrayOf(groupThreadId.toString())) { cursor -> cursor.getString(invitingMessageHash) } } fun deleteGroupInviteReferrer(groupThreadId: Long) { - databaseHelper.writableDatabase.delete( + writableDatabase.delete( groupInviteTable, "$threadID = ?", arrayOf(groupThreadId.toString()) ) } 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 1cbbf34c9c..492462c79f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt @@ -6,8 +6,9 @@ import android.database.Cursor import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsignal.utilities.JsonUtil import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import javax.inject.Provider -class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { +class LokiThreadDatabase(context: Context, helper: Provider) : Database(context, helper) { companion object { private val sessionResetTable = "loki_thread_session_reset_database" @@ -22,7 +23,7 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa } fun getAllOpenGroups(): Map { - val database = databaseHelper.readableDatabase + val database = readableDatabase var cursor: Cursor? = null val result = mutableMapOf() try { @@ -45,7 +46,7 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa if (threadID < 0) { return null } - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.get(publicChatTable, "${Companion.threadID} = ?", arrayOf(threadID.toString())) { cursor -> val json = cursor.getString(publicChat) OpenGroup.fromJSON(json) @@ -53,7 +54,7 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa } fun getThreadId(openGroup: OpenGroup): Long? { - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.get(publicChatTable, "$publicChat = ?", arrayOf(JsonUtil.toJson(openGroup.toJson()))) { cursor -> cursor.getLong(threadID) } @@ -63,7 +64,7 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa if (threadID < 0) { return } - val database = databaseHelper.writableDatabase + val database = writableDatabase val contentValues = ContentValues(2) contentValues.put(Companion.threadID, threadID) contentValues.put(publicChat, JsonUtil.toJson(openGroup.toJson())) @@ -73,7 +74,7 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa fun removeOpenGroupChat(threadID: Long) { if (threadID < 0) return - val database = databaseHelper.writableDatabase + val database = writableDatabase database.delete(publicChatTable,"${Companion.threadID} = ?", arrayOf(threadID.toString())) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiUserDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiUserDatabase.kt index 4a5468c5d4..083e0aa058 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiUserDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiUserDatabase.kt @@ -2,9 +2,9 @@ package org.thoughtcrime.securesms.database import android.content.Context import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper -import org.session.libsession.utilities.TextSecurePreferences +import javax.inject.Provider -class LokiUserDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { +class LokiUserDatabase(context: Context, helper: Provider) : Database(context, helper) { companion object { // Shared @@ -18,21 +18,4 @@ class LokiUserDatabase(context: Context, helper: SQLCipherOpenHelper) : Database private val serverID = "server_id" @JvmStatic val createServerDisplayNameTableCommand = "CREATE TABLE $serverDisplayNameTable ($publicKey TEXT, $serverID TEXT, $displayName TEXT, PRIMARY KEY ($publicKey, $serverID));" } - - fun getDisplayName(publicKey: String): String? { - if (publicKey == TextSecurePreferences.getLocalNumber(context)) { - return TextSecurePreferences.getProfileName(context) - } else { - val database = databaseHelper.readableDatabase - val result = database.get(displayNameTable, "${Companion.publicKey} = ?", arrayOf( publicKey )) { cursor -> - cursor.getString(cursor.getColumnIndexOrThrow(displayName)) - } ?: return null - val suffix = " (...${publicKey.substring(publicKey.count() - 8)})" - if (result.endsWith(suffix)) { - return result.substring(0..(result.count() - suffix.count())) - } else { - return result - } - } - } } \ No newline at end of file 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 3f44588393..b12591a9a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java @@ -16,6 +16,8 @@ import java.util.List; +import javax.inject.Provider; + public class MediaDatabase extends Database { private static final String BASE_MEDIA_QUERY = "SELECT " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + " AS " + AttachmentDatabase.ROW_ID + ", " @@ -41,6 +43,7 @@ public class MediaDatabase extends Database { + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + ", " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CAPTION + ", " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.NAME + ", " + + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.AUDIO_DURATION + ", " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.MESSAGE_BOX + ", " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + ", " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + ", " @@ -60,15 +63,15 @@ public class MediaDatabase extends Database { private static final String GALLERY_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, AttachmentDatabase.CONTENT_TYPE + " LIKE 'image/%' OR " + AttachmentDatabase.CONTENT_TYPE + " LIKE 'video/%'"); private static final String DOCUMENT_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, AttachmentDatabase.CONTENT_TYPE + " NOT LIKE 'image/%' AND " + AttachmentDatabase.CONTENT_TYPE + " NOT LIKE 'video/%' AND " + - AttachmentDatabase.CONTENT_TYPE + " NOT LIKE 'audio/%' AND " + - AttachmentDatabase.CONTENT_TYPE + " NOT LIKE 'text/x-signal-plain'"); + AttachmentDatabase.CONTENT_TYPE + " NOT LIKE 'text/x-signal-plain' AND " + + "IFNULL(" + AttachmentDatabase.VOICE_NOTE + ", 0) = 0"); - public MediaDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + public MediaDatabase(Context context, Provider databaseHelper) { super(context, databaseHelper); } public Cursor getGalleryMediaForThread(long threadId) { - SQLiteDatabase database = databaseHelper.getReadableDatabase(); + SQLiteDatabase database = getReadableDatabase(); Cursor cursor = database.rawQuery(GALLERY_MEDIA_QUERY, new String[]{threadId+""}); setNotifyConversationListeners(cursor, threadId); return cursor; @@ -83,7 +86,7 @@ public void unsubscribeToMediaChanges(@NonNull ContentObserver observer) { } public Cursor getDocumentMediaForThread(long threadId) { - SQLiteDatabase database = databaseHelper.getReadableDatabase(); + SQLiteDatabase database = getReadableDatabase(); Cursor cursor = database.rawQuery(DOCUMENT_MEDIA_QUERY, new String[]{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 5622807127..4f8f8f598d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java @@ -16,18 +16,20 @@ import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.util.SqlUtil; import java.io.IOException; import java.util.ArrayList; +import java.util.Collection; import java.util.Iterator; import java.util.List; +import javax.inject.Provider; + public abstract class MessagingDatabase extends Database implements MmsSmsColumns { private static final String TAG = MessagingDatabase.class.getSimpleName(); - public MessagingDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + public MessagingDatabase(Context context, Provider databaseHelper) { super(context, databaseHelper); } @@ -35,7 +37,7 @@ public MessagingDatabase(Context context, SQLCipherOpenHelper databaseHelper) { public abstract void markExpireStarted(long messageId, long startTime); - public abstract void markAsSent(long messageId, boolean secure); + public abstract void markAsSent(long messageId, boolean sent); public abstract void markAsSyncing(long id); @@ -43,12 +45,15 @@ public MessagingDatabase(Context context, SQLCipherOpenHelper databaseHelper) { public abstract void markAsSyncFailed(long id); - public abstract void markUnidentified(long messageId, boolean unidentified); public abstract void markAsDeleted(long messageId, boolean isOutgoing, String displayedMessage); + public abstract List getExpiredMessageIDs(long nowMills); + + public abstract long getNextExpiringTimestamp(); + public abstract boolean deleteMessage(long messageId); - public abstract boolean deleteMessages(long[] messageId, long threadId); + public abstract boolean deleteMessages(Collection messageIds); public abstract void updateThreadId(long fromId, long toId); @@ -76,36 +81,8 @@ public void removeMismatchedIdentity(long messageId, Address address, IdentityKe } } - void updateReactionsUnread(SQLiteDatabase db, long messageId, boolean hasReactions, boolean isRemoval, boolean notifyUnread) { - try { - MessageRecord message = getMessageRecord(messageId); - ContentValues values = new ContentValues(); - - if (notifyUnread) { - if (!hasReactions) { - values.put(REACTIONS_UNREAD, 0); - } else if (!isRemoval) { - values.put(REACTIONS_UNREAD, 1); - } - } else { - values.put(REACTIONS_UNREAD, 0); - } - - if (message.isOutgoing() && hasReactions) { - values.put(NOTIFIED, 0); - } - - if (values.size() > 0) { - db.update(getTableName(), values, ID_WHERE, SqlUtil.buildArgs(messageId)); - } - notifyConversationListeners(message.getThreadId()); - } catch (NoSuchMessageException e) { - Log.w(TAG, "Failed to find message " + messageId); - } - } - protected , I> void removeFromDocument(long messageId, String column, I object, Class clazz) throws IOException { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); + SQLiteDatabase database = getWritableDatabase(); database.beginTransaction(); try { @@ -137,7 +114,7 @@ protected , I> void addToDocument(long messageId, String c } protected , I> void addToDocument(long messageId, String column, List objects, Class clazz) throws IOException { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); + SQLiteDatabase database = getWritableDatabase(); database.beginTransaction(); try { @@ -200,7 +177,7 @@ private D getDocument(SQLiteDatabase database, long message } public void migrateThreadId(long oldThreadId, long newThreadId) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); String where = THREAD_ID+" = ?"; String[] args = new String[]{oldThreadId+""}; ContentValues contentValues = new ContentValues(); @@ -209,7 +186,7 @@ public void migrateThreadId(long oldThreadId, long newThreadId) { } public boolean isOutgoing(long messageId) { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SQLiteDatabase db = getReadableDatabase(); try(Cursor cursor = db.query(getTableName(), new String[]{getTypeColumn()}, ID_WHERE, new String[]{String.valueOf(messageId)}, null, null, null)) { 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 786430d637..3f1961297b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -20,8 +20,9 @@ import android.content.ContentValues import android.content.Context import android.database.Cursor import com.annimon.stream.Stream -import com.google.android.mms.pdu_alt.PduHeaders -import org.apache.commons.lang3.StringUtils +import dagger.Lazy +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.serialization.json.Json import org.json.JSONArray import org.json.JSONException import org.json.JSONObject @@ -35,7 +36,6 @@ 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.messaging.utilities.UpdateMessageData import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.UNKNOWN @@ -48,20 +48,19 @@ 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.recipients.Recipient -import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils.queue import org.session.libsignal.utilities.Util.SECURE_RANDOM import org.session.libsignal.utilities.guava.Optional -import org.thoughtcrime.securesms.attachments.MmsNotificationAttachment -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.MessageId 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.database.model.content.DisappearingMessageUpdate +import org.thoughtcrime.securesms.database.model.content.MessageContent import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get import org.thoughtcrime.securesms.mms.MmsException import org.thoughtcrime.securesms.mms.SlideDeck @@ -69,14 +68,26 @@ import org.thoughtcrime.securesms.util.asSequence import java.io.Closeable import java.io.IOException import java.util.LinkedList - -class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : MessagingDatabase(context, databaseHelper) { +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +class MmsDatabase + @Inject constructor( + @ApplicationContext context: Context, + databaseHelper: Provider, + private val json: Json, + private val attachmentDatabase: Lazy, + private val groupRecipientDatabase: Lazy, + private val threadDatabase: Lazy, + ) : MessagingDatabase(context, databaseHelper) { private val earlyDeliveryReceiptCache = EarlyReceiptCache() private val earlyReadReceiptCache = EarlyReceiptCache() override fun getTableName() = TABLE_NAME fun getMessageCountForThread(threadId: Long): Int { - val db = databaseHelper.readableDatabase + val db = readableDatabase db.query( TABLE_NAME, arrayOf("COUNT(*)"), @@ -91,12 +102,12 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa return 0 } - fun isOutgoingMessage(timestamp: Long): Boolean = - databaseHelper.writableDatabase.query( + fun isOutgoingMessage(id: Long): Boolean = + writableDatabase.query( TABLE_NAME, arrayOf(ID, THREAD_ID, MESSAGE_BOX, ADDRESS), - DATE_SENT + " = ?", - arrayOf(timestamp.toString()), + "$ID = ?", + arrayOf(id.toString()), null, null, null, @@ -108,12 +119,12 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa .any { MmsSmsColumns.Types.isOutgoingMessageType(it) } } - fun isDeletedMessage(timestamp: Long): Boolean = - databaseHelper.writableDatabase.query( + fun isDeletedMessage(id: Long): Boolean = + writableDatabase.query( TABLE_NAME, arrayOf(ID, THREAD_ID, MESSAGE_BOX, ADDRESS), - DATE_SENT + " = ?", - arrayOf(timestamp.toString()), + "$ID = ?", + arrayOf(id.toString()), null, null, null, @@ -131,7 +142,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa deliveryReceipt: Boolean, readReceipt: Boolean ) { - val database = databaseHelper.writableDatabase + val database = writableDatabase var cursor: Cursor? = null var found = false try { @@ -199,13 +210,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa fun updateInfoMessage(messageId: Long, body: String?, runThreadUpdate: Boolean = true) { val threadId = getThreadIdForMessage(messageId) - val db = databaseHelper.writableDatabase + val db = writableDatabase db.execSQL( "UPDATE $TABLE_NAME SET $BODY = ? WHERE $ID = ?", arrayOf(body, messageId.toString()) ) with (get(context).threadDatabase()) { - setLastSeen(threadId) + setLastSeen(threadId, SnodeAPI.nowWithOffset) setHasSent(threadId, true) if (runThreadUpdate) { update(threadId, true) @@ -213,20 +224,24 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } } - fun updateSentTimestamp(messageId: Long, newTimestamp: Long, threadId: Long) { - val db = databaseHelper.writableDatabase - db.execSQL( - "UPDATE $TABLE_NAME SET $DATE_SENT = ? WHERE $ID = ?", - arrayOf(newTimestamp.toString(), messageId.toString()) - ) - notifyConversationListeners(threadId) + fun updateSentTimestamp(messageId: Long, newTimestamp: Long) { + val db = writableDatabase + val threadId = db.rawQuery( + "UPDATE $TABLE_NAME SET $DATE_SENT = ? WHERE $ID = ? RETURNING $THREAD_ID", + newTimestamp.toString(), + messageId.toString() + ).use { + if (it.moveToFirst()) it.getLong(0) else null + } + + threadId?.let(::notifyConversationListeners) notifyConversationListListeners() } fun getThreadIdForMessage(id: Long): Long { val sql = "SELECT $THREAD_ID FROM $TABLE_NAME WHERE $ID = ?" val sqlArgs = arrayOf(id.toString()) - val db = databaseHelper.readableDatabase + val db = readableDatabase var cursor: Cursor? = null return try { cursor = db.rawQuery(sql, sqlArgs) @@ -237,7 +252,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } private fun rawQuery(where: String, arguments: Array?): Cursor { - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.rawQuery( "SELECT " + MMS_PROJECTION.joinToString(",") + " FROM " + TABLE_NAME + " LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME + " ON (" + TABLE_NAME + "." + ID + " = " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ")" + @@ -252,32 +267,33 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa return cursor } - fun getRecentChatMemberIDs(threadID: Long, limit: Int): List { - val sql = """ - SELECT DISTINCT $ADDRESS FROM $TABLE_NAME - WHERE $THREAD_ID = ? - ORDER BY $DATE_SENT DESC - LIMIT $limit - """.trimIndent() + override fun getExpiredMessageIDs(nowMills: Long): List { + val query = "SELECT " + ID + " FROM " + TABLE_NAME + + " WHERE " + EXPIRES_IN + " > 0 AND " + EXPIRE_STARTED + " > 0 AND " + EXPIRE_STARTED + " + " + EXPIRES_IN + " <= ?" - return databaseHelper.readableDatabase.rawQuery(sql, threadID).use { cursor -> + return readableDatabase.rawQuery(query, nowMills).use { cursor -> cursor.asSequence() - .map { it.getString(0) } + .map { it.getLong(0) } .toList() } } - val expireStartedMessages: Reader - get() { - val where = "$EXPIRE_STARTED > 0" - return readerFor(rawQuery(where, null))!! - } - - val expireNotStartedMessages: Reader - get() { - val where = "$EXPIRES_IN > 0 AND $EXPIRE_STARTED = 0" - return readerFor(rawQuery(where, null))!! + /** + * @return the next expiring timestamp for messages that have started expiring. 0 if no messages are expiring. + */ + override fun getNextExpiringTimestamp(): Long { + val query = + "SELECT MIN(" + EXPIRE_STARTED + " + " + EXPIRES_IN + ") FROM " + TABLE_NAME + + " WHERE " + EXPIRES_IN + " > 0 AND " + EXPIRE_STARTED + " > 0" + + return readableDatabase.rawQuery(query).use { cursor -> + if (cursor.moveToFirst()) { + cursor.getLong(0) + } else { + 0L + } } + } private fun updateMailboxBitmask( id: Long, @@ -285,7 +301,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa maskOn: Long, threadId: Optional ) { - val db = databaseHelper.writableDatabase + val db = writableDatabase db.execSQL( "UPDATE " + TABLE_NAME + " SET " + MESSAGE_BOX + " = (" + MESSAGE_BOX + " & " + (MmsSmsColumns.Types.TOTAL_MASK - maskOff) + " | " + maskOn + " )" + @@ -328,23 +344,17 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa markAs(messageId, MmsSmsColumns.Types.BASE_SENT_FAILED_TYPE) } - override fun markAsSent(messageId: Long, secure: Boolean) { - 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) { - val contentValues = ContentValues() - contentValues.put(UNIDENTIFIED, if (unidentified) 1 else 0) - val db = databaseHelper.writableDatabase - db.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString())) + override fun markAsSent(messageId: Long, isSent: Boolean) { + markAs(messageId, MmsSmsColumns.Types.BASE_SENT_TYPE or if (isSent) MmsSmsColumns.Types.PUSH_MESSAGE_BIT or MmsSmsColumns.Types.SECURE_MESSAGE_BIT else 0) } override fun markAsDeleted(messageId: Long, isOutgoing: Boolean, displayedMessage: String) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val contentValues = ContentValues() contentValues.put(READ, 1) contentValues.put(BODY, displayedMessage) contentValues.put(HAS_MENTION, 0) + database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString())) val attachmentDatabase = get(context).attachmentDatabase() queue { attachmentDatabase.deleteAttachmentsForMessage(messageId) } @@ -359,14 +369,14 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa override fun markExpireStarted(messageId: Long, startedTimestamp: Long) { val contentValues = ContentValues() contentValues.put(EXPIRE_STARTED, startedTimestamp) - val db = databaseHelper.writableDatabase + val db = writableDatabase db.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString())) val threadId = getThreadIdForMessage(messageId) notifyConversationListeners(threadId) } fun markAsNotified(id: Long) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val contentValues = ContentValues() contentValues.put(NOTIFIED, 1) database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(id.toString())) @@ -374,20 +384,20 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa fun setMessagesRead(threadId: Long, beforeTime: Long): List { return setMessagesRead( - THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1) AND " + DATE_SENT + " <= ?", + THREAD_ID + " = ? AND (" + READ + " = 0) AND " + DATE_SENT + " <= ?", arrayOf(threadId.toString(), beforeTime.toString()) ) } fun setMessagesRead(threadId: Long): List { return setMessagesRead( - THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)", + THREAD_ID + " = ? AND (" + READ + " = 0)", arrayOf(threadId.toString()) ) } private fun setMessagesRead(where: String, arguments: Array?): List { - val database = databaseHelper.writableDatabase + val database = writableDatabase val result: MutableList = LinkedList() var cursor: Cursor? = null database.beginTransaction() @@ -406,11 +416,10 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa val timestamp = cursor.getLong(2) val syncMessageId = SyncMessageId(fromSerialized(cursor.getString(1)), timestamp) val expirationInfo = ExpirationInfo( - id = cursor.getLong(0), + id = MessageId(cursor.getLong(0), mms = true), timestamp = timestamp, expiresIn = cursor.getLong(4), expireStarted = cursor.getLong(5), - isMms = true ) result.add(MarkedMessageInfo(syncMessageId, expirationInfo)) } @@ -502,6 +511,15 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa Log.w(TAG, e) } } + + val messageContentJson = cursor.getString(cursor.getColumnIndexOrThrow(MESSAGE_CONTENT)) + + val messageContent = runCatching { + json.decodeFromString(messageContentJson) + }.onFailure { + Log.w(TAG, "Failed to decode message content for message ID $messageId", it) + }.getOrNull() + val message = OutgoingMediaMessage( recipient, body, @@ -515,7 +533,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa contacts, previews, networkFailures!!, - mismatches!! + mismatches!!, + messageContent, ) return if (MmsSmsColumns.Types.isSecureType(outboxType)) { OutgoingSecureMediaMessage(message) @@ -609,12 +628,11 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa runThreadUpdate: Boolean ): Optional { if (threadId < 0 ) throw MmsException("No thread ID supplied!") - if (retrieved.isExpirationUpdate) deleteExpirationTimerMessages(threadId, false.takeUnless { retrieved.groupId != null }) + if (retrieved.messageContent is DisappearingMessageUpdate) deleteExpirationTimerMessages(threadId, false.takeUnless { retrieved.groupId != null }) val contentValues = ContentValues() contentValues.put(DATE_SENT, retrieved.sentTimeMillis) - contentValues.put(ADDRESS, retrieved.from.serialize()) + contentValues.put(ADDRESS, retrieved.from.toString()) contentValues.put(MESSAGE_BOX, mailbox) - contentValues.put(MESSAGE_TYPE, PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF) contentValues.put(THREAD_ID, threadId) contentValues.put(CONTENT_LOCATION, contentLocation) contentValues.put(STATUS, Status.DOWNLOAD_INITIALIZED) @@ -631,7 +649,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa contentValues.put(SUBSCRIPTION_ID, retrieved.subscriptionId) contentValues.put(EXPIRES_IN, retrieved.expiresIn) 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)) { @@ -640,7 +657,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa var quoteAttachments: List? = LinkedList() if (retrieved.quote != null) { contentValues.put(QUOTE_ID, retrieved.quote.id) - contentValues.put(QUOTE_AUTHOR, retrieved.quote.author.serialize()) + contentValues.put(QUOTE_AUTHOR, retrieved.quote.author.toString()) contentValues.put(QUOTE_MISSING, if (retrieved.quote.missing) 1 else 0) quoteAttachments = retrieved.quote.attachments } @@ -654,15 +671,15 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa return Optional.absent() } val messageId = insertMediaMessage( - retrieved.body, - retrieved.attachments, - quoteAttachments!!, - retrieved.sharedContacts, - retrieved.linkPreviews, - contentValues, - null, + body = retrieved.body, + messageContent = retrieved.messageContent, + attachments = retrieved.attachments, + quoteAttachments = quoteAttachments!!, + sharedContacts = retrieved.sharedContacts, + linkPreviews = retrieved.linkPreviews, + contentValues = contentValues, ) - if (!MmsSmsColumns.Types.isExpirationTimerUpdate(mailbox)) { + if (retrieved.messageContent !is DisappearingMessageUpdate) { if (runThreadUpdate) { get(context).threadDatabase().update(threadId, true) } @@ -679,8 +696,14 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa runThreadUpdate: Boolean ): Optional { if (threadId < 0 ) throw MmsException("No thread ID supplied!") - if (retrieved.isExpirationUpdate) deleteExpirationTimerMessages(threadId, true.takeUnless { retrieved.isGroup }) - val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate) + if (retrieved.messageContent is DisappearingMessageUpdate) deleteExpirationTimerMessages(threadId, true.takeUnless { retrieved.isGroup }) + val messageId = insertMessageOutbox( + retrieved, + threadId, + false, + serverTimestamp, + runThreadUpdate + ) if (messageId == -1L) { Log.w(TAG, "insertSecureDecryptedMessageOutbox believes the MmsDatabase insertion failed.") return Optional.absent() @@ -701,9 +724,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa if (retrieved.isPushMessage) { type = type or MmsSmsColumns.Types.PUSH_MESSAGE_BIT } - if (retrieved.isExpirationUpdate) { - type = type or MmsSmsColumns.Types.EXPIRATION_TIMER_UPDATE_BIT - } if (retrieved.isScreenshotDataExtraction) { type = type or MmsSmsColumns.Types.SCREENSHOT_EXTRACTION_BIT } @@ -721,7 +741,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa fun insertMessageOutbox( message: OutgoingMediaMessage, threadId: Long, forceSms: Boolean, - insertListener: InsertListener?, serverTimestamp: Long = 0, runThreadUpdate: Boolean ): Long { @@ -732,14 +751,10 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa if (message.isGroup && message is OutgoingGroupMediaMessage) { if (message.isUpdateMessage) type = type or MmsSmsColumns.Types.GROUP_UPDATE_MESSAGE_BIT } - if (message.isExpirationUpdate) { - type = type or MmsSmsColumns.Types.EXPIRATION_TIMER_UPDATE_BIT - } val earlyDeliveryReceipts = earlyDeliveryReceiptCache.remove(message.sentTimeMillis) val earlyReadReceipts = earlyReadReceiptCache.remove(message.sentTimeMillis) val contentValues = ContentValues() contentValues.put(DATE_SENT, message.sentTimeMillis) - contentValues.put(MESSAGE_TYPE, PduHeaders.MESSAGE_TYPE_SEND_REQ) contentValues.put(MESSAGE_BOX, type) contentValues.put(THREAD_ID, threadId) contentValues.put(READ, 1) @@ -752,7 +767,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa 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(ADDRESS, message.recipient.address.toString()) contentValues.put( DELIVERY_RECEIPT_COUNT, Stream.of(earlyDeliveryReceipts.values).mapToLong { obj: Long -> obj } @@ -764,7 +779,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa val quoteAttachments: MutableList = LinkedList() if (message.outgoingQuote != null) { contentValues.put(QUOTE_ID, message.outgoingQuote!!.id) - contentValues.put(QUOTE_AUTHOR, message.outgoingQuote!!.author.serialize()) + contentValues.put(QUOTE_AUTHOR, message.outgoingQuote!!.author.toString()) contentValues.put(QUOTE_MISSING, if (message.outgoingQuote!!.missing) 1 else 0) quoteAttachments.addAll(message.outgoingQuote!!.attachments!!) } @@ -773,13 +788,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa return -1 } val messageId = insertMediaMessage( - message.body, - message.attachments, - quoteAttachments, - message.sharedContacts, - message.linkPreviews, - contentValues, - insertListener, + body = message.body, + messageContent = message.messageContent, + attachments = message.attachments, + quoteAttachments = quoteAttachments, + sharedContacts = message.sharedContacts, + linkPreviews = message.linkPreviews, + contentValues = contentValues, ) if (message.recipient.address.isGroupOrCommunity) { val members = get(context).groupDatabase() @@ -818,16 +833,18 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa @Throws(MmsException::class) private fun insertMediaMessage( body: String?, + messageContent: MessageContent?, attachments: List, quoteAttachments: List, sharedContacts: List, linkPreviews: List, contentValues: ContentValues, - insertListener: InsertListener?, ): Long { - val db = databaseHelper.writableDatabase + val db = writableDatabase val partsDatabase = get(context).attachmentDatabase() val allAttachments: MutableList = LinkedList() + val thumbnailJobs: MutableList = ArrayList() // Collector for thumbnail jobs + val contactAttachments = Stream.of(sharedContacts).map { obj: Contact -> obj.avatarAttachment } .filter { a: Attachment? -> a != null } @@ -836,26 +853,35 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa Stream.of(linkPreviews).filter { lp: LinkPreview -> lp.getThumbnail().isPresent } .map { lp: LinkPreview -> lp.getThumbnail().get() } .toList() + allAttachments.addAll(attachments) allAttachments.addAll(contactAttachments) allAttachments.addAll(previewAttachments) + contentValues.put(BODY, body) contentValues.put(PART_COUNT, allAttachments.size) + contentValues.put(MESSAGE_CONTENT, messageContent?.let { json.encodeToString(it) }) + db.beginTransaction() return try { val messageId = db.insert(TABLE_NAME, null, contentValues) + + // Pass thumbnailJobs collector to attachment insertion val insertedAttachments = partsDatabase.insertAttachmentsForMessage( messageId, allAttachments, - quoteAttachments + quoteAttachments, + thumbnailJobs // This will collect all attachment IDs that need thumbnails ) + val serializedContacts = getSerializedSharedContacts(insertedAttachments, sharedContacts) val serializedPreviews = getSerializedLinkPreviews(insertedAttachments, linkPreviews) + if (!serializedContacts.isNullOrEmpty()) { val contactValues = ContentValues() contactValues.put(SHARED_CONTACTS, serializedContacts) - val database = databaseHelper.readableDatabase + val database = readableDatabase val rows = database.update( TABLE_NAME, contactValues, @@ -866,10 +892,11 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa Log.w(TAG, "Failed to update message with shared contact data.") } } + if (!serializedPreviews.isNullOrEmpty()) { val contactValues = ContentValues() contactValues.put(LINK_PREVIEWS, serializedPreviews) - val database = databaseHelper.readableDatabase + val database = readableDatabase val rows = database.update( TABLE_NAME, contactValues, @@ -880,95 +907,86 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa Log.w(TAG, "Failed to update message with link preview data.") } } + db.setTransactionSuccessful() messageId } finally { db.endTransaction() - insertListener?.onComplete() + + // Process thumbnail jobs AFTER transaction commits + val attachmentDatabase = get(context).attachmentDatabase() + thumbnailJobs.forEach { attachmentId -> + Log.i(TAG, "Submitting thumbnail generation job for attachment: $attachmentId") + attachmentDatabase.thumbnailExecutor.submit( + attachmentDatabase.ThumbnailFetchCallable(attachmentId) + ) + } + notifyConversationListeners(contentValues.getAsLong(THREAD_ID)) } } - /** - * Delete all the messages in single queries where possible - * @param messageIds a String array representation of regularly Long types representing message IDs - */ - private fun deleteMessages(messageIds: Array) { - if (messageIds.isEmpty()) { - Log.w(TAG, "No message Ids provided to MmsDatabase.deleteMessages - aborting delete operation!") - return + private fun doDeleteMessages( + updateThread: Boolean, + where: String, + vararg whereArgs: Any?): Boolean { + val deletedMessageIDs: MutableList + val deletedMessagesThreadIDs = hashSetOf() + + writableDatabase.rawQuery( + "DELETE FROM $TABLE_NAME WHERE $where RETURNING $ID, $THREAD_ID", + *whereArgs + ).use { cursor -> + deletedMessageIDs = ArrayList(cursor.count) + + while (cursor.moveToNext()) { + deletedMessageIDs += cursor.getLong(0) + deletedMessagesThreadIDs += cursor.getLong(1) + } } - // don't need thread IDs - val queryBuilder = StringBuilder() - for (i in messageIds.indices) { - queryBuilder.append("$TABLE_NAME.$ID").append(" = ").append( - messageIds[i] - ) - if (i + 1 < messageIds.size) { - queryBuilder.append(" OR ") + // Delete messages related data from other tables + if (!deletedMessageIDs.isEmpty()) { + attachmentDatabase.get().deleteAttachmentsForMessages(deletedMessageIDs) + groupRecipientDatabase.get().deleteRowsForMessages(deletedMessageIDs) + + notifyConversationListListeners() + notifyStickerListeners() + notifyStickerPackListeners() + } + + if (updateThread) { + for (threadId in deletedMessagesThreadIDs) { + threadDatabase.get().update(threadId, false) } } - val idsAsString = queryBuilder.toString() - val attachmentDatabase = get(context).attachmentDatabase() - queue { attachmentDatabase.deleteAttachmentsForMessages(messageIds) } - val groupReceiptDatabase = get(context).groupReceiptDatabase() - groupReceiptDatabase.deleteRowsForMessages(messageIds) - val database = databaseHelper.writableDatabase - database.delete(TABLE_NAME, idsAsString, null) - notifyConversationListListeners() - notifyStickerListeners() - notifyStickerPackListeners() + + return deletedMessageIDs.isNotEmpty() } override fun getTypeColumn(): String = MESSAGE_BOX - // 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() - queue { attachmentDatabase.deleteAttachmentsForMessage(messageId) } - val groupReceiptDatabase = get(context).groupReceiptDatabase() - groupReceiptDatabase.deleteRowsForMessage(messageId) - val database = databaseHelper.writableDatabase - database!!.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString())) - val threadDeleted = get(context).threadDatabase().update(threadId, false) - notifyConversationListeners(threadId) - notifyStickerListeners() - notifyStickerPackListeners() - return threadDeleted + return doDeleteMessages( + updateThread = true, + where = "$ID = ?", + messageId + ) } - override fun deleteMessages(messageIds: LongArray, threadId: Long): Boolean { - val argsArray = messageIds.map { "?" } - val argValues = messageIds.map { it.toString() }.toTypedArray() - - val attachmentDatabase = get(context).attachmentDatabase() - val groupReceiptDatabase = get(context).groupReceiptDatabase() - - queue { attachmentDatabase.deleteAttachmentsForMessages(messageIds) } - groupReceiptDatabase.deleteRowsForMessages(messageIds) - - val db = databaseHelper.writableDatabase - db.delete( - TABLE_NAME, - ID + " IN (" + StringUtils.join(argsArray, ',') + ")", - argValues + override fun deleteMessages(messageIds: Collection): Boolean { + return doDeleteMessages( + updateThread = true, + where = "$ID IN (SELECT value FROM json_each(?))", + JSONArray(messageIds).toString() ) - - val threadDeleted = get(context).threadDatabase().update(threadId, false) - notifyConversationListeners(threadId) - notifyStickerListeners() - notifyStickerPackListeners() - return threadDeleted } override fun updateThreadId(fromId: Long, toId: Long) { val contentValues = ContentValues(1) contentValues.put(THREAD_ID, toId) - val db = databaseHelper.writableDatabase + val db = writableDatabase db.update(SmsDatabase.TABLE_NAME, contentValues, "$THREAD_ID = ?", arrayOf("$fromId")) notifyConversationListeners(toId) notifyConversationListListeners() @@ -981,64 +999,32 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } } - fun deleteThread(threadId: Long) { - deleteThreads(setOf(threadId)) + fun deleteThread(threadId: Long, updateThread: Boolean) { + deleteThreads(listOf(threadId), updateThread) } fun deleteMediaFor(threadId: Long, fromUser: String? = null) { - val db = databaseHelper.writableDatabase - val whereString = - if (fromUser == null) "$THREAD_ID = ? AND $LINK_PREVIEWS IS NULL" - else "$THREAD_ID = ? AND $ADDRESS = ? AND $LINK_PREVIEWS IS NULL" - val whereArgs = if (fromUser == null) arrayOf(threadId.toString()) else arrayOf(threadId.toString(), fromUser) - var cursor: Cursor? = null - try { - cursor = db.query(TABLE_NAME, arrayOf(ID), whereString, whereArgs, null, null, null, null) - val toDeleteStringMessageIds = mutableListOf() - while (cursor.moveToNext()) { - toDeleteStringMessageIds += cursor.getLong(0).toString() // get the ID as a string - } - // TODO: this can probably be optimized out, - // currently attachmentDB uses MmsID not threadID which makes it difficult to delete - // and clean up on threadID alone - toDeleteStringMessageIds.toList().chunked(50).forEach { sublist -> - deleteMessages(sublist.toTypedArray()) - } - } finally { - cursor?.close() + if (fromUser != null) { + doDeleteMessages( + updateThread = true, + where = "$THREAD_ID = ? AND $ADDRESS = ? AND $LINK_PREVIEWS IS NULL", + threadId, fromUser + ) + } else { + doDeleteMessages( + updateThread = true, + where = "$THREAD_ID = ? AND $LINK_PREVIEWS IS NULL", + threadId + ) } - val threadDb = get(context).threadDatabase() - threadDb.update(threadId, false) - notifyConversationListeners(threadId) - notifyStickerListeners() - notifyStickerPackListeners() } fun deleteMessagesFrom(threadId: Long, fromUser: String) { // copied from deleteThreads implementation - val db = databaseHelper.writableDatabase - var cursor: Cursor? = null - val whereString = "$THREAD_ID = ? AND $ADDRESS = ?" - try { - cursor = - db!!.query(TABLE_NAME, arrayOf(ID), whereString, arrayOf(threadId.toString(), fromUser), null, null, null) - val toDeleteStringMessageIds = mutableListOf() - while (cursor.moveToNext()) { - toDeleteStringMessageIds += cursor.getLong(0).toString() // get the ID as a string - } - // TODO: this can probably be optimized out, - // currently attachmentDB uses MmsID not threadID which makes it difficult to delete - // and clean up on threadID alone - toDeleteStringMessageIds.toList().chunked(50).forEach { sublist -> - deleteMessages(sublist.toTypedArray()) - } - } finally { - cursor?.close() - } - val threadDb = get(context).threadDatabase() - threadDb.update(threadId, false) - notifyConversationListeners(threadId) - notifyStickerListeners() - notifyStickerPackListeners() + doDeleteMessages( + updateThread = true, + where = "$THREAD_ID = ? AND $ADDRESS = ?", + threadId, fromUser + ) } private fun getSerializedSharedContacts( @@ -1101,13 +1087,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa message: IncomingMediaMessage?, threadId: Long ): Boolean { - val database = databaseHelper.readableDatabase + val database = readableDatabase val cursor: Cursor? = database!!.query( TABLE_NAME, null, MESSAGE_REQUEST_RESPONSE + " = 1 AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?", arrayOf( - message!!.from.serialize(), threadId.toString() + message!!.from.toString(), threadId.toString() ), null, null, @@ -1122,13 +1108,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } private fun isDuplicate(message: IncomingMediaMessage?, threadId: Long): Boolean { - val database = databaseHelper.readableDatabase + val database = readableDatabase val cursor: Cursor? = database!!.query( TABLE_NAME, null, DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?", arrayOf( - message!!.sentTimeMillis.toString(), message.from.serialize(), threadId.toString() + message!!.sentTimeMillis.toString(), message.from.toString(), threadId.toString() ), null, null, @@ -1143,14 +1129,14 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } private fun isDuplicate(message: OutgoingMediaMessage?, threadId: Long): Boolean { - val database = databaseHelper.readableDatabase + val database = readableDatabase val cursor: Cursor? = database!!.query( TABLE_NAME, null, DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?", arrayOf( message!!.sentTimeMillis.toString(), - message.recipient.address.serialize(), + message.recipient.address.toString(), threadId.toString() ), null, @@ -1166,7 +1152,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } fun isSent(messageId: Long): Boolean { - val database = databaseHelper.readableDatabase + val database = readableDatabase database!!.query( TABLE_NAME, arrayOf(MESSAGE_BOX), @@ -1184,66 +1170,31 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa return false } - private fun deleteThreads(threadIds: Set) { - val db = databaseHelper.writableDatabase - val where = StringBuilder() - var cursor: Cursor? = null - for (threadId in threadIds) { - where.append(THREAD_ID).append(" = '").append(threadId).append("' OR ") - } - val whereString = where.substring(0, where.length - 4) - try { - cursor = db!!.query(TABLE_NAME, arrayOf(ID), whereString, null, null, null, null) - val toDeleteStringMessageIds = mutableListOf() - while (cursor.moveToNext()) { - toDeleteStringMessageIds += cursor.getLong(0).toString() - } - // TODO: this can probably be optimized out, - // currently attachmentDB uses MmsID not threadID which makes it difficult to delete - // and clean up on threadID alone - toDeleteStringMessageIds.toList().chunked(50).forEach { sublist -> - deleteMessages(sublist.toTypedArray()) - } - } finally { - cursor?.close() - } - val threadDb = get(context).threadDatabase() - for (threadId in threadIds) { - val threadDeleted = threadDb.update(threadId, false) - notifyConversationListeners(threadId) - } - notifyStickerListeners() - notifyStickerPackListeners() + fun deleteThreads(threadIds: Collection, updateThread: Boolean) { + doDeleteMessages( + updateThread = updateThread, + where = "$THREAD_ID IN (SELECT value FROM json_each(?))", + JSONArray(threadIds).toString() + ) } /*package*/ fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long, onlyMedia: Boolean) { - var cursor: Cursor? = null - try { - val db = databaseHelper.readableDatabase - var where = - THREAD_ID + " = ? AND (CASE (" + MESSAGE_BOX + " & " + MmsSmsColumns.Types.BASE_TYPE_MASK + ") " - for (outgoingType in MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES) { - where += " WHEN $outgoingType THEN $DATE_SENT < $date" - } - where += " ELSE $DATE_RECEIVED < $date END)" - if (onlyMedia) where += " AND $PART_COUNT >= 1" - cursor = db.query( - TABLE_NAME, - arrayOf(ID), - where, - arrayOf(threadId.toString() + ""), - null, - null, - null - ) - while (cursor != null && cursor.moveToNext()) { - Log.i("MmsDatabase", "Trimming: " + cursor.getLong(0)) - deleteMessage(cursor.getLong(0)) - } - } finally { - cursor?.close() + var where = + THREAD_ID + " = ? AND (CASE (" + MESSAGE_BOX + " & " + MmsSmsColumns.Types.BASE_TYPE_MASK + ") " + + for (outgoingType in MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES) { + where += " WHEN $outgoingType THEN $DATE_SENT < $date" } + + where += " ELSE $DATE_RECEIVED < $date END)" + if (onlyMedia) where += " AND $PART_COUNT >= 1" + + doDeleteMessages( + updateThread = true, + where = where, + threadId + ) } fun readerFor(cursor: Cursor?, getQuote: Boolean = true) = Reader(cursor, getQuote) @@ -1253,7 +1204,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa fun setQuoteMissing(messageId: Long): Int { val contentValues = ContentValues() contentValues.put(QUOTE_MISSING, 1) - val database = databaseHelper.writableDatabase + val database = writableDatabase return database!!.update( TABLE_NAME, contentValues, @@ -1271,9 +1222,9 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa " 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) + val where = "$THREAD_ID = ? AND $MESSAGE_CONTENT->>'$.${MessageContent.DISCRIMINATOR}' == '${DisappearingMessageUpdate.TYPE_NAME}' " + outgoingClause + + doDeleteMessages(updateThread = true, where, threadId) } object Status { @@ -1298,7 +1249,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa LinkedList(), message.subscriptionId, message.expiresIn, - SnodeAPI.nowWithOffset, 0, + message.expireStartedAt, 0, if (message.outgoingQuote != null) Quote( message.outgoingQuote!!.id, message.outgoingQuote!!.author, @@ -1306,7 +1257,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa message.outgoingQuote!!.missing, SlideDeck(context, message.outgoingQuote!!.attachments!!) ) else null, - message.sharedContacts, message.linkPreviews, listOf(), false, false + message.sharedContacts, message.linkPreviews, listOf(), false, + message.messageContent, ) } @@ -1317,39 +1269,9 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa get() = if (cursor == null || !cursor.moveToNext()) null else current val current: MessageRecord get() { - val mmsType = cursor!!.getLong(cursor.getColumnIndexOrThrow(MESSAGE_TYPE)) - return if (mmsType == PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND.toLong()) { - getNotificationMmsMessageRecord(cursor) - } else { - getMediaMmsMessageRecord(cursor, getQuote) - } + return getMediaMmsMessageRecord(cursor!!, getQuote) } - private fun getNotificationMmsMessageRecord(cursor: Cursor): NotificationMmsMessageRecord { - // Note: Additional details such as ADDRESS_DEVICE_ID, CONTENT_LOCATION, and TRANSACTION_ID are available if required. - val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)) - val dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)) - val dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_RECEIVED)) - val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)) - val mailbox = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)) - val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)) - val recipient = getRecipientFor(address) - val messageSize = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_SIZE)) - val expiry = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRY)) - val status = cursor.getInt(cursor.getColumnIndexOrThrow(STATUS)) - val deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(DELIVERY_RECEIPT_COUNT)) - val readReceiptCount = if (isReadReceiptsEnabled(context)) cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT)) else 0 - val hasMention = (cursor.getInt(cursor.getColumnIndexOrThrow(HAS_MENTION)) == 1) - val slideDeck = SlideDeck(context, MmsNotificationAttachment(status, messageSize)) - - return NotificationMmsMessageRecord( - id, recipient, recipient, - dateSent, dateReceived, deliveryReceiptCount, threadId, - messageSize, expiry, status, mailbox, slideDeck, - readReceiptCount, hasMention - ) - } - private fun getMediaMmsMessageRecord(cursor: Cursor, getQuote: Boolean): MediaMmsMessageRecord { val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)) val dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)) @@ -1367,8 +1289,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID)) 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 + val messageContentJson = cursor.getString(cursor.getColumnIndexOrThrow(MESSAGE_CONTENT)) if (!isReadReceiptsEnabled(context)) { readReceiptCount = 0 @@ -1392,12 +1314,20 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa ) val quote = if (getQuote) getQuote(cursor) else null val reactions = get(context).reactionDatabase().getReactions(cursor) + val messageContent = runCatching { + messageContentJson?.takeIf { it.isNotBlank() } + ?.let { json.decodeFromString(it) } + }.onFailure { + Log.e(TAG, "Failed to decode message content", it) + }.getOrNull() + 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, hasMention + readReceiptCount, quote, contacts, previews, reactions, hasMention, + messageContent ) } @@ -1475,6 +1405,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa const val MESSAGE_BOX: String = "msg_box" const val CONTENT_LOCATION: String = "ct_l" const val EXPIRY: String = "exp" + + @kotlin.Deprecated(message = "No longer used.") const val MESSAGE_TYPE: String = "m_type" const val MESSAGE_SIZE: String = "m_size" const val STATUS: String = "st" @@ -1489,6 +1421,17 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa const val SHARED_CONTACTS: String = "shared_contacts" const val LINK_PREVIEWS: String = "previews" + /** + * The column that holds [MessageContent] in a JSON format. + * + * Note that this is a new column that we try to slowly migrate to, to store + * all the message content information in a single column. Right now the [MmsSmsColumns.BODY] column + * coexists alongside this column: if you see a [MessageContent], it takes precedence + * over the [MmsSmsColumns.BODY]/[MESSAGE_BOX]. If it's null, then we will still use + * the old way of describe what a message is. + */ + const val MESSAGE_CONTENT = "message_content" + private const val IS_DELETED_COLUMN_DEF = """ $IS_DELETED GENERATED ALWAYS AS ( @@ -1564,9 +1507,28 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa const val ADD_IS_GROUP_UPDATE_COLUMN: String = "ALTER TABLE $TABLE_NAME ADD COLUMN $IS_GROUP_UPDATE BOOL GENERATED ALWAYS AS ($MESSAGE_BOX & ${MmsSmsColumns.Types.GROUP_UPDATE_MESSAGE_BIT} != 0) VIRTUAL" + const val ADD_MESSAGE_CONTENT_COLUMN: String = + "ALTER TABLE $TABLE_NAME ADD COLUMN $MESSAGE_CONTENT TEXT DEFAULT NULL" + + // This migration looks for messages with EXPIRATION_TIMER_UPDATE_BIT set, + // then create a message content with json type = 'disappearing_message_update' and remove the bit + const val MIGRATE_EXPIRY_CONTROL_MESSAGES = """ + UPDATE $TABLE_NAME + SET $MESSAGE_CONTENT = json_object( + '${MessageContent.DISCRIMINATOR}', '${DisappearingMessageUpdate.TYPE_NAME}', + '${DisappearingMessageUpdate.KEY_EXPIRY_TIME_SECONDS}', $EXPIRES_IN / 1000, + '${DisappearingMessageUpdate.KEY_EXPIRY_TYPE}', + iif($EXPIRES_IN <= 0, '${DisappearingMessageUpdate.EXPIRY_MODE_NONE}', + iif($EXPIRE_STARTED == $DATE_SENT, ${DisappearingMessageUpdate.EXPIRY_MODE_AFTER_SENT}, ${DisappearingMessageUpdate.EXPIRY_MODE_AFTER_READ})) + ), + $MESSAGE_BOX = $MESSAGE_BOX & ~${MmsSmsColumns.Types.EXPIRATION_TIMER_UPDATE_BIT} + WHERE ($MESSAGE_BOX & ${MmsSmsColumns.Types.EXPIRATION_TIMER_UPDATE_BIT}) != 0; + """ + private val MMS_PROJECTION: Array = arrayOf( "$TABLE_NAME.$ID AS $ID", THREAD_ID, + MESSAGE_CONTENT, "$DATE_SENT AS $NORMALIZED_DATE_SENT", "$DATE_RECEIVED AS $NORMALIZED_DATE_RECEIVED", MESSAGE_BOX, @@ -1596,7 +1558,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa QUOTE_MISSING, SHARED_CONTACTS, LINK_PREVIEWS, - UNIDENTIFIED, HAS_MENTION, "json_group_array(json_object(" + "'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " + @@ -1619,6 +1580,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa "'" + AttachmentDatabase.CAPTION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CAPTION + ", " + "'" + AttachmentDatabase.STICKER_PACK_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID + ", " + "'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " + + "'" + AttachmentDatabase.AUDIO_DURATION + "', ifnull(" + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.AUDIO_DURATION + ", -1), " + "'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + ")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS, "json_group_array(json_object(" + 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 d6937666fc..35da0238df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java @@ -9,6 +9,7 @@ public interface MmsSmsColumns { public static final String THREAD_ID = "thread_id"; public static final String READ = "read"; public static final String BODY = "body"; + public static final String MESSAGE_CONTENT = "message_content"; // 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! @@ -23,8 +24,13 @@ public interface MmsSmsColumns { public static final String EXPIRES_IN = "expires_in"; public static final String EXPIRE_STARTED = "expire_started"; public static final String NOTIFIED = "notified"; + + // Not used but still in the database + @Deprecated(forRemoval = true) public static final String UNIDENTIFIED = "unidentified"; + public static final String MESSAGE_REQUEST_RESPONSE = "message_request_response"; + @Deprecated(forRemoval = true) public static final String REACTIONS_UNREAD = "reactions_unread"; public static final String REACTIONS_LAST_SEEN = "reactions_last_seen"; @@ -32,6 +38,8 @@ public interface MmsSmsColumns { public static final String IS_DELETED = "is_deleted"; public static final String IS_GROUP_UPDATE = "is_group_update"; + public static final String SERVER_HASH = "server_hash"; + public static class Types { protected static final long TOTAL_MASK = 0xFFFFFFFF; @@ -92,6 +100,7 @@ public static class Types { // Group Message Information protected static final long GROUP_UPDATE_BIT = 0x10000; protected static final long GROUP_QUIT_BIT = 0x20000; + @Deprecated(forRemoval = true) protected static final long EXPIRATION_TIMER_UPDATE_BIT = 0x40000; protected static final long GROUP_UPDATE_MESSAGE_BIT = 0x80000; @@ -247,9 +256,6 @@ public static boolean isCallLog(long type) { baseType == MISSED_CALL_TYPE || baseType == FIRST_MISSED_CALL_TYPE; } - public static boolean isExpirationTimerUpdate(long type) { - return (type & EXPIRATION_TIMER_UPDATE_BIT) != 0; - } public static boolean isMediaSavedExtraction(long type) { return (type & MEDIA_SAVED_EXTRACTION_BIT) != 0; 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 c29b27a8cd..ddadfc34fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -17,9 +17,10 @@ package org.thoughtcrime.securesms.database; import static org.thoughtcrime.securesms.database.MmsDatabase.MESSAGE_BOX; -import static org.thoughtcrime.securesms.database.MmsSmsColumns.Types.BASE_DELETED_INCOMING_TYPE; -import static org.thoughtcrime.securesms.database.MmsSmsColumns.Types.BASE_DELETED_OUTGOING_TYPE; -import static org.thoughtcrime.securesms.database.MmsSmsColumns.Types.BASE_TYPE_MASK; +import static org.thoughtcrime.securesms.database.MmsSmsColumns.ID; +import static org.thoughtcrime.securesms.database.MmsSmsColumns.NOTIFIED; +import static org.thoughtcrime.securesms.database.MmsSmsColumns.READ; +import static org.thoughtcrime.securesms.database.MmsSmsColumns.UNIQUE_ROW_ID; import android.content.Context; import android.database.Cursor; @@ -38,6 +39,7 @@ 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.MessageId; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; @@ -47,6 +49,8 @@ import java.util.List; import java.util.Set; +import javax.inject.Provider; + import kotlin.Pair; public class MmsSmsDatabase extends Database { @@ -59,14 +63,13 @@ public class MmsSmsDatabase extends Database { public static final String SMS_TRANSPORT = "sms"; private static final String[] PROJECTION = {MmsSmsColumns.ID, MmsSmsColumns.UNIQUE_ROW_ID, - SmsDatabase.BODY, SmsDatabase.TYPE, + SmsDatabase.BODY, SmsDatabase.TYPE, MmsSmsColumns.MESSAGE_CONTENT, MmsSmsColumns.THREAD_ID, SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.NORMALIZED_DATE_RECEIVED, MmsDatabase.MESSAGE_TYPE, MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, - MmsSmsColumns.UNIDENTIFIED, MmsDatabase.PART_COUNT, MmsDatabase.CONTENT_LOCATION, MmsDatabase.TRANSACTION_ID, MmsDatabase.MESSAGE_SIZE, MmsDatabase.EXPIRY, @@ -78,7 +81,7 @@ public class MmsSmsDatabase extends Database { MmsSmsColumns.SUBSCRIPTION_ID, MmsSmsColumns.EXPIRES_IN, MmsSmsColumns.EXPIRE_STARTED, - MmsSmsColumns.NOTIFIED, + NOTIFIED, TRANSPORT, AttachmentDatabase.ATTACHMENT_JSON_ALIAS, MmsDatabase.QUOTE_ID, @@ -89,25 +92,32 @@ public class MmsSmsDatabase extends Database { MmsDatabase.SHARED_CONTACTS, MmsDatabase.LINK_PREVIEWS, ReactionDatabase.REACTION_JSON_ALIAS, - MmsSmsColumns.HAS_MENTION + MmsSmsColumns.HAS_MENTION, + MmsSmsColumns.SERVER_HASH }; - public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + public MmsSmsDatabase(Context context, Provider databaseHelper) { super(context, databaseHelper); } - public @Nullable MessageRecord getMessageForTimestamp(long timestamp) { - try (Cursor cursor = queryTables(PROJECTION, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, null, null)) { + public @Nullable MessageRecord getMessageForTimestamp(long threadId, long timestamp) { + final String selection = MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp + + " AND " + MmsSmsColumns.THREAD_ID + " = " + threadId; + + try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) { MmsSmsDatabase.Reader reader = readerFor(cursor); return reader.getNext(); } } - public @Nullable MessageRecord getNonDeletedMessageForTimestamp(long timestamp) { - String selection = MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp; - try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) { - MmsSmsDatabase.Reader reader = readerFor(cursor); - return reader.getNext(); + public @Nullable MessageRecord getMessageById(@NonNull MessageId id) { + if (id.isMms()) { + final MmsDatabase db = DatabaseComponent.get(context).mmsDatabase(); + try (final Cursor cursor = db.getMessage(id.getId())) { + return db.readerFor(cursor, true).getNext(); + } + } else { + return DatabaseComponent.get(context).smsDatabase().getMessageOrNull(id.getId()); } } @@ -125,7 +135,7 @@ public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) { while ((messageRecord = reader.getNext()) != null) { if ((isOwnNumber && messageRecord.isOutgoing()) || - (!isOwnNumber && messageRecord.getIndividualRecipient().getAddress().serialize().equals(serializedAuthor))) + (!isOwnNumber && messageRecord.getIndividualRecipient().getAddress().toString().equals(serializedAuthor))) { return messageRecord; } @@ -182,8 +192,27 @@ public MessageRecord getLastSentMessageRecordFromSender(long threadId, String se return null; } + @Nullable + public MessageId getLastSentMessageID(long threadId) { + String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND NOT " + MmsSmsColumns.IS_DELETED; + + try (final Cursor cursor = queryTables(PROJECTION, selection, order, null)) { + try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { + MessageRecord messageRecord; + while ((messageRecord = reader.getNext()) != null) { + if (messageRecord.isOutgoing()) { + return new MessageId(messageRecord.getId(), messageRecord.isMms()); + } + } + } + } + + return null; + } + public @Nullable MessageRecord getMessageFor(long timestamp, Address author) { - return getMessageFor(timestamp, author.serialize()); + return getMessageFor(timestamp, author.toString()); } public long getPreviousPage(long threadId, long fromTime, int limit) { @@ -266,13 +295,21 @@ public Cursor getConversationSnippet(long threadId) { return queryTables(PROJECTION, selection, order, null); } - public long getLastMessageID(long threadId) { - String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; + public List getRecentChatMemberAddresses(long threadId, int limit) { + String[] projection = new String[] { "DISTINCT " + MmsSmsColumns.ADDRESS }; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; + String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; + String limitStr = String.valueOf(limit); - try (Cursor cursor = queryTables(PROJECTION, selection, order, "1")) { - cursor.moveToFirst(); - return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.ID)); + try (Cursor cursor = queryTables(projection, selection, order, limitStr)) { + List addresses = new ArrayList<>(); + while (cursor != null && cursor.moveToNext()) { + String address = cursor.getString(0); + if (address != null && !address.isEmpty()) { + addresses.add(address); + } + } + return addresses; } } @@ -284,7 +321,7 @@ public List getUserMessages(long threadId, String sender) { Reader reader = readerFor(cursor); while (reader.getNext() != null) { MessageRecord record = reader.getCurrent(); - if (record.getIndividualRecipient().getAddress().serialize().equals(sender)) { + if (record.getIndividualRecipient().getAddress().toString().equals(sender)) { idList.add(record); } } @@ -312,68 +349,122 @@ public Set getAllMessageRecordsFromSenderInThread(long threadId, return identifiedMessages; } - public Set getAllMessageRecordsBefore(long threadId, long timestampMills) { + public List> getAllMessageRecordsBefore(long threadId, long timestampMills) { String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.NORMALIZED_DATE_SENT + " < " + timestampMills; - Set identifiedMessages = new HashSet<>(); + List> identifiedMessages = new ArrayList<>(); // 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); + @Nullable String hash = + cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsColumns.SERVER_HASH)); + + identifiedMessages.add(new Pair<>(messageRecord, hash)); } } } return identifiedMessages; } - public long getLastOutgoingTimestamp(long threadId) { - String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; + public List> getAllMessagesWithHash(long threadId) { + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; + List> identifiedMessages = new ArrayList<>(); - // 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; - } + try (Cursor cursor = queryTables(PROJECTION, selection, null, null); + MmsSmsDatabase.Reader reader = readerFor(cursor)) { + + MessageRecord record; + while ((record = reader.getNext()) != null) { + @Nullable String hash = + cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsColumns.SERVER_HASH)); + + identifiedMessages.add(new Pair<>(record, hash)); } } - Log.i(TAG, "Could not find last sent message from us - returning -1."); - return -1; + return identifiedMessages; } - public long getLastMessageTimestamp(long threadId) { + @Nullable + public MessageRecord getLastMessage(long threadId) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; // make sure the last message isn't marked as deleted String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + "NOT " + MmsSmsColumns.IS_DELETED; try (Cursor cursor = queryTables(PROJECTION, selection, order, "1")) { - if (cursor.moveToFirst()) { - return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_SENT)); - } + return readerFor(cursor).getNext(); } - - return -1; } - public Cursor getUnread() { - String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " ASC"; - String selection = "(" + MmsSmsColumns.READ + " = 0 OR " + MmsSmsColumns.REACTIONS_UNREAD + " = 1) AND " + MmsSmsColumns.NOTIFIED + " = 0"; - + /** + * Get all incoming unread + unnotified messages + * or + * all outgoing messages with unread reactions + */ + public Cursor getUnreadOrUnseenReactions() { + + // ────────────────────────────────────────────────────────────── + // 1) Build “is-outgoing” condition that works for both MMS & SMS + // ────────────────────────────────────────────────────────────── + // MMS rows → use MESSAGE_BOX + // SMS rows → use TYPE + // + // TRANSPORT lets us be sure we’re looking at the right column, + // so an incoming MMS/SMS can never be mistaken for outgoing. + // + String outgoingCondition = + /* MMS */ + "(" + TRANSPORT + " = '" + MMS_TRANSPORT + "' AND " + + "(" + MESSAGE_BOX + " & " + + MmsSmsColumns.Types.BASE_TYPE_MASK + ") IN (" + + buildOutgoingTypesList() + "))" + + + " OR " + + + /* SMS */ + "(" + TRANSPORT + " = '" + SMS_TRANSPORT + "' AND " + + "(" + SmsDatabase.TYPE + " & " + + MmsSmsColumns.Types.BASE_TYPE_MASK + ") IN (" + + buildOutgoingTypesList() + "))"; + + final String lastSeenQuery = "SELECT " + ThreadDatabase.LAST_SEEN + + " FROM " + ThreadDatabase.TABLE_NAME + + " WHERE " + ThreadDatabase.ID + " = " + MmsSmsColumns.THREAD_ID; + + // ────────────────────────────────────────────────────────────── + // 2) Selection: + // A) incoming unread+un-notified, NOT outgoing + // B) outgoing with unseen reactions, IS outgoing + // To query unseen reactions, we compare the date received on the reaction with the "last seen timestamp" on this thread + // ────────────────────────────────────────────────────────────── + String selection = + "(" + READ + " = 0 AND " + + NOTIFIED + " = 0 AND NOT (" + outgoingCondition + "))" + // A + " OR (" + + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + " > (" + lastSeenQuery +") AND (" + + outgoingCondition + "))"; // B + + String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " ASC"; return queryTables(PROJECTION, selection, order, null); } + /** Builds the comma-separated list of base types that represent + * *outgoing* messages (same helper as before). */ + private String buildOutgoingTypesList() { + long[] types = MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES; + StringBuilder sb = new StringBuilder(types.length * 3); + for (int i = 0; i < types.length; i++) { + if (i > 0) sb.append(','); + sb.append(types[i]); + } + return sb.toString(); + } + public int getUnreadCount(long threadId) { - String selection = MmsSmsColumns.READ + " = 0 AND " + MmsSmsColumns.NOTIFIED + " = 0 AND " + MmsSmsColumns.THREAD_ID + " = " + threadId; + String selection = READ + " = 0 AND " + NOTIFIED + " = 0 AND " + MmsSmsColumns.THREAD_ID + " = " + threadId; Cursor cursor = queryTables(PROJECTION, selection, null, null); try { @@ -408,43 +499,18 @@ public long getConversationCount(long threadId) { return count; } - public void incrementDeliveryReceiptCount(SyncMessageId syncMessageId, long timestamp) { - DatabaseComponent.get(context).smsDatabase().incrementReceiptCount(syncMessageId, true, false); - DatabaseComponent.get(context).mmsDatabase().incrementReceiptCount(syncMessageId, timestamp, true, false); - } - public void incrementReadReceiptCount(SyncMessageId syncMessageId, long timestamp) { DatabaseComponent.get(context).smsDatabase().incrementReceiptCount(syncMessageId, false, true); DatabaseComponent.get(context).mmsDatabase().incrementReceiptCount(syncMessageId, timestamp, false, true); } - public int getQuotedMessagePosition(long threadId, long quoteId, @NonNull Address address) { - 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)) { - String serializedAddress = address.serialize(); - boolean isOwnNumber = Util.isOwnNumber(context, address.serialize()); - - while (cursor != null && cursor.moveToNext()) { - boolean quoteIdMatches = cursor.getLong(0) == quoteId; - boolean addressMatches = serializedAddress.equals(cursor.getString(1)); - - if (quoteIdMatches && (addressMatches || isOwnNumber)) { - return cursor.getPosition(); - } - } - } - return -1; - } - 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_SENT, MmsSmsColumns.ADDRESS }, selection, order, null)) { - String serializedAddress = address.serialize(); - boolean isOwnNumber = Util.isOwnNumber(context, address.serialize()); + String serializedAddress = address.toString(); + boolean isOwnNumber = Util.isOwnNumber(context, address.toString()); while (cursor != null && cursor.moveToNext()) { boolean timestampMatches = cursor.getLong(0) == sentTimestamp; @@ -498,19 +564,21 @@ private Cursor queryTables(String[] projection, String selection, String order, "'" + AttachmentDatabase.CAPTION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CAPTION + ", " + "'" + AttachmentDatabase.STICKER_PACK_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID + ", " + "'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " + + "'" + AttachmentDatabase.AUDIO_DURATION + "', ifnull(" + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.AUDIO_DURATION + ", -1), " + "'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + ")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS, reactionsColumn, - SmsDatabase.BODY, MmsSmsColumns.READ, MmsSmsColumns.THREAD_ID, + SmsDatabase.BODY, + MmsDatabase.MESSAGE_CONTENT, + READ, MmsSmsColumns.THREAD_ID, SmsDatabase.TYPE, SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE, MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT, MmsDatabase.CONTENT_LOCATION, MmsDatabase.TRANSACTION_ID, MmsDatabase.MESSAGE_SIZE, MmsDatabase.EXPIRY, MmsDatabase.STATUS, - MmsDatabase.UNIDENTIFIED, MmsSmsColumns.DELIVERY_RECEIPT_COUNT, MmsSmsColumns.READ_RECEIPT_COUNT, MmsSmsColumns.MISMATCHED_IDENTITIES, MmsSmsColumns.SUBSCRIPTION_ID, MmsSmsColumns.EXPIRES_IN, MmsSmsColumns.EXPIRE_STARTED, - MmsSmsColumns.NOTIFIED, + NOTIFIED, MmsDatabase.NETWORK_FAILURE, TRANSPORT, MmsDatabase.QUOTE_ID, MmsDatabase.QUOTE_AUTHOR, @@ -519,7 +587,8 @@ private Cursor queryTables(String[] projection, String selection, String order, MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.SHARED_CONTACTS, MmsDatabase.LINK_PREVIEWS, - MmsSmsColumns.HAS_MENTION + MmsSmsColumns.HAS_MENTION, + "mms_hash.server_hash AS " + MmsSmsColumns.SERVER_HASH, }; String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT, @@ -530,16 +599,17 @@ private Cursor queryTables(String[] projection, String selection, String order, + " AS " + MmsSmsColumns.UNIQUE_ROW_ID, "NULL AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS, reactionsColumn, - SmsDatabase.BODY, MmsSmsColumns.READ, MmsSmsColumns.THREAD_ID, + SmsDatabase.BODY, + MmsSmsColumns.MESSAGE_CONTENT, + READ, MmsSmsColumns.THREAD_ID, SmsDatabase.TYPE, SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE, MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT, MmsDatabase.CONTENT_LOCATION, MmsDatabase.TRANSACTION_ID, MmsDatabase.MESSAGE_SIZE, MmsDatabase.EXPIRY, MmsDatabase.STATUS, - MmsDatabase.UNIDENTIFIED, MmsSmsColumns.DELIVERY_RECEIPT_COUNT, MmsSmsColumns.READ_RECEIPT_COUNT, MmsSmsColumns.MISMATCHED_IDENTITIES, MmsSmsColumns.SUBSCRIPTION_ID, MmsSmsColumns.EXPIRES_IN, MmsSmsColumns.EXPIRE_STARTED, - MmsSmsColumns.NOTIFIED, + NOTIFIED, MmsDatabase.NETWORK_FAILURE, TRANSPORT, MmsDatabase.QUOTE_ID, MmsDatabase.QUOTE_AUTHOR, @@ -548,7 +618,8 @@ private Cursor queryTables(String[] projection, String selection, String order, MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.SHARED_CONTACTS, MmsDatabase.LINK_PREVIEWS, - MmsSmsColumns.HAS_MENTION + MmsSmsColumns.HAS_MENTION, + "sms_hash.server_hash AS " + MmsSmsColumns.SERVER_HASH, }; SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder(); @@ -559,18 +630,23 @@ private Cursor queryTables(String[] projection, String selection, String order, smsQueryBuilder.setTables(SmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME + - " ON " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 0"); + " ON " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 0" + + " LEFT OUTER JOIN " + LokiMessageDatabase.smsHashTable + " AS sms_hash" + + " ON sms_hash.message_id = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID); mmsQueryBuilder.setTables(MmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME + " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME + - " ON " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 1"); + " ON " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 1" + + " LEFT OUTER JOIN " + LokiMessageDatabase.mmsHashTable + " AS mms_hash" + + " ON mms_hash.message_id = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID); Set mmsColumnsPresent = new HashSet<>(); mmsColumnsPresent.add(MmsSmsColumns.ID); - mmsColumnsPresent.add(MmsSmsColumns.READ); + mmsColumnsPresent.add(READ); mmsColumnsPresent.add(MmsSmsColumns.THREAD_ID); + mmsColumnsPresent.add(MmsSmsColumns.MESSAGE_CONTENT); mmsColumnsPresent.add(MmsSmsColumns.BODY); mmsColumnsPresent.add(MmsSmsColumns.ADDRESS); mmsColumnsPresent.add(MmsSmsColumns.ADDRESS_DEVICE_ID); @@ -589,11 +665,11 @@ private Cursor queryTables(String[] projection, String selection, String order, mmsColumnsPresent.add(MmsDatabase.TRANSACTION_ID); mmsColumnsPresent.add(MmsDatabase.MESSAGE_SIZE); mmsColumnsPresent.add(MmsDatabase.EXPIRY); - mmsColumnsPresent.add(MmsDatabase.NOTIFIED); + mmsColumnsPresent.add(NOTIFIED); mmsColumnsPresent.add(MmsDatabase.STATUS); - mmsColumnsPresent.add(MmsDatabase.UNIDENTIFIED); mmsColumnsPresent.add(MmsDatabase.NETWORK_FAILURE); mmsColumnsPresent.add(MmsSmsColumns.HAS_MENTION); + mmsColumnsPresent.add("mms_hash.server_hash AS " + MmsSmsColumns.SERVER_HASH); mmsColumnsPresent.add(AttachmentDatabase.ROW_ID); mmsColumnsPresent.add(AttachmentDatabase.UNIQUE_ID); @@ -641,7 +717,7 @@ private Cursor queryTables(String[] projection, String selection, String order, smsColumnsPresent.add(MmsSmsColumns.BODY); smsColumnsPresent.add(MmsSmsColumns.ADDRESS); smsColumnsPresent.add(MmsSmsColumns.ADDRESS_DEVICE_ID); - smsColumnsPresent.add(MmsSmsColumns.READ); + smsColumnsPresent.add(READ); smsColumnsPresent.add(MmsSmsColumns.THREAD_ID); smsColumnsPresent.add(MmsSmsColumns.DELIVERY_RECEIPT_COUNT); smsColumnsPresent.add(MmsSmsColumns.READ_RECEIPT_COUNT); @@ -649,13 +725,12 @@ private Cursor queryTables(String[] projection, String selection, String order, smsColumnsPresent.add(MmsSmsColumns.SUBSCRIPTION_ID); smsColumnsPresent.add(MmsSmsColumns.EXPIRES_IN); smsColumnsPresent.add(MmsSmsColumns.EXPIRE_STARTED); - smsColumnsPresent.add(MmsSmsColumns.NOTIFIED); + smsColumnsPresent.add(NOTIFIED); smsColumnsPresent.add(SmsDatabase.TYPE); smsColumnsPresent.add(SmsDatabase.SUBJECT); smsColumnsPresent.add(SmsDatabase.DATE_SENT); 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); @@ -668,6 +743,7 @@ private Cursor queryTables(String[] projection, String selection, String order, smsColumnsPresent.add(ReactionDatabase.DATE_SENT); smsColumnsPresent.add(ReactionDatabase.DATE_RECEIVED); smsColumnsPresent.add(ReactionDatabase.REACTION_JSON_ALIAS); + smsColumnsPresent.add("sms_hash.server_hash AS " + MmsSmsColumns.SERVER_HASH); @SuppressWarnings("deprecation") String mmsSubQuery = mmsQueryBuilder.buildUnionSubQuery(TRANSPORT, mmsProjection, mmsColumnsPresent, 5, MMS_TRANSPORT, selection, null, MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID, null); @@ -683,7 +759,7 @@ private Cursor queryTables(String[] projection, String selection, String order, @SuppressWarnings("deprecation") String query = outerQueryBuilder.buildQuery(projection, null, null, null, null, null, null); - SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SQLiteDatabase db = getReadableDatabase(); return db.rawQuery(query, null); } 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 b832d04dfc..d4c1d947af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/PushDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/PushDatabase.java @@ -16,6 +16,8 @@ import java.io.IOException; +import javax.inject.Provider; + public class PushDatabase extends Database { private static final String TAG = PushDatabase.class.getSimpleName(); @@ -35,7 +37,7 @@ public class PushDatabase extends Database { TYPE + " INTEGER, " + SOURCE + " TEXT, " + DEVICE_ID + " INTEGER, " + LEGACY_MSG + " TEXT, " + CONTENT + " TEXT, " + TIMESTAMP + " INTEGER, " + SERVER_TIMESTAMP + " INTEGER DEFAULT 0, " + SERVER_GUID + " TEXT DEFAULT NULL);"; - public PushDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + public PushDatabase(Context context, Provider databaseHelper) { super(context, databaseHelper); } @@ -55,7 +57,7 @@ public long insert(@NonNull SignalServiceEnvelope envelope) { values.put(SERVER_TIMESTAMP, envelope.getServerTimestamp()); values.put(SERVER_GUID, ""); - return databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, values); + return getWritableDatabase().insert(TABLE_NAME, null, values); } } @@ -63,7 +65,7 @@ public SignalServiceEnvelope get(long id) throws NoSuchMessageException { Cursor cursor = null; try { - cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, ID_WHERE, + cursor = getReadableDatabase().query(TABLE_NAME, null, ID_WHERE, new String[] {String.valueOf(id)}, null, null, null); @@ -89,11 +91,11 @@ public SignalServiceEnvelope get(long id) throws NoSuchMessageException { } public Cursor getPending() { - return databaseHelper.getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null); + return getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null); } public void delete(long id) { - databaseHelper.getWritableDatabase().delete(TABLE_NAME, ID_WHERE, new String[] {id+""}); + getWritableDatabase().delete(TABLE_NAME, ID_WHERE, new String[] {id+""}); } public Reader readerFor(Cursor cursor) { @@ -101,7 +103,7 @@ public Reader readerFor(Cursor cursor) { } private Optional find(SignalServiceEnvelope envelope) { - SQLiteDatabase database = databaseHelper.getReadableDatabase(); + SQLiteDatabase database = getReadableDatabase(); Cursor cursor = null; try { 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 150ec073e1..17f54aeeff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt @@ -9,13 +9,13 @@ import org.session.libsignal.utilities.JsonUtil.SaneJSONObject 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.DatabaseComponent import org.thoughtcrime.securesms.util.CursorUtil +import javax.inject.Provider /** * Store reactions on messages. */ -class ReactionDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { +class ReactionDatabase(context: Context, helper: Provider) : Database(context, helper) { companion object { const val TABLE_NAME = "reaction" @@ -72,10 +72,12 @@ class ReactionDatabase(context: Context, helper: SQLCipherOpenHelper) : Database """ ) + @JvmField + val CREATE_MESSAGE_ID_MMS_INDEX = arrayOf("CREATE INDEX IF NOT EXISTS reaction_message_id_mms_idx ON $TABLE_NAME ($MESSAGE_ID, $IS_MMS)") + private fun readReaction(cursor: Cursor): ReactionRecord { return ReactionRecord( - messageId = CursorUtil.requireLong(cursor, MESSAGE_ID), - isMms = CursorUtil.requireInt(cursor, IS_MMS) == 1, + messageId = MessageId(CursorUtil.requireLong(cursor, MESSAGE_ID), CursorUtil.requireInt(cursor, IS_MMS) == 1), emoji = CursorUtil.requireString(cursor, EMOJI), author = CursorUtil.requireString(cursor, AUTHOR_ID), serverId = CursorUtil.requireString(cursor, SERVER_ID), @@ -102,134 +104,105 @@ class ReactionDatabase(context: Context, helper: SQLCipherOpenHelper) : Database return reactions } - fun addReaction(messageId: MessageId, reaction: ReactionRecord, notifyUnread: Boolean) { + fun addReaction(reaction: ReactionRecord) { + addReactions(mapOf(reaction.messageId to listOf(reaction)), replaceAll = false) + } + + fun addReactions(reactionsByMessageId: Map>, replaceAll: Boolean) { + if (reactionsByMessageId.isEmpty()) return + + val values = ContentValues() writableDatabase.beginTransaction() try { - val values = ContentValues().apply { - put(MESSAGE_ID, messageId.id) - put(IS_MMS, if (messageId.mms) 1 else 0) - put(EMOJI, reaction.emoji) - put(AUTHOR_ID, reaction.author) - put(SERVER_ID, reaction.serverId) - put(COUNT, reaction.count) - put(SORT_ID, reaction.sortId) - put(DATE_SENT, reaction.dateSent) - put(DATE_RECEIVED, reaction.dateReceived) - } + // Delete existing reactions for the same message IDs if replaceAll is true + if (replaceAll && reactionsByMessageId.isNotEmpty()) { + // We don't need to do parameteralized queries here as messageId and isMms are always + // integers/boolean, and hence no risk of SQL injection. + val whereClause = StringBuilder("($MESSAGE_ID, $IS_MMS) IN (") + for ((i, id) in reactionsByMessageId.keys.withIndex()) { + if (i > 0) { + whereClause.append(", ") + } - writableDatabase.insert(TABLE_NAME, null, values) + whereClause + .append('(') + .append(id.id).append(',').append(id.mms) + .append(')') + } + whereClause.append(')') - if (messageId.mms) { - DatabaseComponent.get(context).mmsDatabase().updateReactionsUnread(writableDatabase, messageId.id, hasReactions(messageId), false, notifyUnread) - } else { - DatabaseComponent.get(context).smsDatabase().updateReactionsUnread(writableDatabase, messageId.id, hasReactions(messageId), false, notifyUnread) + writableDatabase.delete(TABLE_NAME, whereClause.toString(), null) } + reactionsByMessageId + .asSequence() + .flatMap { it.value.asSequence() } + .forEach { reaction -> + values.apply { + put(MESSAGE_ID, reaction.messageId.id) + put(IS_MMS, reaction.messageId.mms) + put(EMOJI, reaction.emoji) + put(AUTHOR_ID, reaction.author) + put(SERVER_ID, reaction.serverId) + put(COUNT, reaction.count) + put(SORT_ID, reaction.sortId) + put(DATE_SENT, reaction.dateSent) + put(DATE_RECEIVED, reaction.dateReceived) + } + + writableDatabase.insert(TABLE_NAME, null, values) + } + writableDatabase.setTransactionSuccessful() } finally { writableDatabase.endTransaction() } } - fun deleteReaction(emoji: String, messageId: MessageId, author: String, notifyUnread: Boolean) { + fun deleteReaction(emoji: String, messageId: MessageId, author: String) { deleteReactions( - messageId = messageId, query = "$MESSAGE_ID = ? AND $IS_MMS = ? AND $EMOJI = ? AND $AUTHOR_ID = ?", args = arrayOf("${messageId.id}", "${if (messageId.mms) 1 else 0}", emoji, author), - notifyUnread ) } fun deleteEmojiReactions(emoji: String, messageId: MessageId) { deleteReactions( - messageId = messageId, query = "$MESSAGE_ID = ? AND $IS_MMS = ? AND $EMOJI = ?", args = arrayOf("${messageId.id}", "${if (messageId.mms) 1 else 0}", emoji), - false ) } fun deleteMessageReactions(messageId: MessageId) { deleteReactions( - messageId = messageId, query = "$MESSAGE_ID = ? AND $IS_MMS = ?", args = arrayOf("${messageId.id}", "${if (messageId.mms) 1 else 0}"), - false ) } - private fun deleteReactions(messageId: MessageId, query: String, args: Array, notifyUnread: Boolean) { - writableDatabase.beginTransaction() - try { - writableDatabase.delete(TABLE_NAME, query, args) + fun deleteMessageReactions(messageIds: List) { + if (messageIds.isEmpty()) return // Early exit if the list is empty - if (messageId.mms) { - DatabaseComponent.get(context).mmsDatabase().updateReactionsUnread(writableDatabase, messageId.id, hasReactions(messageId), true, notifyUnread) - } else { - DatabaseComponent.get(context).smsDatabase().updateReactionsUnread(writableDatabase, messageId.id, hasReactions(messageId), true, notifyUnread) - } - - writableDatabase.setTransactionSuccessful() - } finally { - writableDatabase.endTransaction() - } - } - - fun deleteMessageReactions(messageIds: List) { - if (messageIds.isEmpty()) return // Early exit if the list is empty - - val conditions = mutableListOf() - val args = mutableListOf() - - for (messageId in messageIds) { - conditions.add("($MESSAGE_ID = ? AND $IS_MMS = ?)") - args.add(messageId.id.toString()) - args.add(if (messageId.mms) "1" else "0") - } - - val query = conditions.joinToString(" OR ") + val conditions = mutableListOf() + val args = mutableListOf() - deleteReactions( - messageIds = messageIds, - query = query, - args = args.toTypedArray(), - notifyUnread = false - ) - } + for (messageId in messageIds) { + conditions.add("($MESSAGE_ID = ? AND $IS_MMS = ?)") + args.add(messageId.id.toString()) + args.add(if (messageId.mms) "1" else "0") + } - private fun deleteReactions(messageIds: List, query: String, args: Array, notifyUnread: Boolean) { - writableDatabase.beginTransaction() - try { - writableDatabase.delete(TABLE_NAME, query, args) - - // Update unread status for each message - for (messageId in messageIds) { - val hasReaction = hasReactions(messageId) - if (messageId.mms) { - DatabaseComponent.get(context).mmsDatabase().updateReactionsUnread( - writableDatabase, messageId.id, hasReaction, true, notifyUnread - ) - } else { - DatabaseComponent.get(context).smsDatabase().updateReactionsUnread( - writableDatabase, messageId.id, hasReaction, true, notifyUnread - ) - } - } + val query = conditions.joinToString(" OR ") - writableDatabase.setTransactionSuccessful() - } finally { - writableDatabase.endTransaction() - } - } - - private fun hasReactions(messageId: MessageId): Boolean { - val query = "$MESSAGE_ID = ? AND $IS_MMS = ?" - val args = arrayOf("${messageId.id}", "${if (messageId.mms) 1 else 0}") + deleteReactions( + query = query, + args = args.toTypedArray() + ) + } - readableDatabase.query(TABLE_NAME, arrayOf(MESSAGE_ID), query, args, null, null, null).use { cursor -> - return cursor.moveToFirst() - } + private fun deleteReactions(query: String, args: Array) { + writableDatabase.delete(TABLE_NAME, query, args) } fun getReactions(cursor: Cursor): List { @@ -241,20 +214,19 @@ class ReactionDatabase(context: Context, helper: SQLCipherOpenHelper) : Database val result = mutableSetOf() val array = JSONArray(cursor.getString(cursor.getColumnIndexOrThrow(REACTION_JSON_ALIAS))) for (i in 0 until array.length()) { - val `object` = SaneJSONObject(array.getJSONObject(i)) - if (!`object`.isNull(ROW_ID)) { + val obj = SaneJSONObject(array.getJSONObject(i)) + if (!obj.isNull(ROW_ID)) { result.add( ReactionRecord( - `object`.getLong(ROW_ID), - `object`.getLong(MESSAGE_ID), - `object`.getInt(IS_MMS) == 1, - `object`.getString(AUTHOR_ID), - `object`.getString(EMOJI), - `object`.getString(SERVER_ID), - `object`.getLong(COUNT), - `object`.getLong(SORT_ID), - `object`.getLong(DATE_SENT), - `object`.getLong(DATE_RECEIVED) + id = obj.getLong(ROW_ID), + messageId = MessageId(obj.getLong(MESSAGE_ID), obj.getInt(IS_MMS) == 1), + author = obj.getString(AUTHOR_ID), + emoji = obj.getString(EMOJI), + serverId = obj.getString(SERVER_ID), + count = obj.getLong(COUNT), + sortId = obj.getLong(SORT_ID), + dateSent = obj.getLong(DATE_SENT), + dateReceived = obj.getLong(DATE_RECEIVED) ) ) } @@ -263,16 +235,15 @@ class ReactionDatabase(context: Context, helper: SQLCipherOpenHelper) : Database } else { listOf( ReactionRecord( - cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID)), - cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_ID)), - cursor.getInt(cursor.getColumnIndexOrThrow(IS_MMS)) == 1, - cursor.getString(cursor.getColumnIndexOrThrow(AUTHOR_ID)), - cursor.getString(cursor.getColumnIndexOrThrow(EMOJI)), - cursor.getString(cursor.getColumnIndexOrThrow(SERVER_ID)), - cursor.getLong(cursor.getColumnIndexOrThrow(COUNT)), - cursor.getLong(cursor.getColumnIndexOrThrow(SORT_ID)), - cursor.getLong(cursor.getColumnIndexOrThrow(DATE_SENT)), - cursor.getLong(cursor.getColumnIndexOrThrow(DATE_RECEIVED)) + id = cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID)), + messageId = MessageId(cursor.getLong(MESSAGE_ID), cursor.getInt(IS_MMS) == 1), + author = cursor.getString(cursor.getColumnIndexOrThrow(AUTHOR_ID)), + emoji = cursor.getString(cursor.getColumnIndexOrThrow(EMOJI)), + serverId = cursor.getString(cursor.getColumnIndexOrThrow(SERVER_ID)), + count = cursor.getLong(cursor.getColumnIndexOrThrow(COUNT)), + sortId = cursor.getLong(cursor.getColumnIndexOrThrow(SORT_ID)), + dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(DATE_SENT)), + dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(DATE_RECEIVED)) ) ) } 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 fb32fad978..daec3fe928 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -5,31 +5,27 @@ import android.content.ContentValues; import android.content.Context; import android.database.Cursor; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; - import com.annimon.stream.Stream; - import net.zetetic.database.sqlcipher.SQLiteDatabase; - import org.session.libsession.utilities.Address; import org.session.libsession.utilities.MaterialColor; import org.session.libsession.utilities.Util; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.Recipient.RecipientSettings; import org.session.libsession.utilities.recipients.Recipient.RegisteredState; -import org.session.libsession.utilities.recipients.Recipient.UnidentifiedAccessMode; import org.session.libsignal.utilities.Base64; import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.guava.Optional; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; - import java.io.Closeable; import java.io.IOException; import java.util.ArrayList; import java.util.List; +import javax.inject.Provider; + public class RecipientDatabase extends Database { private static final String TAG = RecipientDatabase.class.getSimpleName(); @@ -60,9 +56,11 @@ public class RecipientDatabase extends Database { private static final String CALL_RINGTONE = "call_ringtone"; private static final String CALL_VIBRATE = "call_vibrate"; private static final String NOTIFICATION_CHANNEL = "notification_channel"; + @Deprecated(forRemoval = true) 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 + @Deprecated(forRemoval = true) private static final String WRAPPER_HASH = "wrapper_hash"; private static final String BLOCKS_COMMUNITY_MESSAGE_REQUESTS = "blocks_community_message_requests"; private static final String AUTO_DOWNLOAD = "auto_download"; // 1 / 0 / -1 flag for whether to auto-download in a conversation, or if the user hasn't selected a preference @@ -147,8 +145,8 @@ public static String getUpdateResetApprovedCommand() { public static String getUpdateApprovedSelectConversations() { return "UPDATE "+ TABLE_NAME + " SET "+APPROVED+" = 1, "+APPROVED_ME+" = 1 "+ "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+")))"; + "AND ("+ADDRESS+" IN (SELECT "+ThreadDatabase.TABLE_NAME+"."+ThreadDatabase.ADDRESS+" FROM "+ThreadDatabase.TABLE_NAME+" WHERE "+ + ADDRESS +" IN (SELECT "+GroupDatabase.TABLE_NAME+"."+GroupDatabase.ADMINS+" FROM "+GroupDatabase.TABLE_NAME+")))"; } public static String getCreateDisappearingStateCommand() { @@ -170,12 +168,12 @@ public static String getAddBlocksCommunityMessageRequests() { public static final int NOTIFY_TYPE_MENTIONS = 1; public static final int NOTIFY_TYPE_NONE = 2; - public RecipientDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + public RecipientDatabase(Context context, Provider databaseHelper) { super(context, databaseHelper); } public RecipientReader getRecipientsWithNotificationChannels() { - SQLiteDatabase database = databaseHelper.getReadableDatabase(); + SQLiteDatabase database = getReadableDatabase(); Cursor cursor = database.query(TABLE_NAME, new String[] {ID, ADDRESS}, NOTIFICATION_CHANNEL + " NOT NULL", null, null, null, null, null); @@ -183,9 +181,9 @@ public RecipientReader getRecipientsWithNotificationChannels() { } public Optional getRecipientSettings(@NonNull Address address) { - SQLiteDatabase database = databaseHelper.getReadableDatabase(); + SQLiteDatabase database = getReadableDatabase(); - try (Cursor 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.toString()}, null, null, null)) { if (cursor != null && cursor.moveToNext()) { return getRecipientSettings(cursor); @@ -220,9 +218,7 @@ Optional getRecipientSettings(@NonNull Cursor cursor) { String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SESSION_PROFILE_AVATAR)); boolean profileSharing = cursor.getInt(cursor.getColumnIndexOrThrow(PROFILE_SHARING)) == 1; 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; @@ -255,13 +251,13 @@ Optional getRecipientSettings(@NonNull Cursor cursor) { profileKey, systemDisplayName, systemContactPhoto, systemPhoneLabel, systemContactUri, signalProfileName, signalProfileAvatar, profileSharing, - notificationChannel, Recipient.UnidentifiedAccessMode.fromMode(unidentifiedAccessMode), - forceSmsSelection, wrapperHash, blocksCommunityMessageRequests)); + notificationChannel, + forceSmsSelection, blocksCommunityMessageRequests)); } public boolean isAutoDownloadFlagSet(Recipient recipient) { SQLiteDatabase db = getReadableDatabase(); - Cursor cursor = db.query(TABLE_NAME, new String[]{ AUTO_DOWNLOAD }, ADDRESS+" = ?", new String[]{ recipient.getAddress().serialize() }, null, null, null); + Cursor cursor = db.query(TABLE_NAME, new String[]{ AUTO_DOWNLOAD }, ADDRESS+" = ?", new String[]{ recipient.getAddress().toString() }, null, null, null); boolean flagUnset = false; try { if (cursor.moveToFirst()) { @@ -301,7 +297,7 @@ public void setForceSmsSelection(@NonNull Recipient recipient, boolean forceSmsS 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)) { + try (Cursor cursor = db.query(TABLE_NAME, new String[]{APPROVED}, ADDRESS + " = ?", new String[]{address.toString()}, null, null, null)) { if (cursor != null && cursor.moveToNext()) { return cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED)) == 1; } @@ -309,14 +305,6 @@ public boolean getApproved(@NonNull Address address) { 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); @@ -340,7 +328,7 @@ public void setBlocked(@NonNull Iterable recipients, boolean blocked) ContentValues values = new ContentValues(); values.put(BLOCK, blocked ? 1 : 0); for (Recipient recipient : recipients) { - db.update(TABLE_NAME, values, ADDRESS + " = ?", new String[]{recipient.getAddress().serialize()}); + db.update(TABLE_NAME, values, ADDRESS + " = ?", new String[]{recipient.getAddress().toString()}); recipient.resolve().setBlocked(blocked); } db.setTransactionSuccessful(); @@ -350,13 +338,21 @@ public void setBlocked(@NonNull Iterable recipients, boolean blocked) notifyRecipientListeners(); } + // Delete a recipient with the given address from the database + public void deleteRecipient(@NonNull String recipientAddress) { + SQLiteDatabase db = getWritableDatabase(); + int rowCount = db.delete(TABLE_NAME, ADDRESS + " = ?", new String[] { recipientAddress }); + if (rowCount == 0) { Log.w(TAG, "Could not find to delete recipient with address: " + recipientAddress); } + notifyRecipientListeners(); + } + public void setAutoDownloadAttachments(@NonNull Recipient recipient, boolean shouldAutoDownloadAttachments) { SQLiteDatabase db = getWritableDatabase(); db.beginTransaction(); try { ContentValues values = new ContentValues(); values.put(AUTO_DOWNLOAD, shouldAutoDownloadAttachments ? 1 : 0); - db.update(TABLE_NAME, values, ADDRESS+ " = ?", new String[]{recipient.getAddress().serialize()}); + db.update(TABLE_NAME, values, ADDRESS+ " = ?", new String[]{recipient.getAddress().toString()}); recipient.resolve().setAutoDownloadAttachments(shouldAutoDownloadAttachments); db.setTransactionSuccessful(); } finally { @@ -387,14 +383,6 @@ public void setNotifyType(@NonNull Recipient recipient, int notifyType) { notifyRecipientListeners(); } - public void setUnidentifiedAccessMode(@NonNull Recipient recipient, @NonNull UnidentifiedAccessMode unidentifiedAccessMode) { - ContentValues values = new ContentValues(1); - values.put(UNIDENTIFIED_ACCESS_MODE, unidentifiedAccessMode.getMode()); - updateOrInsert(recipient.getAddress(), values); - recipient.resolve().setUnidentifiedAccessMode(unidentifiedAccessMode); - notifyRecipientListeners(); - } - public void setProfileKey(@NonNull Recipient recipient, @Nullable byte[] profileKey) { ContentValues values = new ContentValues(1); values.put(PROFILE_KEY, profileKey == null ? null : Base64.encodeBytes(profileKey)); @@ -453,15 +441,15 @@ public void setBlocksCommunityMessageRequests(@NonNull Recipient recipient, bool } private void updateOrInsert(Address address, ContentValues contentValues) { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); + SQLiteDatabase database = getWritableDatabase(); database.beginTransaction(); int updated = database.update(TABLE_NAME, contentValues, ADDRESS + " = ?", - new String[] {address.serialize()}); + new String[] {address.toString()}); if (updated < 1) { - contentValues.put(ADDRESS, address.serialize()); + contentValues.put(ADDRESS, address.toString()); database.insert(TABLE_NAME, null, contentValues); } @@ -470,7 +458,7 @@ private void updateOrInsert(Address address, ContentValues contentValues) { } public List getBlockedContacts() { - SQLiteDatabase database = databaseHelper.getReadableDatabase(); + SQLiteDatabase database = getReadableDatabase(); Cursor cursor = database.query(TABLE_NAME, new String[] {ID, ADDRESS}, BLOCK + " = 1", null, null, null, null, null); @@ -485,6 +473,29 @@ public List getBlockedContacts() { return returnList; } + /** + * Returns a list of all recipients in the database. + * + * @return A list of all recipients + */ + public List getAllRecipients() { + SQLiteDatabase database = getReadableDatabase(); + + Cursor cursor = database.query(TABLE_NAME, new String[] {ID, ADDRESS}, null, + null, null, null, null, null); + + RecipientReader reader = new RecipientReader(context, cursor); + List returnList = new ArrayList<>(); + Recipient current; + + while ((current = reader.getNext()) != null) { + returnList.add(current); + } + + reader.close(); + return returnList; + } + public void setDisappearingState(@NonNull Recipient recipient, @NonNull Recipient.DisappearingState disappearingState) { ContentValues values = new ContentValues(); values.put(DISAPPEARING_STATE, disappearingState.getId()); 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 e2b3eb9e27..9358f1e187 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.database; +import static org.thoughtcrime.securesms.database.UtilKt.generatePlaceholders; + import android.content.Context; import android.database.Cursor; @@ -13,7 +15,12 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Set; + +import javax.inject.Provider; /** * Contains all databases necessary for full-text search (FTS). @@ -31,114 +38,182 @@ public class SearchDatabase extends Database { public static final String MESSAGE_ADDRESS = "message_address"; public static final String[] CREATE_TABLE = { - "CREATE VIRTUAL TABLE " + SMS_FTS_TABLE_NAME + " USING fts5(" + BODY + ", " + THREAD_ID + " UNINDEXED, content=" + SmsDatabase.TABLE_NAME + ", content_rowid=" + SmsDatabase.ID + ");", - - "CREATE TRIGGER sms_ai AFTER INSERT ON " + SmsDatabase.TABLE_NAME + " BEGIN\n" + - " INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES (new." + SmsDatabase.ID + ", new." + SmsDatabase.BODY + ", new." + SmsDatabase.THREAD_ID + ");\n" + - "END;\n", - "CREATE TRIGGER sms_ad AFTER DELETE ON " + SmsDatabase.TABLE_NAME + " BEGIN\n" + - " INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + SMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + SmsDatabase.ID + ", old." + SmsDatabase.BODY + ", old." + SmsDatabase.THREAD_ID + ");\n" + - "END;\n", - "CREATE TRIGGER sms_au AFTER UPDATE ON " + SmsDatabase.TABLE_NAME + " BEGIN\n" + - " INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + SMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + SmsDatabase.ID + ", old." + SmsDatabase.BODY + ", old." + SmsDatabase.THREAD_ID + ");\n" + - " INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES(new." + SmsDatabase.ID + ", new." + SmsDatabase.BODY + ", new." + SmsDatabase.THREAD_ID + ");\n" + - "END;", - - - "CREATE VIRTUAL TABLE " + MMS_FTS_TABLE_NAME + " USING fts5(" + BODY + ", " + THREAD_ID + " UNINDEXED, content=" + MmsDatabase.TABLE_NAME + ", content_rowid=" + MmsDatabase.ID + ");", - - "CREATE TRIGGER mms_ai AFTER INSERT ON " + MmsDatabase.TABLE_NAME + " BEGIN\n" + - " INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES (new." + MmsDatabase.ID + ", new." + MmsDatabase.BODY + ", new." + MmsDatabase.THREAD_ID + ");\n" + - "END;\n", - "CREATE TRIGGER mms_ad AFTER DELETE ON " + MmsDatabase.TABLE_NAME + " BEGIN\n" + - " INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + MMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + MmsDatabase.ID + ", old." + MmsDatabase.BODY + ", old." + MmsDatabase.THREAD_ID + ");\n" + - "END;\n", - "CREATE TRIGGER mms_au AFTER UPDATE ON " + MmsDatabase.TABLE_NAME + " BEGIN\n" + - " INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + MMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + MmsDatabase.ID + ", old." + MmsDatabase.BODY + ", old." + MmsDatabase.THREAD_ID + ");\n" + - " INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES (new." + MmsDatabase.ID + ", new." + MmsDatabase.BODY + ", new." + MmsDatabase.THREAD_ID + ");\n" + - "END;" + "CREATE VIRTUAL TABLE " + SMS_FTS_TABLE_NAME + " USING fts5(" + BODY + ", " + THREAD_ID + " UNINDEXED, content=" + SmsDatabase.TABLE_NAME + ", content_rowid=" + SmsDatabase.ID + ");", + + "CREATE TRIGGER sms_ai AFTER INSERT ON " + SmsDatabase.TABLE_NAME + " BEGIN\n" + + " INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES (new." + SmsDatabase.ID + ", new." + SmsDatabase.BODY + ", new." + SmsDatabase.THREAD_ID + ");\n" + + "END;\n", + "CREATE TRIGGER sms_ad AFTER DELETE ON " + SmsDatabase.TABLE_NAME + " BEGIN\n" + + " INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + SMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + SmsDatabase.ID + ", old." + SmsDatabase.BODY + ", old." + SmsDatabase.THREAD_ID + ");\n" + + "END;\n", + "CREATE TRIGGER sms_au AFTER UPDATE ON " + SmsDatabase.TABLE_NAME + " BEGIN\n" + + " INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + SMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + SmsDatabase.ID + ", old." + SmsDatabase.BODY + ", old." + SmsDatabase.THREAD_ID + ");\n" + + " INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES(new." + SmsDatabase.ID + ", new." + SmsDatabase.BODY + ", new." + SmsDatabase.THREAD_ID + ");\n" + + "END;", + + + "CREATE VIRTUAL TABLE " + MMS_FTS_TABLE_NAME + " USING fts5(" + BODY + ", " + THREAD_ID + " UNINDEXED, content=" + MmsDatabase.TABLE_NAME + ", content_rowid=" + MmsDatabase.ID + ");", + + "CREATE TRIGGER mms_ai AFTER INSERT ON " + MmsDatabase.TABLE_NAME + " BEGIN\n" + + " INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES (new." + MmsDatabase.ID + ", new." + MmsDatabase.BODY + ", new." + MmsDatabase.THREAD_ID + ");\n" + + "END;\n", + "CREATE TRIGGER mms_ad AFTER DELETE ON " + MmsDatabase.TABLE_NAME + " BEGIN\n" + + " INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + MMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + MmsDatabase.ID + ", old." + MmsDatabase.BODY + ", old." + MmsDatabase.THREAD_ID + ");\n" + + "END;\n", + "CREATE TRIGGER mms_au AFTER UPDATE ON " + MmsDatabase.TABLE_NAME + " BEGIN\n" + + " INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + MMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + MmsDatabase.ID + ", old." + MmsDatabase.BODY + ", old." + MmsDatabase.THREAD_ID + ");\n" + + " INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES (new." + MmsDatabase.ID + ", new." + MmsDatabase.BODY + ", new." + MmsDatabase.THREAD_ID + ");\n" + + "END;" }; - private static final String MESSAGES_QUERY = - "SELECT " + - 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_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 + " " + - "INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " + - "WHERE " + SMS_FTS_TABLE_NAME + " MATCH ? " + " AND NOT " + MmsSmsColumns.IS_DELETED + " AND NOT " + MmsSmsColumns.IS_GROUP_UPDATE + " " + - "UNION ALL " + - "SELECT " + - 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_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 NOT " + MmsSmsColumns.IS_DELETED + " AND NOT " + MmsSmsColumns.IS_GROUP_UPDATE + " " + - "ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC " + - "LIMIT ?"; - - private static final String MESSAGES_FOR_THREAD_QUERY = - "SELECT " + - 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_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 + " " + - "INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " + - "WHERE " + SMS_FTS_TABLE_NAME + " MATCH ? AND " + SmsDatabase.TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = ? " + " AND NOT " + MmsSmsColumns.IS_DELETED + " AND NOT " + MmsSmsColumns.IS_GROUP_UPDATE + " " + - "UNION ALL " + - "SELECT " + - 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_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 + " = ? " + " AND NOT " + MmsSmsColumns.IS_DELETED + " AND NOT " + MmsSmsColumns.IS_GROUP_UPDATE + " " + - "ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC " + - "LIMIT 500"; - - public SearchDatabase(@NonNull Context context, @NonNull SQLCipherOpenHelper databaseHelper) { + // Base query definitions with placeholders for blocked contact filtering + private static final String MESSAGES_QUERY_BASE = + "SELECT " + + 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_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 + " " + + "INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " + + "WHERE " + SMS_FTS_TABLE_NAME + " MATCH ? " + + "AND NOT " + MmsSmsColumns.IS_DELETED + + " AND NOT " + MmsSmsColumns.IS_GROUP_UPDATE + + " %s " + // placeholder for blocked + "UNION ALL " + + "SELECT " + + 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_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 NOT " + MmsSmsColumns.IS_DELETED + + " AND NOT " + MmsSmsColumns.IS_GROUP_UPDATE + + " %s " + // placeholder for blocked + "ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC " + + "LIMIT ?"; + + private static final String MESSAGES_FOR_THREAD_QUERY_BASE = + "SELECT " + + 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_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 + " " + + "INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " + + "WHERE " + SMS_FTS_TABLE_NAME + " MATCH ? AND " + SmsDatabase.TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = ? " + + "AND NOT " + MmsSmsColumns.IS_DELETED + + " AND NOT " + MmsSmsColumns.IS_GROUP_UPDATE + + " %s " + // placeholder for blocked + "UNION ALL " + + "SELECT " + + 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_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 + " = ? " + + "AND NOT " + MmsSmsColumns.IS_DELETED + + " AND NOT " + MmsSmsColumns.IS_GROUP_UPDATE + + " %s " + // placeholder for blocked + "ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC " + + "LIMIT 500"; + + public SearchDatabase(@NonNull Context context, @NonNull Provider databaseHelper) { super(context, databaseHelper); } - 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) }); + public Cursor queryMessages(@NonNull String query, @NonNull Set blockedContacts) { + SQLiteDatabase db = getReadableDatabase(); + String prefixQuery = adjustQuery(query); + int queryLimit = Math.min(query.length()*50, 500); + + // Build the blocked contacts filter clause if needed + String blockedFilter = ""; + if (!blockedContacts.isEmpty()) { + blockedFilter = " AND " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " NOT IN (" + + generatePlaceholders(blockedContacts.size()) + ")"; + } + + // Format the query with the filter placeholders + String messagesQuery = String.format(MESSAGES_QUERY_BASE, blockedFilter, blockedFilter); + + // Build the query arguments + List args = new ArrayList<>(); + args.add(prefixQuery); // For SMS query + + // Add blocked contacts for SMS query if any + if (!blockedContacts.isEmpty()) { + args.addAll(blockedContacts); + } + + args.add(prefixQuery); // For MMS query + + // Add blocked contacts for MMS query if any + if (!blockedContacts.isEmpty()) { + args.addAll(blockedContacts); + } + + args.add(String.valueOf(queryLimit)); + + Cursor cursor = db.rawQuery(messagesQuery, args.toArray(new String[0])); setNotifyConversationListListeners(cursor); return cursor; } - public Cursor queryMessages(@NonNull String query, long threadId) { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - String prefixQuery = adjustQuery(query); + public Cursor queryMessages(@NonNull String query, long threadId, @NonNull Set blockedContacts) { + SQLiteDatabase db = getReadableDatabase(); + String prefixQuery = adjustQuery(query); + + // Build the blocked contacts filter clause if needed + String blockedFilter = ""; + if (!blockedContacts.isEmpty()) { + blockedFilter = " AND " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " NOT IN (" + + generatePlaceholders(blockedContacts.size()) + ")"; + } + + // Format the query with the filter placeholders + String messagesForThreadQuery = String.format(MESSAGES_FOR_THREAD_QUERY_BASE, blockedFilter, blockedFilter); + + // Build the query arguments + List args = new ArrayList<>(); + args.add(prefixQuery); + args.add(String.valueOf(threadId)); + + // Add blocked contacts for SMS query if any + if (!blockedContacts.isEmpty()) { + args.addAll(blockedContacts); + } - Cursor cursor = db.rawQuery(MESSAGES_FOR_THREAD_QUERY, new String[] { prefixQuery, String.valueOf(threadId), prefixQuery, String.valueOf(threadId) }); + args.add(prefixQuery); + args.add(String.valueOf(threadId)); + + // Add blocked contacts for MMS query if any + if (!blockedContacts.isEmpty()) { + args.addAll(blockedContacts); + } + + Cursor cursor = db.rawQuery(messagesForThreadQuery, args.toArray(new String[0])); setNotifyConversationListListeners(cursor); return cursor; - } private String adjustQuery(@NonNull String query) { - List tokens = Stream.of(query.split(" ")).filter(s -> s.trim().length() > 0).toList(); + List tokens = Stream.of(query.split(" ")).filter(s -> s.trim().length() > 0).toList(); String prefixQuery = Util.join(tokens, "* "); prefixQuery += "*"; return prefixQuery; } -} - +} \ No newline at end of file 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 70d12c0c32..df8bf98be0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt @@ -8,9 +8,11 @@ import org.session.libsession.messaging.contacts.Contact import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import javax.inject.Provider -class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { +class SessionContactDatabase(context: Context, helper: Provider) : Database(context, helper) { companion object { const val sessionContactTable = "session_contact_database" @@ -35,14 +37,14 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da } fun getContactWithAccountID(accountID: String): Contact? { - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.get(sessionContactTable, "${Companion.accountID} = ?", arrayOf( accountID )) { cursor -> contactFromCursor(cursor) } } fun getContacts(accountIDs: Collection): List { - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.getAll( sessionContactTable, "$accountID IN (SELECT value FROM json_each(?))", @@ -51,16 +53,14 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da } fun getAllContacts(): Set { - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.getAll(sessionContactTable, null, null) { cursor -> contactFromCursor(cursor) - }.filter { contact -> - contact.accountID.let(::AccountId).prefix == IdPrefix.STANDARD }.toSet() } fun setContactIsTrusted(contact: Contact, isTrusted: Boolean, threadID: Long) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val contentValues = ContentValues(1) contentValues.put(Companion.isTrusted, if (isTrusted) 1 else 0) database.update(sessionContactTable, contentValues, "$accountID = ?", arrayOf( contact.accountID )) @@ -71,7 +71,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da } fun setContact(contact: Contact) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val contentValues = ContentValues(8) contentValues.put(accountID, contact.accountID) contentValues.put(name, contact.name) @@ -86,6 +86,15 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da notifyConversationListListeners() } + fun deleteContact(accountId: String) { + val database = writableDatabase + val rowsAffected = database.delete(sessionContactTable, "$accountID = ?", arrayOf( accountId )) + if (rowsAffected == 0) { + Log.w("SessionContactDatabase", "Failed to delete contact with id: $accountId") + } + notifyConversationListListeners() + } + fun contactFromCursor(cursor: Cursor): Contact { val contact = Contact(cursor.getString(accountID)) contact.name = cursor.getStringOrNull(name) @@ -99,12 +108,23 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da return contact } - fun queryContactsByName(constraint: String): Cursor { - return databaseHelper.readableDatabase.query( - sessionContactTable, null, " $name LIKE ? OR $nickname LIKE ?", arrayOf( - "%$constraint%", - "%$constraint%" - ), + fun queryContactsByName(constraint: String, excludeUserAddresses: Set = emptySet()): Cursor { + val whereClause = StringBuilder("($name LIKE ? OR $nickname LIKE ?)") + val whereArgs = ArrayList() + whereArgs.add("%$constraint%") + whereArgs.add("%$constraint%") + + // filter out users is the list isn't empty + if (excludeUserAddresses.isNotEmpty()) { + whereClause.append(" AND $accountID NOT IN (") + whereClause.append(excludeUserAddresses.joinToString(", ") { "?" }) + whereClause.append(")") + + whereArgs.addAll(excludeUserAddresses) + } + + return readableDatabase.query( + sessionContactTable, null, whereClause.toString(), whereArgs.toTypedArray(), null, null, null ) } 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 e83c464c7d..92767715e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt @@ -3,22 +3,29 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import android.database.Cursor +import dagger.hilt.android.qualifiers.ApplicationContext import org.json.JSONArray 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 import org.session.libsession.messaging.jobs.Job -import org.session.libsession.messaging.jobs.MessageReceiveJob import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.jobs.SessionJobInstantiator -import org.session.libsession.messaging.jobs.SessionJobManagerFactories import org.session.libsession.messaging.utilities.Data import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton -class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { +@Singleton +class SessionJobDatabase @Inject constructor( + @ApplicationContext context: Context, + helper: Provider, + private val jobInstantiator: SessionJobInstantiator +) : Database(context, helper) { companion object { const val sessionJobTable = "session_job_database" @@ -34,7 +41,7 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa } fun persistJob(job: Job) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val contentValues = ContentValues(4) contentValues.put(jobID, job.id!!) contentValues.put(jobType, job.getFactoryKey()) @@ -44,15 +51,15 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa } fun markJobAsSucceeded(jobID: String) { - databaseHelper.writableDatabase.delete(sessionJobTable, "${Companion.jobID} = ?", arrayOf( jobID )) + writableDatabase.delete(sessionJobTable, "${Companion.jobID} = ?", arrayOf( jobID )) } fun markJobAsFailedPermanently(jobID: String) { - databaseHelper.writableDatabase.delete(sessionJobTable, "${Companion.jobID} = ?", arrayOf( jobID )) + writableDatabase.delete(sessionJobTable, "${Companion.jobID} = ?", arrayOf( jobID )) } fun getAllJobs(vararg types: String): Map { - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.getAll( sessionJobTable, "$jobType IN (SELECT value FROM json_each(?))", // Use json_each to bypass limitation of SQLite's IN operator binding @@ -69,7 +76,7 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa } fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob? { - val database = databaseHelper.readableDatabase + val database = readableDatabase val result = mutableListOf() database.getAll(sessionJobTable, "$jobType = ?", arrayOf( AttachmentUploadJob.KEY )) { cursor -> val job = jobFromCursor(cursor) as AttachmentUploadJob? @@ -79,28 +86,22 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa } fun getMessageSendJob(messageSendJobID: String): MessageSendJob? { - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.get(sessionJobTable, "$jobID = ? AND $jobType = ?", arrayOf( messageSendJobID, MessageSendJob.KEY )) { cursor -> jobFromCursor(cursor) as MessageSendJob? } } - fun getMessageReceiveJob(messageReceiveJobID: String): MessageReceiveJob? { - val database = databaseHelper.readableDatabase - return database.get(sessionJobTable, "$jobID = ? AND $jobType = ?", arrayOf( messageReceiveJobID, MessageReceiveJob.KEY )) { cursor -> - jobFromCursor(cursor) as MessageReceiveJob? - } - } fun getGroupAvatarDownloadJob(server: String, room: String, imageId: String?): GroupAvatarDownloadJob? { - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.getAll(sessionJobTable, "$jobType = ?", arrayOf(GroupAvatarDownloadJob.KEY)) { jobFromCursor(it) as GroupAvatarDownloadJob? }.filterNotNull().find { it.server == server && it.room == room && (imageId == null || it.imageId == imageId) } } fun cancelPendingMessageSendJobs(threadID: Long) { - val database = databaseHelper.writableDatabase + val database = writableDatabase val attachmentUploadJobKeys = mutableListOf() database.beginTransaction() database.getAll(sessionJobTable, "$jobType = ?", arrayOf( AttachmentUploadJob.KEY )) { cursor -> @@ -129,7 +130,7 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa } fun isJobCanceled(job: Job): Boolean { - val database = databaseHelper.readableDatabase + val database = readableDatabase var cursor: android.database.Cursor? = null try { cursor = database.rawQuery("SELECT * FROM $sessionJobTable WHERE $jobID = ?", arrayOf( job.id!! )) @@ -145,14 +146,14 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa private fun jobFromCursor(cursor: Cursor): Job? { val type = cursor.getString(jobType) val data = SessionJobHelper.dataSerializer.deserialize(cursor.getString(serializedData)) - val job = SessionJobHelper.sessionJobInstantiator.instantiate(type, data) ?: return null + val job = jobInstantiator.instantiate(type, data) ?: return null job.id = cursor.getString(jobID) job.failureCount = cursor.getInt(failureCount) return job } fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean { - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.getAll(sessionJobTable, "$jobType = ?", arrayOf(BackgroundGroupAddJob.KEY)) { cursor -> jobFromCursor(cursor) as? BackgroundGroupAddJob }.filterNotNull().any { it.joinUrl == groupJoinUrl } @@ -161,5 +162,4 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa object SessionJobHelper { val dataSerializer: Data.Serializer = JsonDataSerializer() - val sessionJobInstantiator: SessionJobInstantiator = SessionJobInstantiator(SessionJobManagerFactories.getSessionJobFactories()) } \ No newline at end of file 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 d9f8e07e0b..018b604888 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -24,13 +24,13 @@ import android.content.Context; import android.database.Cursor; import android.text.TextUtils; -import android.util.Pair; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.annimon.stream.Stream; import net.zetetic.database.sqlcipher.SQLiteDatabase; -import net.zetetic.database.sqlcipher.SQLiteStatement; -import org.apache.commons.lang3.StringUtils; + +import org.json.JSONArray; import org.session.libsession.messaging.calls.CallMessageType; import org.session.libsession.messaging.messages.signal.IncomingGroupMessage; import org.session.libsession.messaging.messages.signal.IncomingTextMessage; @@ -46,18 +46,22 @@ import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.guava.Optional; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.MessageId; 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.util.Arrays; +import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.Set; + +import javax.inject.Provider; /** * Database for storage of SMS messages. @@ -108,7 +112,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, HAS_MENTION, + NOTIFIED, READ_RECEIPT_COUNT, 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 + ", " + @@ -147,7 +151,7 @@ public class SmsDatabase extends MessagingDatabase { private static final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache(); private static final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache(); - public SmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + public SmsDatabase(Context context, Provider databaseHelper) { super(context, databaseHelper); } @@ -158,7 +162,7 @@ protected String getTableName() { private void updateTypeBitmask(long id, long maskOff, long maskOn) { Log.i("MessageDatabase", "Updating ID: " + id + " to base type: " + maskOn); - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); db.execSQL("UPDATE " + TABLE_NAME + " SET " + TYPE + " = (" + TYPE + " & " + (Types.TOTAL_MASK - maskOff) + " | " + maskOn + " )" + " WHERE " + ID + " = ?", new String[] {id+""}); @@ -172,7 +176,7 @@ private void updateTypeBitmask(long id, long maskOff, long maskOn) { public long getThreadIdForMessage(long id) { String sql = "SELECT " + THREAD_ID + " FROM " + TABLE_NAME + " WHERE " + ID + " = ?"; String[] sqlArgs = new String[] {id+""}; - SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SQLiteDatabase db = getReadableDatabase(); Cursor cursor = null; @@ -189,7 +193,7 @@ public long getThreadIdForMessage(long id) { } public int getMessageCountForThread(long threadId) { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SQLiteDatabase db = getReadableDatabase(); Cursor cursor = null; try { @@ -211,8 +215,8 @@ public void markAsDecryptFailed(long id) { } @Override - public void markAsSent(long id, boolean isSecure) { - updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SENT_TYPE | (isSecure ? Types.PUSH_MESSAGE_BIT | Types.SECURE_MESSAGE_BIT : 0)); + public void markAsSent(long id, boolean isSent) { + updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SENT_TYPE | (isSent ? Types.PUSH_MESSAGE_BIT | Types.SECURE_MESSAGE_BIT : 0)); } public void markAsSending(long id) { @@ -234,23 +238,15 @@ 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); - contentValues.put(UNIDENTIFIED, unidentified ? 1 : 0); - - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(id)}); - } - @Override public void markAsDeleted(long messageId, boolean isOutgoing, String displayedMessage) { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); + SQLiteDatabase database = getWritableDatabase(); ContentValues contentValues = new ContentValues(); contentValues.put(READ, 1); contentValues.put(BODY, displayedMessage); contentValues.put(HAS_MENTION, 0); contentValues.put(STATUS, Status.STATUS_NONE); + database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)}); updateTypeBitmask(messageId, Types.BASE_TYPE_MASK, @@ -263,13 +259,14 @@ public void markExpireStarted(long id, long startedAtTimestamp) { ContentValues contentValues = new ContentValues(); contentValues.put(EXPIRE_STARTED, startedAtTimestamp); - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(id)}); - - long threadId = getThreadIdForMessage(id); - - DatabaseComponent.get(context).threadDatabase().update(threadId, false); - notifyConversationListeners(threadId); + SQLiteDatabase db = getWritableDatabase(); + try (final Cursor cursor = db.rawQuery("UPDATE " + TABLE_NAME + " SET " + EXPIRE_STARTED + " = ? " + + "WHERE " + ID + " = ? RETURNING " + THREAD_ID, startedAtTimestamp, id)) { + if (cursor.moveToNext()) { + long threadId = cursor.getLong(0); + DatabaseComponent.get(context).threadDatabase().update(threadId, false); + } + } } public void markAsSentFailed(long id) { @@ -277,7 +274,7 @@ public void markAsSentFailed(long id) { } public void markAsNotified(long id) { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); + SQLiteDatabase database = getWritableDatabase(); ContentValues contentValues = new ContentValues(); contentValues.put(NOTIFIED, 1); @@ -285,14 +282,14 @@ public void markAsNotified(long id) { database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(id)}); } - public boolean isOutgoingMessage(long timestamp) { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); + public boolean isOutgoingMessage(long id) { + SQLiteDatabase database = getWritableDatabase(); Cursor cursor = null; boolean isOutgoing = false; try { cursor = database.query(TABLE_NAME, new String[] { ID, THREAD_ID, ADDRESS, TYPE }, - DATE_SENT + " = ?", new String[] { String.valueOf(timestamp) }, + ID + " = ?", new String[] { String.valueOf(id) }, null, null, null, null); while (cursor.moveToNext()) { @@ -307,14 +304,14 @@ public boolean isOutgoingMessage(long timestamp) { return isOutgoing; } - public boolean isDeletedMessage(long timestamp) { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); + public boolean isDeletedMessage(long id) { + SQLiteDatabase database = getWritableDatabase(); Cursor cursor = null; boolean isDeleted = false; try { cursor = database.query(TABLE_NAME, new String[] { ID, THREAD_ID, ADDRESS, TYPE }, - DATE_SENT + " = ?", new String[] { String.valueOf(timestamp) }, + ID + " = ?", new String[] { String.valueOf(id) }, null, null, null, null); while (cursor.moveToNext()) { @@ -335,7 +332,7 @@ public String getTypeColumn() { } public void incrementReceiptCount(SyncMessageId messageId, boolean deliveryReceipt, boolean readReceipt) { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); + SQLiteDatabase database = getWritableDatabase(); Cursor cursor = null; boolean foundMessage = false; @@ -377,10 +374,10 @@ public void incrementReceiptCount(SyncMessageId messageId, boolean deliveryRecei } public List setMessagesRead(long threadId, long beforeTime) { - return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1) AND " + DATE_SENT + " <= ?", new String[]{threadId+"", beforeTime+""}); + return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0) AND " + DATE_SENT + " <= ?", new String[]{threadId+"", beforeTime+""}); } public List setMessagesRead(long threadId) { - return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)", new String[] {String.valueOf(threadId)}); + return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0)", new String[] {String.valueOf(threadId)}); } public List setAllMessagesRead() { @@ -388,7 +385,7 @@ public List setAllMessagesRead() { } private List setMessagesRead(String where, String[] arguments) { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); + SQLiteDatabase database = getWritableDatabase(); List results = new LinkedList<>(); Cursor cursor = null; @@ -399,7 +396,7 @@ private List setMessagesRead(String where, String[] arguments while (cursor != null && cursor.moveToNext()) { 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); + ExpirationInfo expirationInfo = new ExpirationInfo(new MessageId(cursor.getLong(0), false), timestamp, cursor.getLong(4), cursor.getLong(5)); results.add(new MarkedMessageInfo(syncMessageId, expirationInfo)); } @@ -418,34 +415,16 @@ private List setMessagesRead(String where, String[] arguments return results; } - public void updateSentTimestamp(long messageId, long newTimestamp, long threadId) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.execSQL("UPDATE " + TABLE_NAME + " SET " + DATE_SENT + " = ? " + - "WHERE " + ID + " = ?", - new String[] {newTimestamp + "", messageId + ""}); - notifyConversationListeners(threadId); - notifyConversationListListeners(); - } - - public Pair updateBundleMessageBody(long messageId, String body) { - long type = Types.BASE_INBOX_TYPE | Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT; - return updateMessageBodyAndType(messageId, body, Types.TOTAL_MASK, type); - } - - private Pair updateMessageBodyAndType(long messageId, String body, long maskOff, long maskOn) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.execSQL("UPDATE " + TABLE_NAME + " SET " + BODY + " = ?, " + - TYPE + " = (" + TYPE + " & " + (Types.TOTAL_MASK - maskOff) + " | " + maskOn + ") " + - "WHERE " + ID + " = ?", - new String[] {body, messageId + ""}); - - long threadId = getThreadIdForMessage(messageId); + public void updateSentTimestamp(long messageId, long newTimestamp) { + SQLiteDatabase db = getWritableDatabase(); + try(final Cursor cursor = db.rawQuery("UPDATE " + TABLE_NAME + " SET " + DATE_SENT + " = ? " + + "WHERE " + ID + " = ? RETURNING " + THREAD_ID, newTimestamp, messageId)) { + if (cursor.moveToNext()) { + notifyConversationListeners(cursor.getLong(0)); + } + } - DatabaseComponent.get(context).threadDatabase().update(threadId, true); - notifyConversationListeners(threadId); notifyConversationListListeners(); - - return new Pair<>(messageId, threadId); } protected Optional insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runThreadUpdate) { @@ -459,8 +438,7 @@ protected Optional insertMessageInbox(IncomingTextMessage message, groupRecipient = Recipient.from(context, message.getGroupId(), true); } - boolean unread = (Util.isDefaultSmsProvider(context) || - message.isSecureMessage() || message.isGroup() || message.isCallInfo()); + boolean unread = (message.isSecureMessage() || message.isGroup() || message.isUnreadCallMessage()); long threadId; @@ -484,7 +462,7 @@ protected Optional insertMessageInbox(IncomingTextMessage message, } ContentValues values = new ContentValues(6); - values.put(ADDRESS, message.getSender().serialize()); + values.put(ADDRESS, message.getSender().toString()); values.put(ADDRESS_DEVICE_ID, message.getSenderDeviceId()); // In open groups messages should be sorted by their server timestamp long receivedTimestamp = serverTimestamp; @@ -512,7 +490,7 @@ protected Optional insertMessageInbox(IncomingTextMessage message, Log.w(TAG, "Duplicate message (" + message.getSentTimestampMillis() + "), ignoring..."); return Optional.absent(); } else { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); long messageId = db.insert(TABLE_NAME, null, values); if (runThreadUpdate) { @@ -560,7 +538,7 @@ public Optional insertMessageOutbox(long threadId, OutgoingTextMes if (threadId == -1) { threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(message.getRecipient()); } - long messageId = insertMessageOutbox(threadId, message, false, serverTimestamp, null, runThreadUpdate); + long messageId = insertMessageOutbox(threadId, message, false, serverTimestamp, runThreadUpdate); if (messageId == -1) { return Optional.absent(); } @@ -569,7 +547,7 @@ public Optional insertMessageOutbox(long threadId, OutgoingTextMes } public long insertMessageOutbox(long threadId, OutgoingTextMessage message, - boolean forceSms, long date, InsertListener insertListener, + boolean forceSms, long date, boolean runThreadUpdate) { long type = Types.BASE_SENDING_TYPE; @@ -582,8 +560,8 @@ public long insertMessageOutbox(long threadId, OutgoingTextMessage message, Map earlyDeliveryReceipts = earlyDeliveryReceiptCache.remove(date); Map earlyReadReceipts = earlyReadReceiptCache.remove(date); - ContentValues contentValues = new ContentValues(6); - contentValues.put(ADDRESS, address.serialize()); + ContentValues contentValues = new ContentValues(); + contentValues.put(ADDRESS, address.toString()); contentValues.put(THREAD_ID, threadId); contentValues.put(BODY, message.getMessageBody()); contentValues.put(DATE_RECEIVED, SnodeAPI.getNowWithOffset()); @@ -601,11 +579,8 @@ public long insertMessageOutbox(long threadId, OutgoingTextMessage message, return -1; } - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); long messageId = db.insert(TABLE_NAME, ADDRESS, contentValues); - if (insertListener != null) { - insertListener.onComplete(); - } if (runThreadUpdate) { DatabaseComponent.get(context).threadDatabase().update(threadId, true); @@ -624,73 +599,71 @@ public long insertMessageOutbox(long threadId, OutgoingTextMessage message, } private Cursor rawQuery(@NonNull String where, @Nullable String[] arguments) { - SQLiteDatabase database = databaseHelper.getReadableDatabase(); + SQLiteDatabase database = getReadableDatabase(); return database.rawQuery("SELECT " + Util.join(MESSAGE_PROJECTION, ",") + " FROM " + SmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME + " ON (" + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " = " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 0)" + " WHERE " + where + " GROUP BY " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID, arguments); } - public Cursor getExpirationStartedMessages() { - String where = EXPIRE_STARTED + " > 0"; - return rawQuery(where, null); + @Override + public List getExpiredMessageIDs(long nowMills) { + String query = "SELECT " + ID + " FROM " + TABLE_NAME + + " WHERE " + EXPIRES_IN + " > 0 AND " + EXPIRE_STARTED + " > 0 AND " + EXPIRE_STARTED + " + " + EXPIRES_IN + " <= ?"; + + try (final Cursor cursor = getReadableDatabase().rawQuery(query, nowMills)) { + List result = new ArrayList<>(cursor.getCount()); + while (cursor.moveToNext()) { + result.add(cursor.getLong(0)); + } + + return result; + } } - public Cursor getExpirationNotStartedMessages() { - String where = EXPIRES_IN + " > 0 AND " + EXPIRE_STARTED + " = 0"; - return rawQuery(where, null); + /** + * @return the next expiring timestamp for messages that have started expiring. 0 if no messages are expiring. + */ + @Override + public long getNextExpiringTimestamp() { + String query = "SELECT MIN(" + EXPIRE_STARTED + " + " + EXPIRES_IN + ") FROM " + TABLE_NAME + + " WHERE " + EXPIRES_IN + " > 0 AND " + EXPIRE_STARTED + " > 0"; + + try (final Cursor cursor = getReadableDatabase().rawQuery(query)) { + if (cursor.moveToFirst()) { + return cursor.getLong(0); + } else { + return 0L; + } + } } + @NonNull public SmsMessageRecord getMessage(long messageId) throws NoSuchMessageException { - Cursor cursor = rawQuery(ID_WHERE, new String[]{messageId + ""}); - Reader reader = new Reader(cursor); - SmsMessageRecord record = reader.getNext(); - - reader.close(); + final SmsMessageRecord record = getMessageOrNull(messageId); if (record == null) throw new NoSuchMessageException("No message for ID: " + messageId); else return record; } - 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); - setNotifyConversationListeners(cursor, getThreadIdForMessage(messageId)); - return cursor; + @Nullable + public SmsMessageRecord getMessageOrNull(long messageId) { + try (final Cursor cursor = rawQuery(ID_WHERE, new String[]{String.valueOf(messageId)})) { + return new Reader(cursor).getNext(); + } } - // 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+""}); - notifyConversationListeners(threadId); - boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false); - return threadDeleted; + return doDeleteMessages(true, ID + " = ?", messageId); } @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 + public boolean deleteMessages(Collection messageIds) { + return doDeleteMessages(true, + ID + " IN (SELECT value FROM json_each(?))", + new JSONArray(messageIds).toString() ); - boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false); - notifyConversationListeners(threadId); - return threadDeleted; } @Override @@ -698,7 +671,7 @@ public void updateThreadId(long fromId, long toId) { ContentValues contentValues = new ContentValues(1); contentValues.put(MmsSmsColumns.THREAD_ID, toId); - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); db.update(TABLE_NAME, contentValues, THREAD_ID + " = ?", new String[] {fromId + ""}); notifyConversationListeners(toId); notifyConversationListListeners(); @@ -710,9 +683,9 @@ public MessageRecord getMessageRecord(long messageId) throws NoSuchMessageExcept } private boolean isDuplicate(IncomingTextMessage message, long threadId) { - SQLiteDatabase database = databaseHelper.getReadableDatabase(); + SQLiteDatabase database = getReadableDatabase(); Cursor cursor = database.query(TABLE_NAME, null, DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?", - new String[]{String.valueOf(message.getSentTimestampMillis()), message.getSender().serialize(), String.valueOf(threadId)}, + new String[]{String.valueOf(message.getSentTimestampMillis()), message.getSender().toString(), String.valueOf(threadId)}, null, null, null, "1"); try { @@ -723,9 +696,9 @@ private boolean isDuplicate(IncomingTextMessage message, long threadId) { } private boolean isDuplicate(OutgoingTextMessage message, long threadId) { - SQLiteDatabase database = databaseHelper.getReadableDatabase(); + SQLiteDatabase database = getReadableDatabase(); Cursor cursor = database.query(TABLE_NAME, null, DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?", - new String[]{String.valueOf(message.getSentTimestampMillis()), message.getRecipient().getAddress().serialize(), String.valueOf(threadId)}, + new String[]{String.valueOf(message.getSentTimestampMillis()), message.getRecipient().getAddress().toString(), String.valueOf(threadId)}, null, null, null, "1"); try { @@ -735,69 +708,48 @@ private boolean isDuplicate(OutgoingTextMessage message, long threadId) { } } - void deleteMessagesFrom(long threadId, String fromUser) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.delete(TABLE_NAME, THREAD_ID+" = ? AND "+ADDRESS+" = ?", new String[]{threadId+"", fromUser}); - } - - void deleteMessagesInThreadBeforeDate(long threadId, long date) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - String where = THREAD_ID + " = ? AND " + DATE_SENT + " < " + date; - - db.delete(TABLE_NAME, where, new String[] {threadId + ""}); - } - - void deleteThread(long threadId) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""}); - } + private boolean doDeleteMessages(final boolean updateThread, @NonNull final String where, @Nullable final Object...args) { + final String sql = "DELETE FROM " + TABLE_NAME + " WHERE " + where + " RETURNING " + THREAD_ID; + final HashSet deletedMessageThreadIds = new HashSet<>(); - void deleteThreads(Set threadIds) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - String where = ""; - - for (long threadId : threadIds) { - where += THREAD_ID + " = '" + threadId + "' OR "; + try (final Cursor cursor = getWritableDatabase().rawQuery(sql, args)) { + while (cursor.moveToNext()) { + final long threadId = cursor.getLong(0); + deletedMessageThreadIds.add(threadId); + } } - where = where.substring(0, where.length() - 4); // Remove the final: "' OR " + if (updateThread) { + for (final long threadId : deletedMessageThreadIds) { + DatabaseComponent.get(context).threadDatabase().update(threadId, false); + } + } - db.delete(TABLE_NAME, where, null); + return !deletedMessageThreadIds.isEmpty(); } - void deleteAllThreads() { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.delete(TABLE_NAME, null, null); + void deleteMessagesFrom(long threadId, String fromUser) { + doDeleteMessages( + true, + THREAD_ID + " = ? AND " + ADDRESS + " = ?", + threadId, fromUser + ); } - SQLiteDatabase beginTransaction() { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); - database.beginTransaction(); - return database; + void deleteMessagesInThreadBeforeDate(long threadId, long date) { + doDeleteMessages(true, THREAD_ID + " = ? AND " + DATE_SENT + " < ?", threadId, date); } - void endTransaction(SQLiteDatabase database) { - database.setTransactionSuccessful(); - database.endTransaction(); + void deleteThread(long threadId) { + doDeleteMessages(true, THREAD_ID + " = ?", threadId); } - /*package*/ SQLiteStatement createInsertStatement(SQLiteDatabase database) { - return database.compileStatement("INSERT INTO " + TABLE_NAME + " (" + ADDRESS + ", " + - PERSON + ", " + - DATE_SENT + ", " + - DATE_RECEIVED + ", " + - PROTOCOL + ", " + - READ + ", " + - STATUS + ", " + - TYPE + ", " + - REPLY_PATH_PRESENT + ", " + - SUBJECT + ", " + - BODY + ", " + - SERVICE_CENTER + - ", " + THREAD_ID + ") " + - " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + void deleteThreads(@NonNull Collection threadIds) { + doDeleteMessages(true, THREAD_ID + " IN (SELECT value FROM json_each(?))", + new JSONArray(threadIds).toString()); } + public static class Status { public static final int STATUS_NONE = -1; public static final int STATUS_COMPLETE = 0; @@ -832,7 +784,7 @@ public MessageRecord getCurrent() { 0, message.isSecureMessage() ? MmsSmsColumns.Types.getOutgoingEncryptedMessageType() : MmsSmsColumns.Types.getOutgoingSmsMessageType(), threadId, 0, new LinkedList(), message.getExpiresIn(), - SnodeAPI.getNowWithOffset(), 0, false, Collections.emptyList(), false); + SnodeAPI.getNowWithOffset(), 0, Collections.emptyList(), false); } } @@ -872,7 +824,6 @@ public SmsMessageRecord getCurrent() { long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.EXPIRES_IN)); 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)) { @@ -887,7 +838,7 @@ public SmsMessageRecord getCurrent() { recipient, dateSent, dateReceived, deliveryReceiptCount, type, threadId, status, mismatches, - expiresIn, expireStarted, readReceiptCount, unidentified, reactions, hasMention); + expiresIn, expireStarted, readReceiptCount, reactions, hasMention); } private List getMismatches(String document) { @@ -910,8 +861,4 @@ public void close() { } } - public interface InsertListener { - public void onComplete(); - } - } 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 22ee57b736..57373d61fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -2,17 +2,19 @@ package org.thoughtcrime.securesms.database import android.content.Context import android.net.Uri -import com.goterl.lazysodium.utils.KeyPair +import dagger.Lazy import dagger.hilt.android.qualifiers.ApplicationContext -import java.security.MessageDigest 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.ConfigBase.Companion.PRIORITY_VISIBLE -import network.loki.messenger.libsession_util.getOrNull +import network.loki.messenger.libsession_util.MutableConversationVolatileConfig import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import network.loki.messenger.libsession_util.util.Bytes +import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.ExpiryMode -import network.loki.messenger.libsession_util.util.GroupDisplayInfo import network.loki.messenger.libsession_util.util.GroupInfo +import network.loki.messenger.libsession_util.util.KeyPair import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.avatars.AvatarHelper import org.session.libsession.database.MessageDataProvider @@ -24,12 +26,10 @@ import org.session.libsession.messaging.jobs.AttachmentUploadJob 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.ExpirationConfiguration import org.session.libsession.messaging.messages.Message -import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.signal.IncomingEncryptedMessage @@ -45,40 +45,38 @@ 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.MessageNotifier import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel -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.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.GroupDisplayInfo 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.UsernameUtils import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient.DisappearingState -import org.session.libsession.utilities.recipients.MessageType -import org.session.libsession.utilities.recipients.getType -import org.session.libsession.utilities.truncateIdForDisplay +import org.session.libsession.utilities.upsertContact 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.AccountId 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.AccountId import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.crypto.KeyPairUtilities @@ -87,11 +85,16 @@ import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get import org.thoughtcrime.securesms.groups.GroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.mms.PartAuthority +import org.thoughtcrime.securesms.util.FilenameUtils import org.thoughtcrime.securesms.util.SessionMetaProtocol +import org.thoughtcrime.securesms.util.SessionMetaProtocol.clearReceivedMessages +import java.security.MessageDigest import javax.inject.Inject +import javax.inject.Provider import javax.inject.Singleton import network.loki.messenger.libsession_util.util.Contact as LibSessionContact import network.loki.messenger.libsession_util.util.GroupMember as LibSessionGroupMember @@ -101,7 +104,7 @@ private const val TAG = "Storage" @Singleton open class Storage @Inject constructor( @ApplicationContext context: Context, - helper: SQLCipherOpenHelper, + helper: Provider, private val configFactory: ConfigFactory, private val jobDatabase: SessionJobDatabase, private val threadDatabase: ThreadDatabase, @@ -125,6 +128,8 @@ open class Storage @Inject constructor( private val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol, private val clock: SnodeClock, private val preferences: TextSecurePreferences, + private val usernameUtils: UsernameUtils, + private val openGroupManager: Lazy, ) : Database(context, helper), StorageProtocol, ThreadDatabase.ConversationThreadUpdateListener { init { @@ -133,11 +138,11 @@ open class Storage @Inject constructor( override fun threadCreated(address: Address, threadId: Long) { val localUserAddress = getUserPublicKey() ?: return - if (!getRecipientApproved(address) && localUserAddress != address.serialize()) return // don't store unapproved / message requests + if (!getRecipientApproved(address) && localUserAddress != address.toString()) return // don't store unapproved / message requests when { address.isLegacyGroup -> { - val accountId = GroupUtil.doubleDecodeGroupId(address.serialize()) + val accountId = GroupUtil.doubleDecodeGroupId(address.toString()) val closedGroup = getGroup(address.toGroupString()) if (closedGroup != null && closedGroup.isActive) { configFactory.withMutableUserConfigs { configs -> @@ -153,7 +158,7 @@ open class Storage @Inject constructor( } address.isGroupV2 -> { configFactory.withMutableUserConfigs { configs -> - val accountId = address.serialize() + val accountId = address.toString() configs.userGroups.getClosedGroup(accountId) ?: return@withMutableUserConfigs Log.d("Closed group doesn't exist locally", NullPointerException()) @@ -168,11 +173,11 @@ open class Storage @Inject constructor( address.isContact -> { // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config - if (AccountId(address.serialize()).prefix != IdPrefix.STANDARD) return + if (IdPrefix.fromValue(address.toString()) != IdPrefix.STANDARD) return // don't update our own address into the contacts DB - if (getUserPublicKey() != address.serialize()) { + if (getUserPublicKey() != address.toString()) { configFactory.withMutableUserConfigs { configs -> - configs.contacts.upsertContact(address.serialize()) { + configs.contacts.upsertContact(address.toString()) { priority = PRIORITY_VISIBLE } } @@ -185,56 +190,31 @@ open class Storage @Inject constructor( } configFactory.withMutableUserConfigs { configs -> - configs.convoInfoVolatile.getOrConstructOneToOne(address.serialize()) + configs.convoInfoVolatile.getOrConstructOneToOne(address.toString()) } } } } - override fun threadDeleted(address: Address, threadId: Long) { - configFactory.withMutableUserConfigs { configs -> - if (address.isGroupOrCommunity) { - if (address.isLegacyGroup) { - val accountId = GroupUtil.doubleDecodeGroupId(address.serialize()) - configs.convoInfoVolatile.eraseLegacyClosedGroup(accountId) - configs.userGroups.eraseLegacyGroup(accountId) - } 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 if (address.isGroupV2) { - Log.w("Loki", "Thread delete called for closed group address, expecting to be handled elsewhere") - } - } else { - // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config - if (AccountId(address.serialize()).prefix != IdPrefix.STANDARD) return@withMutableUserConfigs - configs.convoInfoVolatile.eraseOneToOne(address.serialize()) - if (getUserPublicKey() != address.serialize()) { - configs.contacts.upsertContact(address.serialize()) { - priority = PRIORITY_HIDDEN - } - } else { - configs.userProfile.setNtsPriority(PRIORITY_HIDDEN) - } - } - - Unit - } - } + override fun getUserPublicKey(): String? { return preferences.getLocalNumber() } - override fun getUserPublicKey(): String? { - return preferences.getLocalNumber() - } + override fun getUserX25519KeyPair(): ECKeyPair { return lokiAPIDatabase.getUserX25519KeyPair() } - override fun getUserX25519KeyPair(): ECKeyPair { - return lokiAPIDatabase.getUserX25519KeyPair() - } + override fun getUserED25519KeyPair(): KeyPair? { return KeyPairUtilities.getUserED25519KeyPair(context) } - override fun getUserED25519KeyPair(): KeyPair? { - return KeyPairUtilities.getUserED25519KeyPair(context) + override fun getUserBlindedAccountId(serverPublicKey: String): AccountId? { + val userKeyPair = getUserED25519KeyPair() ?: return null + return AccountId( + IdPrefix.BLINDED, + BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = userKeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(serverPublicKey), + )!!.pubKey.data + ) } override fun getUserProfile(): Profile { - val displayName = TextSecurePreferences.getProfileName(context) + val displayName = usernameUtils.getCurrentUsername() val profileKey = ProfileKeyUtil.getProfileKey(context) val profilePictureUrl = TextSecurePreferences.getProfilePictureURL(context) return Profile(displayName, profileKey, profilePictureUrl) @@ -273,14 +253,8 @@ open class Storage @Inject constructor( return registrationID } - override fun persistAttachments(messageID: Long, attachments: List): List { - val database = attachmentDatabase - val databaseAttachments = attachments.mapNotNull { it.toSignalAttachment() } - return database.insertAttachments(messageID, databaseAttachments) - } - - override fun getAttachmentsForMessage(messageID: Long): List { - return attachmentDatabase.getAttachmentsForMessage(messageID) + override fun getAttachmentsForMessage(mmsMessageId: Long): List { + return attachmentDatabase.getAttachmentsForMessage(mmsMessageId) } override fun getLastSeen(threadId: Long): Long { @@ -297,16 +271,12 @@ open class Storage @Inject constructor( val info = lokiMessageDatabase.getSendersForHashes(threadId, hashes) - if (senderIsMe) { - return info.all { it.isOutgoing } - } else { - return info.all { it.sender == sender } - } + return if (senderIsMe) info.all { it.isOutgoing } else info.all { it.sender == sender } } override fun deleteMessagesByHash(threadId: Long, hashes: List) { for (info in lokiMessageDatabase.getSendersForHashes(threadId, hashes.toSet())) { - messageDataProvider.deleteMessage(info.messageId, info.isSms) + messageDataProvider.deleteMessage(info.messageId) if (!info.isOutgoing) { notificationManager.updateNotification(context) } @@ -324,64 +294,108 @@ open class Storage @Inject constructor( } } + override fun clearAllMessages(threadId: Long): List { + val messages = mmsSmsDatabase.getAllMessagesWithHash(threadId) + val (mmsMessages, smsMessages) = messages.partition { it.first.isMms } + if (mmsMessages.isNotEmpty()) { + messageDataProvider.deleteMessages(mmsMessages.map{ it.first.id }, threadId, isSms = false) + } + if (smsMessages.isNotEmpty()) { + messageDataProvider.deleteMessages(smsMessages.map{ it.first.id }, threadId, isSms = true) + } + + return messages.map { it.second } // return the message hashes + } + override fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean) { val threadDb = threadDatabase 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.isGroupOrCommunityRecipient, lastSeenTime, force) && !force) return + if (!threadDb.markAllAsRead(threadId, lastSeenTime, force) && !force) return // don't process configs for inbox recipients if (recipient.isCommunityInboxRecipient) return configFactory.withMutableUserConfigs { configs -> val config = configs.convoInfoVolatile - val convo = when { - // recipient closed group - recipient.isLegacyGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize())) - recipient.isGroupV2Recipient -> config.getOrConstructClosedGroup(recipient.address.serialize()) - // recipient is open group - recipient.isCommunityRecipient -> { - val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return@withMutableUserConfigs - BaseCommunityInfo.parseFullUrl(openGroupJoinUrl)?.let { (base, room, pubKey) -> - config.getOrConstructCommunity(base, room, pubKey) - } ?: return@withMutableUserConfigs - } - // otherwise recipient is one to one - recipient.isContactRecipient -> { - // don't process non-standard account IDs though - if (AccountId(recipient.address.serialize()).prefix != IdPrefix.STANDARD) return@withMutableUserConfigs - config.getOrConstructOneToOne(recipient.address.serialize()) - } - else -> throw NullPointerException("Weren't expecting to have a convo with address ${recipient.address.serialize()}") - } + val convo = getConvo(threadId, recipient, config) ?: return@withMutableUserConfigs convo.lastRead = lastSeenTime - if (convo.unread) { - convo.unread = lastSeenTime <= currentLastRead + + if(convo.unread){ + convo.unread = lastSeenTime < currentLastRead notifyConversationListListeners() } + + config.set(convo) + } + } + } + + override fun markConversationAsUnread(threadId: Long) { + getRecipientForThread(threadId)?.let { recipient -> + + // don't process configs for inbox recipients + if (recipient.isCommunityInboxRecipient) return + + configFactory.withMutableUserConfigs { configs -> + val config = configs.convoInfoVolatile + val convo = getConvo(threadId, recipient, config) ?: return@withMutableUserConfigs + + convo.unread = true + notifyConversationListListeners() + config.set(convo) } } } + private fun getConvo(threadId: Long, recipient : Recipient, config : MutableConversationVolatileConfig) : Conversation? { + return when { + // recipient closed group + recipient.isLegacyGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.toString())) + recipient.isGroupV2Recipient -> config.getOrConstructClosedGroup(recipient.address.toString()) + // recipient is open group + recipient.isCommunityRecipient -> { + val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return null + BaseCommunityInfo.parseFullUrl(openGroupJoinUrl)?.let { (base, room, pubKey) -> + config.getOrConstructCommunity(base, room, pubKey) + } ?: return null + } + // otherwise recipient is one to one + recipient.isContactRecipient -> { + // don't process non-standard account IDs though + if (IdPrefix.fromValue(recipient.address.toString()) != IdPrefix.STANDARD) return null + config.getOrConstructOneToOne(recipient.address.toString()) + } + else -> throw NullPointerException("Weren't expecting to have a convo with address ${recipient.address.toString()}") + } + } + override fun updateThread(threadId: Long, unarchive: Boolean) { val threadDb = threadDatabase threadDb.update(threadId, unarchive) } - override fun persist(message: VisibleMessage, - quotes: QuoteModel?, - linkPreview: List, - groupPublicKey: String?, - openGroupID: String?, - attachments: List, - runThreadUpdate: Boolean): Long? { - var messageID: Long? = null + override fun persist( + message: VisibleMessage, + quotes: QuoteModel?, + linkPreview: List, + groupPublicKey: String?, + openGroupID: String?, + attachments: List, + runThreadUpdate: Boolean): MessageId? { + val messageID: MessageId? val senderAddress = fromSerialized(message.sender!!) val isUserSender = (message.sender!! == getUserPublicKey()) val isUserBlindedSender = message.threadID?.takeIf { it >= 0 }?.let(::getOpenGroup)?.publicKey - ?.let { SodiumUtilities.accountId(getUserPublicKey()!!, message.sender!!, it) } ?: false + ?.let { + BlindKeyAPI.sessionIdMatchesBlindedId( + sessionId = getUserPublicKey()!!, + blindedId = message.sender!!, + serverPubKey = it + ) + } ?: false val group: Optional = when { openGroupID != null -> Optional.of(SignalServiceGroup(openGroupID.toByteArray(), SignalServiceGroup.GroupType.PUBLIC_CHAT)) groupPublicKey != null && groupPublicKey.startsWith(IdPrefix.GROUP.value) -> { @@ -393,9 +407,7 @@ open class Storage @Inject constructor( } else -> Optional.absent() } - val pointers = attachments.mapNotNull { - it.toSignalAttachment() - } + val targetAddress = if ((isUserSender || isUserBlindedSender) && !message.syncTarget.isNullOrEmpty()) { fromSerialized(message.syncTarget!!) } else if (group.isPresent) { @@ -412,10 +424,10 @@ open class Storage @Inject constructor( } val targetRecipient = Recipient.from(context, targetAddress, false) if (!targetRecipient.isGroupOrCommunityRecipient) { - if (isUserSender || isUserBlindedSender) { + if ((isUserSender || isUserBlindedSender) && !targetRecipient.isApproved) { setRecipientApproved(targetRecipient, true) - } else { - setRecipientApprovedMe(targetRecipient, true) + } else if(!targetRecipient.hasApprovedMe()){ + setRecipientApprovedMe(targetRecipient, true) } } if (message.threadID == null && !targetRecipient.isCommunityRecipient) { @@ -425,10 +437,27 @@ open class Storage @Inject constructor( val expiryMode = message.expiryMode val expiresInMillis = expiryMode.expiryMillis val expireStartedAt = if (expiryMode is ExpiryMode.AfterSend) message.sentTimestamp!! else 0 + + if (message.isMediaMessage() || attachments.isNotEmpty()) { + + // Sanitise attachments with missing names + for (attachment in attachments.filter { it.filename.isNullOrEmpty() }) { + + // Unfortunately we have multiple Attachment classes, but only `SignalAttachment` has the `isVoiceNote` property which we can + // use to differentiate between an audio-file with no filename and a voice-message with no file-name, so we convert to that + // and pass it through. + val signalAttachment = attachment.toSignalAttachment() + attachment.filename = FilenameUtils.getFilenameFromUri(context, Uri.parse(attachment.url), attachment.contentType, signalAttachment) + } + val quote: Optional = if (quotes != null) Optional.of(quotes) else Optional.absent() val linkPreviews: Optional> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! }) val insertResult = if (isUserSender || isUserBlindedSender) { + val pointers = attachments.mapNotNull { + it.toSignalAttachment() + } + val mediaMessage = OutgoingMediaMessage.from( message, targetRecipient, @@ -447,9 +476,9 @@ open class Storage @Inject constructor( 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 - } + + messageID = insertResult.orNull()?.messageId?.let { MessageId(it, mms = true) } + } else { val isOpenGroupInvitation = (message.openGroupInvitation != null) @@ -463,17 +492,12 @@ open class Storage @Inject constructor( val encrypted = IncomingEncryptedMessage(textMessage, textMessage.messageBody) smsDatabase.insertMessageInbox(encrypted, message.receivedTimestamp ?: 0, runThreadUpdate) } - insertResult.orNull()?.let { result -> - messageID = result.messageId - } + messageID = insertResult.orNull()?.messageId?.let { MessageId(it, mms = false) } } + message.serverHash?.let { serverHash -> messageID?.let { id -> - // When a message with attachment is received, we don't immediately have - // attachments attached in the messages, but it's a mms from the db's perspective - // nonetheless. - val isMms = message.isMediaMessage() || attachments.isNotEmpty() - lokiMessageDatabase.setMessageServerHash(id, isMms, serverHash) + lokiMessageDatabase.setMessageServerHash(id, serverHash) } } return messageID @@ -503,10 +527,6 @@ open class Storage @Inject constructor( return jobDatabase.getMessageSendJob(messageSendJobID) } - override fun getMessageReceiveJob(messageReceiveJobID: String): MessageReceiveJob? { - return jobDatabase.getMessageReceiveJob(messageReceiveJobID) - } - override fun getGroupAvatarDownloadJob(server: String, room: String, imageId: String?): GroupAvatarDownloadJob? { return jobDatabase.getGroupAvatarDownloadJob(server, room, imageId) } @@ -553,7 +573,7 @@ open class Storage @Inject constructor( preferences.setProfileAvatarId(0) preferences.setProfilePictureURL(null) - Recipient.removeCached(fromSerialized(userPublicKey)) + Recipient.removeCached(fromSerialized(userPublicKey)) // ACL HERE?!?!?! if (clearConfig) { configFactory.withMutableUserConfigs { it.userProfile.setPic(UserPic.DEFAULT) @@ -573,7 +593,7 @@ open class Storage @Inject constructor( override fun getOpenGroup(threadId: Long): OpenGroup? { if (threadId.toInt() < 0) { return null } - val database = databaseHelper.readableDatabase + val database = readableDatabase return database.get(LokiThreadDatabase.publicChatTable, "${LokiThreadDatabase.threadID} = ?", arrayOf( threadId.toString() )) { cursor -> val publicChatAsJson = cursor.getString(LokiThreadDatabase.publicChat) OpenGroup.fromJSON(publicChatAsJson) @@ -616,19 +636,15 @@ open class Storage @Inject constructor( lokiAPIDatabase.setUserCount(room, server, newValue) } - override fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean) { - lokiMessageDatabase.setServerID(messageID, serverID, isSms) - lokiMessageDatabase.setOriginalThreadID(messageID, serverID, threadID) + override fun setOpenGroupServerMessageID(messageID: MessageId, serverID: Long, threadID: Long) { + lokiMessageDatabase.setServerID(messageID, serverID) + lokiMessageDatabase.setOriginalThreadID(messageID.id, serverID, threadID) } override fun getOpenGroup(room: String, server: String): OpenGroup? { return getAllOpenGroups().values.firstOrNull { it.server == server && it.room == room } } - override fun setGroupMemberRoles(members: List) { - groupMemberDatabase.setGroupMembers(members) - } - override fun isDuplicateMessage(timestamp: Long): Boolean { return getReceivedMessageTimestamps().contains(timestamp) } @@ -661,141 +677,52 @@ open class Storage @Inject constructor( SessionMetaProtocol.removeTimestamps(timestamps) } - override fun getMessageIdInDatabase(timestamp: Long, author: String): Pair? { + override fun getMessageBy(timestamp: Long, author: String): MessageRecord? { val database = mmsSmsDatabase val address = fromSerialized(author) - return database.getMessageFor(timestamp, address)?.run { getId() to isMms } - } - - override fun getMessageType(timestamp: Long, author: String): MessageType? { - val address = fromSerialized(author) - return mmsSmsDatabase.getMessageFor(timestamp, address)?.individualRecipient?.getType() + return database.getMessageFor(timestamp, address) } override fun updateSentTimestamp( - messageID: Long, - isMms: Boolean, - openGroupSentTimestamp: Long, - threadId: Long + messageId: MessageId, + newTimestamp: Long ) { - if (isMms) { - val mmsDb = mmsDatabase - mmsDb.updateSentTimestamp(messageID, openGroupSentTimestamp, threadId) + if (messageId.mms) { + mmsDatabase.updateSentTimestamp(messageId.id, newTimestamp) } else { - val smsDb = smsDatabase - smsDb.updateSentTimestamp(messageID, openGroupSentTimestamp, threadId) + smsDatabase.updateSentTimestamp(messageId.id, newTimestamp) } } - override fun markAsSent(timestamp: Long, author: String) { - val database = mmsSmsDatabase - 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) { - mmsDatabase.markAsSent(messageRecord.getId(), true) - } else { - smsDatabase.markAsSent(messageRecord.getId(), true) - } + override fun markAsSent(messageId: MessageId) { + getMmsDatabaseElseSms(messageId.mms).markAsSent(messageId.id, 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 = mmsSmsDatabase - val message = database.getLastSentMessageRecordFromSender(threadId, preferences.getLocalNumber()) - - // 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) { - mmsDatabase.markAsSent(message.getId(), true) - } else { - smsDatabase.markAsSent(message.getId(), true) - } - } - - override fun markAsSyncing(timestamp: Long, author: String) { - mmsSmsDatabase - .getMessageFor(timestamp, author) - ?.run { getMmsDatabaseElseSms(isMms).markAsSyncing(id) } + override fun markAsSyncing(messageId: MessageId) { + getMmsDatabaseElseSms(messageId.mms).markAsSyncing(messageId.id) } private fun getMmsDatabaseElseSms(isMms: Boolean) = if (isMms) mmsDatabase else smsDatabase - override fun markAsResyncing(timestamp: Long, author: String) { - mmsSmsDatabase - .getMessageFor(timestamp, author) - ?.run { getMmsDatabaseElseSms(isMms).markAsResyncing(id) } + override fun markAsResyncing(messageId: MessageId) { + getMmsDatabaseElseSms(messageId.mms).markAsResyncing(messageId.id) } - override fun markAsSending(timestamp: Long, author: String) { - val database = mmsSmsDatabase - val messageRecord = database.getMessageFor(timestamp, author) ?: return - if (messageRecord.isMms) { - val mmsDatabase = mmsDatabase - mmsDatabase.markAsSending(messageRecord.getId()) + override fun markAsSending(messageId: MessageId) { + if (messageId.mms) { + mmsDatabase.markAsSending(messageId.id) } else { - val smsDatabase = smsDatabase - smsDatabase.markAsSending(messageRecord.getId()) - messageRecord.isPending + smsDatabase.markAsSending(messageId.id) } } - override fun markUnidentified(timestamp: Long, author: String) { - val database = mmsSmsDatabase - 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 = mmsDatabase - mmsDatabase.markUnidentified(messageRecord.getId(), true) + override fun markAsSentFailed(messageId: MessageId, error: Exception) { + if (messageId.mms) { + mmsDatabase.markAsSentFailed(messageId.id) } else { - val smsDatabase = smsDatabase - smsDatabase.markUnidentified(messageRecord.getId(), true) - } - } - - // 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 = mmsSmsDatabase - val message = database.getLastSentMessageRecordFromSender(threadId, preferences.getLocalNumber()) - - // 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) { - mmsDatabase.markUnidentified(message.getId(), true) - } else { - smsDatabase.markUnidentified(message.getId(), true) - } - } - - override fun markAsSentFailed(timestamp: Long, author: String, error: Exception) { - val database = mmsSmsDatabase - val messageRecord = database.getMessageFor(timestamp, author) ?: return - if (messageRecord.isMms) { - val mmsDatabase = mmsDatabase - mmsDatabase.markAsSentFailed(messageRecord.getId()) - } else { - val smsDatabase = smsDatabase - smsDatabase.markAsSentFailed(messageRecord.getId()) + smsDatabase.markAsSentFailed(messageId.id) } if (error.localizedMessage != null) { val message: String @@ -804,18 +731,14 @@ open class Storage @Inject constructor( } else { message = error.localizedMessage!! } - lokiMessageDatabase.setErrorMessage(messageRecord.getId(), message) + lokiMessageDatabase.setErrorMessage(messageId, message) } else { - lokiMessageDatabase.setErrorMessage(messageRecord.getId(), error.javaClass.simpleName) + lokiMessageDatabase.setErrorMessage(messageId, error.javaClass.simpleName) } } - override fun markAsSyncFailed(timestamp: Long, author: String, error: Exception) { - val database = mmsSmsDatabase - val messageRecord = database.getMessageFor(timestamp, author) ?: return - - database.getMessageFor(timestamp, author) - ?.run { getMmsDatabaseElseSms(isMms).markAsSyncFailed(id) } + override fun markAsSyncFailed(messageId: MessageId, error: Exception) { + getMmsDatabaseElseSms(messageId.mms).markAsSyncFailed(messageId.id) if (error.localizedMessage != null) { val message: String @@ -824,19 +747,18 @@ open class Storage @Inject constructor( } else { message = error.localizedMessage!! } - lokiMessageDatabase.setErrorMessage(messageRecord.getId(), message) + lokiMessageDatabase.setErrorMessage(messageId, message) } else { - lokiMessageDatabase.setErrorMessage(messageRecord.getId(), error.javaClass.simpleName) + lokiMessageDatabase.setErrorMessage(messageId, error.javaClass.simpleName) } } - override fun clearErrorMessage(messageID: Long) { - val db = lokiMessageDatabase - db.clearErrorMessage(messageID) + override fun clearErrorMessage(messageID: MessageId) { + lokiMessageDatabase.clearErrorMessage(messageID) } - override fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) { - lokiMessageDatabase.setMessageServerHash(messageID, mms, serverHash) + override fun setMessageServerHash(messageId: MessageId, serverHash: String) { + lokiMessageDatabase.setMessageServerHash(messageId, serverHash) } override fun getGroup(groupID: String): GroupRecord? { @@ -864,8 +786,8 @@ open class Storage @Inject constructor( name = name, members = members, priority = PRIORITY_VISIBLE, - encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte - encSecKey = encryptionKeyPair.privateKey.serialize(), + encPubKey = Bytes((encryptionKeyPair.publicKey as DjbECPublicKey).publicKey), // 'serialize()' inserts an extra byte + encSecKey = Bytes(encryptionKeyPair.privateKey.serialize()), disappearingTimer = expirationTimer.toLong(), joinedAtSecs = (formationTimestamp / 1000L) ) @@ -886,8 +808,8 @@ open class Storage @Inject constructor( return@withMutableUserConfigs } val name = existingGroup.title - val admins = existingGroup.admins.map { it.serialize() } - val members = existingGroup.members.map { it.serialize() } + val admins = existingGroup.admins.map { it.toString() } + val members = existingGroup.members.map { it.toString() } val membersMap = GroupUtil.createConfigMemberMap(admins = admins, members = members) val latestKeyPair = getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: return@withMutableUserConfigs Log.w("Loki-DBG", "No latest closed group encryption key pair for ${groupPublicKey.take(4)}} when updating group config") @@ -896,8 +818,8 @@ open class Storage @Inject constructor( 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(), + encPubKey = Bytes((latestKeyPair.publicKey as DjbECPublicKey).publicKey), // 'serialize()' inserts an extra byte + encSecKey = Bytes(latestKeyPair.privateKey.serialize()), priority = if (isPinned(threadID)) PRIORITY_PINNED else PRIORITY_VISIBLE, disappearingTimer = getExpirationConfiguration(threadID)?.expiryMode?.expirySeconds ?: 0L, joinedAtSecs = (existingGroup.formationTimestamp / 1000L) @@ -915,7 +837,7 @@ open class Storage @Inject constructor( } override fun getZombieMembers(groupID: String): Set { - return groupDatabase.getGroupZombieMembers(groupID).map { it.address.serialize() }.toHashSet() + return groupDatabase.getGroupZombieMembers(groupID).map { it.address.toString() }.toHashSet() } override fun removeMember(groupID: String, member: Address) { @@ -949,14 +871,32 @@ open class Storage @Inject constructor( 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, 0, true, null, listOf(), listOf()) + val infoMessage = OutgoingGroupMediaMessage( + recipient, + updateData, + groupID, + null, + sentTimestamp, + 0, + 0, + true, + null, + listOf(), + listOf(), + null + ) val mmsDB = mmsDatabase val mmsSmsDB = mmsSmsDatabase if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) { Log.w(TAG, "Bailing from insertOutgoingInfoMessage because we believe the message has already been sent!") return null } - val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null, runThreadUpdate = true) + val infoMessageID = mmsDB.insertMessageOutbox( + infoMessage, + threadID, + false, + runThreadUpdate = true + ) mmsDB.markAsSent(infoMessageID, true) return infoMessageID } @@ -999,10 +939,6 @@ open class Storage @Inject constructor( lokiAPIDatabase.removeAllClosedGroupEncryptionKeyPairs(groupPublicKey) } - override fun removeClosedGroupThread(threadID: Long) { - threadDatabase.deleteConversation(threadID) - } - override fun updateFormationTimestamp(groupID: String, formationTimestamp: Long) { groupDatabase .updateFormationTimestamp(groupID, formationTimestamp) @@ -1027,7 +963,7 @@ open class Storage @Inject constructor( return configFactory.withGroupConfigs(AccountId(groupAccountId)) { configs -> val info = configs.groupInfo GroupDisplayInfo( - id = info.id(), + id = AccountId(info.id()), name = info.getName(), profilePic = info.getProfilePic(), expiryTimer = info.getExpiryTimer(), @@ -1039,33 +975,33 @@ open class Storage @Inject constructor( } } - override fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId): Long? { + override fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId) { val sentTimestamp = message.sentTimestamp ?: clock.currentTimeMills() val senderPublicKey = message.sender val groupName = configFactory.withGroupConfigs(closedGroup) { it.groupInfo.getName() } ?: configFactory.getGroup(closedGroup)?.name - val updateData = UpdateMessageData.buildGroupUpdate(message, groupName.orEmpty()) ?: return null + val updateData = UpdateMessageData.buildGroupUpdate(message, groupName.orEmpty()) ?: return - return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup) + insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup) } - override fun insertGroupInfoLeaving(closedGroup: AccountId): Long? { + override fun insertGroupInfoLeaving(closedGroup: AccountId) { val sentTimestamp = clock.currentTimeMills() - val senderPublicKey = getUserPublicKey() ?: return null + val senderPublicKey = getUserPublicKey() ?: return val updateData = UpdateMessageData.buildGroupLeaveUpdate(UpdateMessageData.Kind.GroupLeaving) - return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup) + insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup) } - override fun insertGroupInfoErrorQuit(closedGroup: AccountId): Long? { + override fun insertGroupInfoErrorQuit(closedGroup: AccountId) { val sentTimestamp = clock.currentTimeMills() - val senderPublicKey = getUserPublicKey() ?: return null + val senderPublicKey = getUserPublicKey() ?: return val groupName = configFactory.withGroupConfigs(closedGroup) { it.groupInfo.getName() } ?: configFactory.getGroup(closedGroup)?.name val updateData = UpdateMessageData.buildGroupLeaveUpdate(UpdateMessageData.Kind.GroupErrorQuit(groupName.orEmpty())) - return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup) + insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup) } override fun updateGroupInfoChange(messageId: Long, newType: UpdateMessageData.Kind) { @@ -1078,17 +1014,17 @@ open class Storage @Inject constructor( mmsSmsDatabase.deleteGroupInfoMessage(groupId, kind) } - override fun insertGroupInviteControlMessage(sentTimestamp: Long, senderPublicKey: String, senderName: String?, closedGroup: AccountId, groupName: String): Long? { + override fun insertGroupInviteControlMessage(sentTimestamp: Long, senderPublicKey: String, senderName: String?, closedGroup: AccountId, groupName: String) { val updateData = UpdateMessageData(UpdateMessageData.Kind.GroupInvitation( groupAccountId = closedGroup.hexString, invitingAdminId = senderPublicKey, invitingAdminName = senderName, groupName = groupName )) - return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup) + insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup) } - private fun insertUpdateControlMessage(updateData: UpdateMessageData, sentTimestamp: Long, senderPublicKey: String?, closedGroup: AccountId): Long? { + private fun insertUpdateControlMessage(updateData: UpdateMessageData, sentTimestamp: Long, senderPublicKey: String?, closedGroup: AccountId): MessageId? { val userPublicKey = getUserPublicKey()!! val recipient = Recipient.from(context, fromSerialized(closedGroup.hexString), false) val threadDb = threadDatabase @@ -1112,22 +1048,28 @@ open class Storage @Inject constructor( true, null, listOf(), - listOf() + listOf(), + null ) val mmsDB = mmsDatabase val mmsSmsDB = mmsSmsDatabase // check for conflict here, not returning duplicate in case it's different if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) return null - val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null, runThreadUpdate = true) + val infoMessageID = mmsDB.insertMessageOutbox( + infoMessage, + threadID, + false, + runThreadUpdate = true + ) mmsDB.markAsSent(infoMessageID, true) - return infoMessageID + return MessageId(infoMessageID, mms = true) } else { val group = SignalServiceGroup(Hex.fromStringCondensed(closedGroup.hexString), SignalServiceGroup.GroupType.SIGNAL) val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), expiresInMillis, expireStartedAt, true, false) val infoMessage = IncomingGroupMessage(m, inviteJson, true) val smsDB = smsDatabase val insertResult = smsDB.insertMessageInbox(infoMessage, true) - return insertResult.orNull()?.messageId + return insertResult.orNull()?.messageId?.let { MessageId(it, mms = false) } } } @@ -1144,19 +1086,23 @@ open class Storage @Inject constructor( } override fun updateOpenGroup(openGroup: OpenGroup) { - OpenGroupManager.updateOpenGroup(openGroup, context) + openGroupManager.get().updateOpenGroup(openGroup, context) + + groupDatabase.updateTitle( + groupID = GroupUtil.getEncodedOpenGroupID(openGroup.groupId.toByteArray()), + newValue = openGroup.name + ) } override fun getAllGroups(includeInactive: Boolean): List { return groupDatabase.getAllGroups(includeInactive) } - override suspend fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? { - return OpenGroupManager.addOpenGroup(urlAsString, context) + override suspend fun addOpenGroup(urlAsString: String) { + return openGroupManager.get().addOpenGroup(urlAsString, context) } override fun onOpenGroupAdded(server: String, room: String) { - OpenGroupManager.restartPollerForServer(server.removeSuffix("/")) configFactory.withMutableUserConfigs { configs -> val groups = configs.userGroups val volatileConfig = configs.convoInfoVolatile @@ -1240,21 +1186,6 @@ open class Storage @Inject constructor( return sessionContactDatabase.getContactWithAccountID(accountID) } - override fun getContactNameWithAccountID(accountID: String, groupId: AccountId?, contactContext: Contact.ContactContext): String { - val contact = sessionContactDatabase.getContactWithAccountID(accountID) - return getContactNameWithAccountID(contact, accountID, groupId, contactContext) - } - - override fun getContactNameWithAccountID(contact: Contact?, accountID: String, groupId: AccountId?, contactContext: Contact.ContactContext): String { - // first attempt to get the name from the contact - val userName: String? = contact?.displayName(contactContext) - ?: if(groupId != null){ - configFactory.withGroupConfigs(groupId) { it.groupMembers.getOrNull(accountID)?.name } - } else null - - return userName ?: truncateIdForDisplay(accountID) - } - override fun getAllContacts(): Set { return sessionContactDatabase.getAllContacts() } @@ -1263,9 +1194,25 @@ open class Storage @Inject constructor( sessionContactDatabase.setContact(contact) val address = fromSerialized(contact.accountID) if (!getRecipientApproved(address)) return - val recipientHash = profileManager.contactUpdatedInternal(contact) - val recipient = Recipient.from(context, address, false) - setRecipientHash(recipient, recipientHash) + profileManager.contactUpdatedInternal(contact) + threadDatabase.notifyConversationListListeners() + } + + override fun deleteContactAndSyncConfig(accountId: String) { + deleteContact(accountId) + // also handle the contact removal from the config's point of view + configFactory.removeContact(accountId) + } + + private fun deleteContact(accountId: String){ + sessionContactDatabase.deleteContact(accountId) + Recipient.removeCached(fromSerialized(accountId)) + recipientDatabase.deleteRecipient(accountId) + + val threadId: Long = threadDatabase.getThreadIdIfExistsFor(accountId) + deleteConversation(threadId) + + notifyRecipientListeners() } override fun getRecipientForThread(threadId: Long): Recipient? { @@ -1280,7 +1227,7 @@ open class Storage @Inject constructor( return recipientDatabase.isAutoDownloadFlagSet(recipient) } - override fun addLibSessionContacts(contacts: List, timestamp: Long?) { + override fun syncLibSessionContacts(contacts: List, timestamp: Long?) { val mappingDb = blindedIdMappingDatabase val moreContacts = contacts.filter { contact -> val id = AccountId(contact.id) @@ -1305,9 +1252,8 @@ open class Storage @Inject constructor( 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) + if (key.data.size != ProfileKeyUtil.PROFILE_KEY_BYTES) return@forEach + profileManager.setProfilePicture(context, recipient, url, key.data) } else { profileManager.setProfilePicture(context, recipient, null, null) } @@ -1328,59 +1274,22 @@ open class Storage @Inject constructor( ) } } - setRecipientHash(recipient, contact.hashCode().toString()) } // if we have contacts locally but that are missing from the config, remove their corresponding thread val currentUserKey = getUserPublicKey() - val removedContacts = getAllContacts().filter { localContact -> - localContact.accountID != currentUserKey && // we don't want to remove ourselves (ie, our Note to Self) - moreContacts.none { it.id == localContact.accountID } // we don't want to remove contacts that are present in the config - } - removedContacts.forEach { - getThreadId(fromSerialized(it.accountID))?.let(::deleteConversation) - } - } - - override fun addContacts(contacts: List) { - val recipientDatabase = recipientDatabase - val threadDatabase = threadDatabase - val mappingDb = blindedIdMappingDatabase - val moreContacts = contacts.filter { contact -> - val id = AccountId(contact.publicKey) - id.prefix != IdPrefix.BLINDED || mappingDb.getBlindedIdMapping(contact.publicKey).none { it.accountId != null } - } - for (contact in moreContacts) { - val address = fromSerialized(contact.publicKey) - val recipient = Recipient.from(context, address, true) - if (!contact.profilePicture.isNullOrEmpty()) { - recipientDatabase.setProfileAvatar(recipient, contact.profilePicture) - } - if (contact.profileKey?.isNotEmpty() == true) { - recipientDatabase.setProfileKey(recipient, contact.profileKey) - } - if (contact.name.isNotEmpty()) { - recipientDatabase.setProfileName(recipient, contact.name) - } - recipientDatabase.setProfileSharing(recipient, true) - recipientDatabase.setRegistered(recipient, Recipient.RegisteredState.REGISTERED) - // create Thread if needed - val threadId = threadDatabase.getThreadIdIfExistsFor(recipient) - if (contact.didApproveMe == true) { - recipientDatabase.setApprovedMe(recipient, true) - } - if (contact.isApproved == true && threadId != -1L) { - setRecipientApproved(recipient, true) - threadDatabase.setHasSent(threadId, true) - } - val contactIsBlocked: Boolean? = contact.isBlocked - if (contactIsBlocked != null && recipient.isBlocked != contactIsBlocked) { - setBlocked(listOf(recipient), contactIsBlocked, fromConfigUpdate = true) - } + //NOTE: We used to cycle through all Contact here instead or all Recipients, but turns out a Contact isn't saved until we have a name, nickname or avatar + // which in the case of contacts we are messaging for the first time and who haven't yet approved us, it won't be the case + // But that person is saved in the Recipient db. We might need to investigate how to clean the relationship between Recipients, Contacts and config Contacts. + val removedContacts = recipientDatabase.allRecipients.filter { localContact -> + IdPrefix.fromValue(localContact.address.toString()) == IdPrefix.STANDARD && // only want standard address + localContact.is1on1 && // only for conversations + localContact.address.toString() != currentUserKey && // we don't want to remove ourselves (ie, our Note to Self) + moreContacts.none { it.id == localContact.address.toString() } // we don't want to remove contacts that are present in the config } - if (contacts.isNotEmpty()) { - threadDatabase.notifyConversationListListeners() + removedContacts.forEach { + deleteContact(it.address.toString()) } } @@ -1396,11 +1305,6 @@ open class Storage @Inject constructor( recipientDb.setAutoDownloadAttachments(recipient, shouldAutoDownloadAttachments) } - override fun setRecipientHash(recipient: Recipient, recipientHash: String?) { - val recipientDb = recipientDatabase - recipientDb.setRecipientHash(recipient, recipientHash) - } - override fun getLastUpdated(threadID: Long): Long { val threadDB = threadDatabase return threadDB.getLastUpdated(threadID) @@ -1421,6 +1325,48 @@ open class Storage @Inject constructor( return mmsSmsDb.getConversationCount(threadID) } + override fun getTotalPinned(): Int { + return configFactory.withUserConfigs { + var totalPins = 0 + + // check if the note to self is pinned + if (it.userProfile.getNtsPriority() == PRIORITY_PINNED) { + totalPins ++ + } + + // check for 1on1 + it.contacts.all().forEach { contact -> + if (contact.priority == PRIORITY_PINNED) { + totalPins ++ + } + } + + // check groups and communities + it.userGroups.all().forEach { group -> + when(group){ + is GroupInfo.ClosedGroupInfo -> { + if (group.priority == PRIORITY_PINNED) { + totalPins ++ + } + } + is GroupInfo.CommunityGroupInfo -> { + if (group.priority == PRIORITY_PINNED) { + totalPins ++ + } + } + + is GroupInfo.LegacyGroupInfo -> { + if (group.priority == PRIORITY_PINNED) { + totalPins ++ + } + } + } + } + + totalPins + } + } + override fun setPinned(threadID: Long, isPinned: Boolean) { val threadDB = threadDatabase threadDB.setPinned(threadID, isPinned) @@ -1429,13 +1375,13 @@ open class Storage @Inject constructor( if (threadRecipient.isLocalNumber) { configs.userProfile.setNtsPriority(if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE) } else if (threadRecipient.isContactRecipient) { - configs.contacts.upsertContact(threadRecipient.address.serialize()) { + configs.contacts.upsertContact(threadRecipient.address.toString()) { priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE } } else if (threadRecipient.isGroupOrCommunityRecipient) { when { threadRecipient.isLegacyGroupRecipient -> { - threadRecipient.address.serialize() + threadRecipient.address.toString() .let(GroupUtil::doubleDecodeGroupId) .let(configs.userGroups::getOrConstructLegacyGroupInfo) .copy(priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE) @@ -1444,7 +1390,7 @@ open class Storage @Inject constructor( threadRecipient.isGroupV2Recipient -> { val newGroupInfo = configs.userGroups - .getOrConstructClosedGroup(threadRecipient.address.serialize()) + .getOrConstructClosedGroup(threadRecipient.address.toString()) .copy(priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE) configs.userGroups.set(newGroupInfo) } @@ -1470,6 +1416,11 @@ open class Storage @Inject constructor( return threadDB.isPinned(threadID) } + override fun isRead(threadId: Long) : Boolean { + val threadDB = threadDatabase + return threadDB.isRead(threadId) + } + override fun setThreadCreationDate(threadId: Long, newDate: Long) { val threadDb = threadDatabase threadDb.setCreationDate(threadId, newDate) @@ -1485,32 +1436,52 @@ open class Storage @Inject constructor( override fun deleteConversation(threadID: Long) { val threadDB = threadDatabase val groupDB = groupDatabase - threadDB.deleteConversation(threadID) - val recipient = getRecipientForThread(threadID) - if (recipient == null) { - Log.w(TAG, "Got null recipient when deleting conversation - aborting."); - return - } + val recipientAddress = getRecipientForThread(threadID)?.address + + // Delete the conversation and its messages + smsDatabase.deleteThread(threadID) + mmsDatabase.deleteThread(threadID, updateThread = false) + get(context).draftDatabase().clearDrafts(threadID) + lokiMessageDatabase.deleteThread(threadID) + threadDB.deleteThread(threadID) + notifyConversationListeners(threadID) + notifyConversationListListeners() + clearReceivedMessages() - // 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 (recipientAddress == null) return - // If we get here then this is a closed group conversation (i.e., recipient.isClosedGroupRecipient) configFactory.withMutableUserConfigs { configs -> - val volatile = configs.convoInfoVolatile - val groups = configs.userGroups - 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) + if (recipientAddress.isGroupOrCommunity) { + if (recipientAddress.isLegacyGroup) { + val accountId = GroupUtil.doubleDecodeGroupId(recipientAddress.toString()) + groupDB.delete(recipientAddress.toString()) + configs.convoInfoVolatile.eraseLegacyClosedGroup(accountId) + configs.userGroups.eraseLegacyGroup(accountId) + } else if (recipientAddress.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 if (recipientAddress.isGroupV2) { + Log.w("Loki", "Thread delete called for closed group address, expecting to be handled elsewhere") + } } else { - Log.w("Loki-DBG", "Failed to find a closed group for ${groupPublicKey.take(4)}") + // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config + if(!recipientAddress.toString().startsWith(IdPrefix.STANDARD.value)) return@withMutableUserConfigs + configs.convoInfoVolatile.eraseOneToOne(recipientAddress.toString()) + + if (getUserPublicKey() != recipientAddress.toString()) { + // only update the priority if the contact exists in our config + // (this helps for example when deleting a contact and we do not want to recreate one here only to mark it hidden) + configs.contacts.get(recipientAddress.toString())?.let{ + it.priority = PRIORITY_HIDDEN + configs.contacts.set(it) + } + } else { + configs.userProfile.setNtsPriority(PRIORITY_HIDDEN) + } } + + Unit } } @@ -1519,11 +1490,11 @@ open class Storage @Inject constructor( if (fromUser == null) { // this deletes all *from* thread, not deleting the actual thread smsDatabase.deleteThread(threadID) - mmsDatabase.deleteThread(threadID) // threadDB update called from within + mmsDatabase.deleteThread(threadID, updateThread = true) // threadDB update called from within } else { // this deletes all *from* thread, not deleting the actual thread - smsDatabase.deleteMessagesFrom(threadID, fromUser.serialize()) - mmsDatabase.deleteMessagesFrom(threadID, fromUser.serialize()) + smsDatabase.deleteMessagesFrom(threadID, fromUser.toString()) + mmsDatabase.deleteMessagesFrom(threadID, fromUser.toString()) threadDb.update(threadID, false) } @@ -1533,7 +1504,7 @@ open class Storage @Inject constructor( } override fun clearMedia(threadID: Long, fromUser: Address?): Boolean { - mmsDatabase.deleteMediaFor(threadID, fromUser?.serialize()) + mmsDatabase.deleteMediaFor(threadID, fromUser?.toString()) return true } @@ -1546,7 +1517,6 @@ open class Storage @Inject constructor( } override fun insertDataExtractionNotificationMessage(senderPublicKey: String, message: DataExtractionNotificationInfoMessage, sentTimestamp: Long) { - val database = mmsDatabase val address = fromSerialized(senderPublicKey) val recipient = Recipient.from(context, address, false) @@ -1564,19 +1534,17 @@ open class Storage @Inject constructor( expireStartedAt, false, false, - false, - false, Optional.absent(), Optional.absent(), Optional.absent(), + null, Optional.absent(), Optional.absent(), Optional.absent(), Optional.of(message) ) - database.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true) - messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode) + mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true) } /** @@ -1616,16 +1584,15 @@ open class Storage @Inject constructor( if ((profileKeyValid && profileKeyChanged) || (profileKeyValid && needsProfilePicture)) { profileManager.setProfilePicture(context, sender, profile.profilePictureURL!!, newProfileKey!!) - profileManager.setUnidentifiedAccessMode(context, sender, Recipient.UnidentifiedAccessMode.UNKNOWN) } } - threadDatabase.setHasSent(threadId, true) + val mappingDb = blindedIdMappingDatabase val mappings = mutableMapOf() threadDatabase.readerFor(threadDatabase.conversationList).use { reader -> while (reader.next != null) { val recipient = reader.current.recipient - val address = recipient.address.serialize() + val address = recipient.address.toString() val blindedId = when { recipient.isGroupOrCommunityRecipient -> null recipient.isCommunityInboxRecipient -> GroupUtil.getDecodedOpenGroupInboxAccountId(address) @@ -1637,7 +1604,12 @@ open class Storage @Inject constructor( } } for (mapping in mappings) { - if (!SodiumUtilities.accountId(senderPublicKey, mapping.value.blindedId, mapping.value.serverId)) { + if (!BlindKeyAPI.sessionIdMatchesBlindedId( + sessionId = senderPublicKey, + blindedId = mapping.value.blindedId, + serverPubKey = mapping.value.serverId + ) + ) { continue } mappingDb.addBlindedIdMapping(mapping.value.copy(accountId = senderPublicKey)) @@ -1645,38 +1617,42 @@ open class Storage @Inject constructor( val blindedThreadId = threadDatabase.getOrCreateThreadIdFor(Recipient.from(context, fromSerialized(mapping.key), false)) mmsDatabase.updateThreadId(blindedThreadId, threadId) smsDatabase.updateThreadId(blindedThreadId, threadId) - threadDatabase.deleteConversation(blindedThreadId) + deleteConversation(blindedThreadId) } - setRecipientApproved(sender, true) - setRecipientApprovedMe(sender, true) - // Also update the config about this contact - configFactory.withMutableUserConfigs { - it.contacts.upsertContact(sender.address.serialize()) { - approved = true - approvedMe = true - } + var alreadyApprovedMe: Boolean = false + configFactory.withUserConfigs { + // check is the person had not yet approvedMe + alreadyApprovedMe = it.contacts.get(sender.address.toString())?.approvedMe ?: false } - val message = IncomingMediaMessage( - sender.address, - response.sentTimestamp!!, - -1, - 0, - 0, - false, - false, - true, - false, - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent() - ) - mmsDatabase.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = true) + setRecipientApprovedMe(sender, true) + + // only show the message if wasn't already approvedMe before + if(!alreadyApprovedMe) { + val message = IncomingMediaMessage( + sender.address, + response.sentTimestamp!!, + -1, + 0, + 0, + true, + false, + Optional.absent(), + Optional.absent(), + Optional.absent(), + null, + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent() + ) + mmsDatabase.insertSecureDecryptedMessageInbox( + message, + threadId, + runThreadUpdate = true + ) + } } } @@ -1692,13 +1668,12 @@ open class Storage @Inject constructor( -1, 0, 0, - false, - false, true, false, Optional.absent(), Optional.absent(), Optional.absent(), + null, Optional.absent(), Optional.absent(), Optional.absent(), @@ -1715,7 +1690,7 @@ open class Storage @Inject constructor( recipientDatabase.setApproved(recipient, approved) if (recipient.isLocalNumber || !recipient.isContactRecipient) return configFactory.withMutableUserConfigs { - it.contacts.upsertContact(recipient.address.serialize()) { + it.contacts.upsertContact(recipient.address.toString()) { // if the contact wasn't approved before but is approved now, make sure it's visible if(approved && !this.approved) this.priority = PRIORITY_VISIBLE @@ -1729,24 +1704,22 @@ open class Storage @Inject constructor( recipientDatabase.setApprovedMe(recipient, approvedMe) if (recipient.isLocalNumber || !recipient.isContactRecipient) return configFactory.withMutableUserConfigs { - it.contacts.upsertContact(recipient.address.serialize()) { + it.contacts.upsertContact(recipient.address.toString()) { this.approvedMe = approvedMe } } } override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) { - val database = smsDatabase val address = fromSerialized(senderPublicKey) val recipient = Recipient.from(context, address, false) val threadId = 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 expireStartedAt = if (expiryMode != ExpiryMode.NONE) clock.currentTimeMills() else 0 val callMessage = IncomingTextMessage.fromCallInfo(callMessageType, address, Optional.absent(), sentTimestamp, expiresInMillis, expireStartedAt) - database.insertCallMessage(callMessage) - messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode) + smsDatabase.insertCallMessage(callMessage) } override fun conversationHasOutgoing(userPublicKey: String): Boolean { @@ -1795,14 +1768,24 @@ open class Storage @Inject constructor( } getAllContacts().forEach { contact -> val accountId = AccountId(contact.accountID) - if (accountId.prefix == IdPrefix.STANDARD && SodiumUtilities.accountId(accountId.hexString, blindedId, serverPublicKey)) { + if (accountId.prefix == IdPrefix.STANDARD && BlindKeyAPI.sessionIdMatchesBlindedId( + sessionId = accountId.hexString, + blindedId = blindedId, + serverPubKey = serverPublicKey + ) + ) { val contactMapping = mapping.copy(accountId = accountId.hexString) db.addBlindedIdMapping(contactMapping) return contactMapping } } db.getBlindedIdMappingsExceptFor(server).forEach { - if (SodiumUtilities.accountId(it.accountId!!, blindedId, serverPublicKey)) { + if (BlindKeyAPI.sessionIdMatchesBlindedId( + sessionId = it.accountId!!, + blindedId = blindedId, + serverPubKey = serverPublicKey + ) + ) { val otherMapping = mapping.copy(accountId = it.accountId) db.addBlindedIdMapping(otherMapping) return otherMapping @@ -1812,27 +1795,30 @@ open class Storage @Inject constructor( return mapping } - override fun addReaction(reaction: Reaction, messageSender: String, notifyUnread: Boolean) { + override fun addReaction( + threadId: Long, + reaction: Reaction, + messageSender: String, + notifyUnread: Boolean + ) { val timestamp = reaction.timestamp - val localId = reaction.localId - val isMms = reaction.isMms - val messageId = if (localId != null && localId > 0 && isMms != null) { - // bail early is the message is marked as deleted - val messagingDatabase: MessagingDatabase = if (isMms == true) mmsDatabase else smsDatabase - if(messagingDatabase.getMessageRecord(localId)?.isDeleted == true) return - - MessageId(localId, isMms) - } else if (timestamp != null && timestamp > 0) { - val messageRecord = mmsSmsDatabase.getMessageForTimestamp(timestamp) ?: return + val messageId = if (timestamp != null && timestamp > 0) { + val messageRecord = mmsSmsDatabase.getMessageForTimestamp(threadId, timestamp) ?: return if (messageRecord.isDeleted) return MessageId(messageRecord.id, messageRecord.isMms) - } else return + } else { + Log.d(TAG, "Invalid reaction timestamp: $timestamp. Not adding") + return + } + + addReaction(messageId, reaction, messageSender) + } + + override fun addReaction(messageId: MessageId, reaction: Reaction, messageSender: String) { reactionDatabase.addReaction( - messageId, ReactionRecord( - messageId = messageId.id, - isMms = messageId.mms, + messageId = messageId, author = messageSender, emoji = reaction.emoji!!, serverId = reaction.serverId!!, @@ -1840,15 +1826,34 @@ open class Storage @Inject constructor( sortId = reaction.index!!, dateSent = reaction.dateSent!!, dateReceived = reaction.dateReceived!! - ), - notifyUnread + ) ) } - override fun removeReaction(emoji: String, messageTimestamp: Long, author: String, notifyUnread: Boolean) { - val messageRecord = mmsSmsDatabase.getMessageForTimestamp(messageTimestamp) ?: return - val messageId = MessageId(messageRecord.id, messageRecord.isMms) - reactionDatabase.deleteReaction(emoji, messageId, author, notifyUnread) + override fun addReactions( + reactions: Map>, + replaceAll: Boolean, + notifyUnread: Boolean + ) { + reactionDatabase.addReactions( + reactionsByMessageId = reactions, + replaceAll = replaceAll + ) + } + + override fun removeReaction( + emoji: String, + messageTimestamp: Long, + threadId: Long, + author: String, + notifyUnread: Boolean + ) { + val messageRecord = mmsSmsDatabase.getMessageForTimestamp(threadId, messageTimestamp) ?: return + reactionDatabase.deleteReaction( + emoji, + MessageId(messageRecord.id, messageRecord.isMms), + author + ) } override fun updateReactionIfNeeded(message: Message, sender: String, openGroupSentTimestamp: Long) { @@ -1867,8 +1872,8 @@ open class Storage @Inject constructor( database.updateReaction(reaction) } - override fun deleteReactions(messageId: Long, mms: Boolean) { - reactionDatabase.deleteMessageReactions(MessageId(messageId, mms)) + override fun deleteReactions(messageId: MessageId) { + reactionDatabase.deleteMessageReactions(messageId) } override fun deleteReactions(messageIds: List, mms: Boolean) { @@ -1885,7 +1890,7 @@ open class Storage @Inject constructor( configFactory.withMutableUserConfigs { configs -> recipients.filter { it.isContactRecipient && !it.isLocalNumber } .forEach { recipient -> - configs.contacts.upsertContact(recipient.address.serialize()) { + configs.contacts.upsertContact(recipient.address.toString()) { this.blocked = isBlocked } } @@ -1905,11 +1910,11 @@ open class Storage @Inject constructor( recipient.isLocalNumber -> configFactory.withUserConfigs { it.userProfile.getNtsExpiry() } recipient.isContactRecipient -> { // read it from contacts config if exists - recipient.address.serialize().takeIf { it.startsWith(IdPrefix.STANDARD.value) } + recipient.address.toString().takeIf { it.startsWith(IdPrefix.STANDARD.value) } ?.let { configFactory.withUserConfigs { configs -> configs.contacts.get(it)?.expiryMode } } } recipient.isGroupV2Recipient -> { - configFactory.withGroupConfigs(AccountId(recipient.address.serialize())) { configs -> + configFactory.withGroupConfigs(AccountId(recipient.address.toString())) { configs -> configs.groupInfo.getExpiryTimer() }.let { if (it == 0L) ExpiryMode.NONE else ExpiryMode.AfterSend(it) @@ -1917,7 +1922,7 @@ open class Storage @Inject constructor( } recipient.isLegacyGroupRecipient -> { // read it from group config if exists - GroupUtil.doubleDecodeGroupId(recipient.address.serialize()) + GroupUtil.doubleDecodeGroupId(recipient.address.toString()) .let { id -> configFactory.withUserConfigs { it.userGroups.getLegacyGroupInfo(id) } } ?.run { disappearingTimer.takeIf { it != 0L }?.let(ExpiryMode::AfterSend) ?: ExpiryMode.NONE } } @@ -1940,7 +1945,7 @@ open class Storage @Inject constructor( if (expiryMode == ExpiryMode.NONE) { // Clear the legacy recipients on updating config to be none - lokiAPIDatabase.setLastLegacySenderAddress(recipient.address.serialize(), null) + lokiAPIDatabase.setLastLegacySenderAddress(recipient.address.toString(), null) } if (recipient.isLegacyGroupRecipient) { @@ -1952,7 +1957,7 @@ open class Storage @Inject constructor( it.userGroups.set(groupInfo) } } else if (recipient.isGroupV2Recipient) { - val groupSessionId = AccountId(recipient.address.serialize()) + val groupSessionId = AccountId(recipient.address.toString()) configFactory.withMutableGroupConfigs(groupSessionId) { configs -> configs.groupInfo.setExpiryTimer(expiryMode.expirySeconds) } @@ -1963,7 +1968,7 @@ open class Storage @Inject constructor( } } else if (recipient.isContactRecipient) { configFactory.withMutableUserConfigs { - val contact = it.contacts.get(recipient.address.serialize())?.copy(expiryMode = expiryMode) ?: return@withMutableUserConfigs + val contact = it.contacts.get(recipient.address.toString())?.copy(expiryMode = expiryMode) ?: return@withMutableUserConfigs it.contacts.set(contact) } } @@ -1972,27 +1977,6 @@ open class Storage @Inject constructor( ) } - override fun getExpiringMessages(messageIds: List): List> { - val expiringMessages = mutableListOf>() - val smsDb = 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 = 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, @@ -2001,7 +1985,7 @@ open class Storage @Inject constructor( val threadDb = threadDatabase val lokiDb = lokiAPIDatabase val recipient = threadDb.getRecipientForThreadId(threadID) ?: return - val recipientAddress = recipient.address.serialize() + val recipientAddress = recipient.address.toString() recipientDatabase .setDisappearingState(recipient, disappearingState); val currentLegacyRecipient = lokiDb.getLastLegacySenderAddress(recipientAddress) 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 0a295c260e..c3324c1bc2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -17,25 +17,29 @@ */ package org.thoughtcrime.securesms.database; -import static org.session.libsession.utilities.GroupUtil.LEGACY_CLOSED_GROUP_PREFIX; import static org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX; +import static org.session.libsession.utilities.GroupUtil.LEGACY_CLOSED_GROUP_PREFIX; import static org.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID; +import static org.thoughtcrime.securesms.database.UtilKt.generatePlaceholders; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.MergeCursor; import android.net.Uri; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; + import com.annimon.stream.Stream; + import net.zetetic.database.sqlcipher.SQLiteDatabase; -import org.jetbrains.annotations.NotNull; +import net.zetetic.database.sqlcipher.SQLiteStatement; + import org.session.libsession.messaging.MessagingModuleConfiguration; import org.session.libsession.snode.SnodeAPI; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.ConfigFactoryProtocolKt; -import org.session.libsession.utilities.Contact; import org.session.libsession.utilities.DelimiterUtil; import org.session.libsession.utilities.DistributionTypes; import org.session.libsession.utilities.GroupRecord; @@ -49,38 +53,45 @@ import org.session.libsignal.utilities.Pair; import org.session.libsignal.utilities.guava.Optional; import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.contacts.ContactUtil; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.GroupThreadStatus; 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.database.model.content.MessageContent; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent; 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.io.IOException; -import java.util.Collections; +import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; +import javax.inject.Inject; +import javax.inject.Provider; +import javax.inject.Singleton; + +import kotlin.collections.CollectionsKt; +import kotlinx.serialization.json.Json; import network.loki.messenger.libsession_util.util.GroupInfo; -public class ThreadDatabase extends Database { +@Singleton +public class ThreadDatabase extends Database implements OnAppStartupComponent { 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(); + // Map of threadID -> Address private final Map addressCache = new HashMap<>(); public static final String TABLE_NAME = "thread"; @@ -97,6 +108,10 @@ public interface ConversationThreadUpdateListener { private static final String ERROR = "error"; public static final String SNIPPET_TYPE = "snippet_type"; public static final String SNIPPET_URI = "snippet_uri"; + /** + * The column that hold a {@link MessageContent}. See {@link MmsDatabase#MESSAGE_CONTENT} for more information + */ + public static final String SNIPPET_CONTENT = "snippet_content"; public static final String ARCHIVED = "archived"; public static final String STATUS = "status"; public static final String DELIVERY_RECEIPT_COUNT = "delivery_receipt_count"; @@ -122,9 +137,11 @@ public interface ConversationThreadUpdateListener { "CREATE INDEX IF NOT EXISTS archived_count_index ON " + TABLE_NAME + " (" + ARCHIVED + ", " + MESSAGE_COUNT + ");", }; + public static final String ADD_SNIPPET_CONTENT_COLUMN = "ALTER TABLE " + TABLE_NAME + " ADD COLUMN " + SNIPPET_CONTENT + " TEXT DEFAULT NULL;"; + private static final String[] THREAD_PROJECTION = { 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 + SNIPPET_URI, ARCHIVED, STATUS, DELIVERY_RECEIPT_COUNT, EXPIRES_IN, LAST_SEEN, READ_RECEIPT_COUNT, IS_PINNED, SNIPPET_CONTENT, }; private static final List TYPED_THREAD_PROJECTION = Stream.of(THREAD_PROJECTION) @@ -152,9 +169,38 @@ public static String getUnreadMentionCountCommand() { } private ConversationThreadUpdateListener updateListener; - - public ThreadDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + private final Json json; + private final TextSecurePreferences prefs; + + @Inject + public ThreadDatabase( + @dagger.hilt.android.qualifiers.ApplicationContext Context context, + Provider databaseHelper, + TextSecurePreferences prefs, + Json json) { super(context, databaseHelper); + this.json = json; + this.prefs = prefs; + } + + @Override + public void onPostAppStarted() { + if (!prefs.getMigratedDisappearingMessagesToMessageContent()) { + migrateDisappearingMessagesToMessageContent(); + prefs.setMigratedDisappearingMessagesToMessageContent(true); + } + } + + // As we migrate disappearing messages to MessageContent, we need to ensure that + // if they appear in the snippet, they have to be re-generated with the new MessageContent. + private void migrateDisappearingMessagesToMessageContent() { + String sql = "SELECT " + ID + " FROM " + TABLE_NAME + + " WHERE " + SNIPPET_TYPE + " & " + MmsSmsColumns.Types.EXPIRATION_TIMER_UPDATE_BIT + " != 0"; + try (final Cursor cursor = getReadableDatabase().rawQuery(sql)) { + while (cursor.moveToNext()) { + update(cursor.getLong(0), false); + } + } } public void setUpdateListener(ConversationThreadUpdateListener updateListener) { @@ -164,17 +210,17 @@ public void setUpdateListener(ConversationThreadUpdateListener updateListener) { private long createThreadForRecipient(Address address, boolean group, int distributionType) { ContentValues contentValues = new ContentValues(4); - contentValues.put(ADDRESS, address.serialize()); + contentValues.put(ADDRESS, address.toString()); if (group) contentValues.put(DISTRIBUTION_TYPE, distributionType); contentValues.put(MESSAGE_COUNT, 0); - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); return db.insert(TABLE_NAME, null, contentValues); } - private void updateThread(long threadId, long count, String body, @Nullable Uri attachment, + private void updateThread(long threadId, long count, String body, @Nullable Uri attachment, @Nullable MessageContent messageContent, long date, int status, int deliveryReceiptCount, long type, boolean unarchive, long expiresIn, int readReceiptCount) { @@ -184,6 +230,7 @@ private void updateThread(long threadId, long count, String body, @Nullable Uri if (!body.isEmpty()) { contentValues.put(SNIPPET, body); } + contentValues.put(SNIPPET_CONTENT, messageContent == null ? null : json.encodeToString(MessageContent.Companion.serializer(), messageContent)); contentValues.put(SNIPPET_URI, attachment == null ? null : attachment.toString()); contentValues.put(SNIPPET_TYPE, type); contentValues.put(STATUS, status); @@ -193,7 +240,7 @@ private void updateThread(long threadId, long count, String body, @Nullable Uri if (unarchive) { contentValues.put(ARCHIVED, 0); } - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); db.update(TABLE_NAME, contentValues, ID + " = ?", new String[] {threadId + ""}); notifyConversationListListeners(); } @@ -202,44 +249,22 @@ public void clearSnippet(long threadId){ ContentValues contentValues = new ContentValues(1); contentValues.put(SNIPPET, ""); + contentValues.put(SNIPPET_CONTENT, ""); - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.update(TABLE_NAME, contentValues, ID + " = ?", new String[] {threadId + ""}); - notifyConversationListListeners(); - } - - public void updateSnippet(long threadId, String snippet, @Nullable Uri attachment, long date, long type, boolean unarchive) { - ContentValues contentValues = new ContentValues(4); - - contentValues.put(THREAD_CREATION_DATE, date - date % 1000); - if (!snippet.isEmpty()) { - contentValues.put(SNIPPET, snippet); - } - contentValues.put(SNIPPET_TYPE, type); - contentValues.put(SNIPPET_URI, attachment == null ? null : attachment.toString()); - - if (unarchive) { - contentValues.put(ARCHIVED, 0); - } - - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); db.update(TABLE_NAME, contentValues, ID + " = ?", new String[] {threadId + ""}); notifyConversationListListeners(); } - private void deleteThread(long threadId) { - Recipient recipient = getRecipientForThreadId(threadId); - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - int numberRemoved = db.delete(TABLE_NAME, ID_WHERE, new String[] {threadId + ""}); + public void deleteThread(long threadId) { + SQLiteDatabase db = getWritableDatabase(); + 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 threadIds) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); String where = ""; for (long threadId : threadIds) { where += ID + " = '" + threadId + "' OR "; } @@ -254,7 +279,7 @@ private void deleteThreads(Set threadIds) { } private void deleteAllThreads() { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); db.delete(TABLE_NAME, null, null); addressCache.clear(); notifyConversationListListeners(); @@ -323,23 +348,16 @@ public List setRead(long threadId, long lastReadTime) { final List smsRecords = DatabaseComponent.get(context).smsDatabase().setMessagesRead(threadId, lastReadTime); final List 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(); + SQLiteDatabase db = getWritableDatabase(); db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""}); notifyConversationListListeners(); - return new LinkedList() {{ - addAll(smsRecords); - addAll(mmsRecords); - }}; + return CollectionsKt.plus(smsRecords, mmsRecords); } public List setRead(long threadId, boolean lastSeen) { @@ -352,7 +370,7 @@ public List setRead(long threadId, boolean lastSeen) { contentValues.put(LAST_SEEN, SnodeAPI.getNowWithOffset()); } - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""}); final List smsRecords = DatabaseComponent.get(context).smsDatabase().setMessagesRead(threadId); @@ -370,7 +388,7 @@ public void setDistributionType(long threadId, int distributionType) { ContentValues contentValues = new ContentValues(1); contentValues.put(DISTRIBUTION_TYPE, distributionType); - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId + ""}); notifyConversationListListeners(); } @@ -378,13 +396,13 @@ public void setDistributionType(long threadId, int distributionType) { public void setCreationDate(long threadId, long date) { ContentValues contentValues = new ContentValues(1); contentValues.put(THREAD_CREATION_DATE, date); - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = 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(); + SQLiteDatabase db = getReadableDatabase(); Cursor cursor = db.query(TABLE_NAME, new String[]{DISTRIBUTION_TYPE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null); try { @@ -399,85 +417,73 @@ public int getDistributionType(long threadId) { } - public Cursor searchConversationAddresses(String addressQuery) { + public Cursor searchConversationAddresses(String addressQuery, Set excludeAddresses) { if (addressQuery == null || addressQuery.isEmpty()) { return null; } - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - String selection = TABLE_NAME + "." + ADDRESS + " LIKE ? AND " + TABLE_NAME + "." + MESSAGE_COUNT + " != 0"; - String[] selectionArgs = new String[]{addressQuery+"%"}; - String query = createQuery(selection, 0); - Cursor cursor = db.rawQuery(query, selectionArgs); - return cursor; + SQLiteDatabase db = getReadableDatabase(); + StringBuilder selection = new StringBuilder(TABLE_NAME + "." + ADDRESS + " LIKE ?"); + + List selectionArgs = new ArrayList<>(); + selectionArgs.add(addressQuery + "%"); + + // Add exclusion for blocked contacts + if (excludeAddresses != null && !excludeAddresses.isEmpty()) { + selection.append(" AND ").append(TABLE_NAME).append(".").append(ADDRESS).append(" NOT IN ("); + + // Use the helper method to generate placeholders + selection.append(generatePlaceholders(excludeAddresses.size())); + selection.append(")"); + + // Add all exclusion addresses to selection args + selectionArgs.addAll(excludeAddresses); + } + + String query = createQuery(selection.toString(), 0); + return db.rawQuery(query, selectionArgs.toArray(new String[0])); } + public Cursor getFilteredConversationList(@Nullable List
filter) { if (filter == null || filter.size() == 0) return null; - SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SQLiteDatabase db = getReadableDatabase(); List> partitionedAddresses = Util.partition(filter, 900); List cursors = new LinkedList<>(); for (List
addresses : partitionedAddresses) { - String selection = TABLE_NAME + "." + ADDRESS + " = ?"; - String[] selectionArgs = new String[addresses.size()]; + StringBuilder selection = new StringBuilder(TABLE_NAME + "." + ADDRESS + " = ?"); + String[] selectionArgs = new String[addresses.size()]; - for (int i=0;i 1 ? new MergeCursor(cursors.toArray(new Cursor[cursors.size()])) : cursors.get(0); + Cursor cursor = cursors.size() > 1 ? new MergeCursor(cursors.toArray(new Cursor[0])) : cursors.get(0); setNotifyConversationListListeners(cursor); return cursor; } public Cursor getRecentConversationList(int limit) { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - String query = createQuery(MESSAGE_COUNT + " != 0", limit); + SQLiteDatabase db = getReadableDatabase(); + String query = createQuery("", limit); return db.rawQuery(query, null); } - public long getLatestUnapprovedConversationTimestamp() { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - Cursor cursor = null; - - try { - 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 + - " ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + - " 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 " + THREAD_CREATION_DATE + " DESC LIMIT 1"; - cursor = db.rawQuery(where, null); - - if (cursor != null && cursor.moveToFirst()) - return cursor.getLong(0); - } finally { - if (cursor != null) - cursor.close(); - } - - return 0; - } - public Cursor getConversationList() { - String where = "(" + MESSAGE_COUNT + " != 0 OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + COMMUNITY_PREFIX + "%') " + - "AND " + ARCHIVED + " = 0 "; - return getConversationList(where); + return getConversationList(ARCHIVED + " = 0 "); } public Cursor getBlindedConversationList() { @@ -501,8 +507,31 @@ public Cursor getUnapprovedConversationList() { return getConversationList(where); } + // Returns the count directly instead of having to use cursor + public long getUnapprovedUnreadConversationCount() { + String where = + "(" + MESSAGE_COUNT + " != 0 OR " + + TABLE_NAME + "." + ADDRESS + " LIKE '" + IdPrefix.GROUP.getValue() + "%')" + + " AND " + ARCHIVED + " = 0" + + " AND " + HAS_SENT + " = 0" + + " AND " + RecipientDatabase.APPROVED + " = 0" + + " AND " + RecipientDatabase.BLOCK + " = 0" + + " AND " + GroupDatabase.GROUP_ID + " IS NULL" + + " AND (" + UNREAD_COUNT + " > 0 OR " + + UNREAD_MENTION_COUNT + " > 0)"; + + String baseSql = createQuery(where, /* limit= */ 0); + + String countSql = "SELECT COUNT(*) FROM (" + baseSql + ")"; + + // try-with-resource to close the statement + try (SQLiteStatement stmt = getReadableDatabase().compileStatement(countSql)) { + return stmt.simpleQueryForLong(); + } + } + private Cursor getConversationList(String where) { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SQLiteDatabase db = getReadableDatabase(); String query = createQuery(where, 0); Cursor cursor = db.rawQuery(query, null); @@ -512,8 +541,8 @@ private Cursor getConversationList(String where) { } public Cursor getDirectShareList() { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - String query = createQuery(MESSAGE_COUNT + " != 0", 0); + SQLiteDatabase db = getReadableDatabase(); + String query = createQuery("", 0); return db.rawQuery(query, null); } @@ -529,7 +558,7 @@ public boolean setLastSeen(long threadId, long timestamp) { Recipient forThreadId = getRecipientForThreadId(threadId); if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && forThreadId != null && forThreadId.isCommunityRecipient()) return false; - SQLiteDatabase db = databaseHelper.getWritableDatabase(); + SQLiteDatabase db = getWritableDatabase(); ContentValues contentValues = new ContentValues(1); long lastSeenTime = timestamp == -1 ? SnodeAPI.getNowWithOffset() : timestamp; @@ -556,16 +585,9 @@ public boolean setLastSeen(long threadId, long timestamp) { 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 getLastSeenAndHasSent(long threadId) { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SQLiteDatabase db = getReadableDatabase(); Cursor cursor = db.query(TABLE_NAME, new String[]{LAST_SEEN, HAS_SENT}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null); try { @@ -580,7 +602,7 @@ public Pair getLastSeenAndHasSent(long threadId) { } public long getLastUpdated(long threadId) { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SQLiteDatabase db = getReadableDatabase(); Cursor cursor = db.query(TABLE_NAME, new String[]{THREAD_CREATION_DATE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null); try { @@ -595,7 +617,7 @@ public long getLastUpdated(long threadId) { } public int getMessageCount(long threadId) { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SQLiteDatabase db = getReadableDatabase(); String[] columns = new String[]{MESSAGE_COUNT}; String[] args = new String[]{String.valueOf(threadId)}; try (Cursor cursor = db.query(TABLE_NAME, columns, ID_WHERE, args, null, null, null)) { @@ -607,19 +629,8 @@ public int getMessageCount(long threadId) { } } - public void deleteConversation(long threadId) { - DatabaseComponent.get(context).smsDatabase().deleteThread(threadId); - DatabaseComponent.get(context).mmsDatabase().deleteThread(threadId); - DatabaseComponent.get(context).draftDatabase().clearDrafts(threadId); - DatabaseComponent.get(context).lokiMessageDatabase().deleteThread(threadId); - deleteThread(threadId); - notifyConversationListeners(threadId); - notifyConversationListListeners(); - SessionMetaProtocol.clearReceivedMessages(); - } - public long getThreadIdIfExistsFor(String address) { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SQLiteDatabase db = getReadableDatabase(); String where = ADDRESS + " = ?"; String[] recipientsArg = new String[] {address}; Cursor cursor = null; @@ -638,7 +649,7 @@ public long getThreadIdIfExistsFor(String address) { } public long getThreadIdIfExistsFor(Recipient recipient) { - return getThreadIdIfExistsFor(recipient.getAddress().serialize()); + return getThreadIdIfExistsFor(recipient.getAddress().toString()); } public long getOrCreateThreadIdFor(Recipient recipient) { @@ -649,7 +660,7 @@ public void setThreadArchived(long threadId) { ContentValues contentValues = new ContentValues(1); contentValues.put(ARCHIVED, 1); - databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, + getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)}); notifyConversationListListeners(); @@ -657,9 +668,9 @@ public void setThreadArchived(long threadId) { } public long getOrCreateThreadIdFor(Recipient recipient, int distributionType) { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SQLiteDatabase db = getReadableDatabase(); String where = ADDRESS + " = ?"; - String[] recipientsArg = new String[]{recipient.getAddress().serialize()}; + String[] recipientsArg = new String[]{recipient.getAddress().toString()}; Cursor cursor = null; boolean created = false; @@ -698,7 +709,7 @@ public long getOrCreateThreadIdFor(Recipient recipient, int distributionType) { return Recipient.from(context, addressCache.get(threadId), false); } - SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SQLiteDatabase db = getReadableDatabase(); Cursor cursor = null; try { @@ -721,7 +732,7 @@ public void setHasSent(long threadId, boolean hasSent) { ContentValues contentValues = new ContentValues(1); contentValues.put(HAS_SENT, hasSent ? 1 : 0); - databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, + getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)}); notifyConversationListeners(threadId); @@ -741,7 +752,7 @@ record = reader.getNext(); } } if (record != null && !record.isDeleted()) { - updateThread(threadId, count, getFormattedBodyFor(record), getAttachmentUriFor(record), + updateThread(threadId, count, getFormattedBodyFor(record), getAttachmentUriFor(record), record.getMessageContent(), record.getTimestamp(), record.getDeliveryStatus(), record.getDeliveryReceiptCount(), record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount()); return false; @@ -760,7 +771,7 @@ public void setPinned(long threadId, boolean pinned) { ContentValues contentValues = new ContentValues(1); contentValues.put(IS_PINNED, pinned ? 1 : 0); - databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, + getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)}); notifyConversationListeners(threadId); @@ -780,13 +791,31 @@ public boolean isPinned(long threadId) { } } + public boolean isRead(long threadId) { + SQLiteDatabase db = getReadableDatabase(); + // Only ask for the "READ" column + String[] projection = {READ}; + String selection = ID + " = ?"; + String[] args = {String.valueOf(threadId)}; + + Cursor cursor = db.query(TABLE_NAME, projection, selection, args, null, null, null); + try { + if (cursor != null && cursor.moveToFirst()) { + // READ is stored as 1 = read, 0 = unread + return cursor.getInt(0) == 1; + } + return false; + } finally { + if (cursor != null) cursor.close(); + } + } + /** * @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) { + public boolean markAllAsRead(long threadId, long lastSeenTime, boolean force) { MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && !force) return false; List messages = setRead(threadId, lastSeenTime); @@ -798,10 +827,6 @@ public boolean markAllAsRead(long threadId, boolean isGroupRecipient, long lastS private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) { if (messageRecord.isMms()) { MmsMessageRecord record = (MmsMessageRecord) messageRecord; - if (!record.getSharedContacts().isEmpty()) { - Contact contact = ((MmsMessageRecord)messageRecord).getSharedContacts().get(0); - return ContactUtil.getStringSummary(context, contact).toString(); - } String attachmentString = record.getSlideDeck().getBody(); if (!attachmentString.isEmpty()) { if (!messageRecord.getBody().isEmpty()) { @@ -846,13 +871,6 @@ public boolean markAllAsRead(long threadId, boolean isGroupRecipient, long lastS return query; } - public void migrateEncodedGroup(long threadId, @NotNull String newEncodedGroupId) { - ContentValues contentValues = new ContentValues(1); - contentValues.put(ADDRESS, newEncodedGroupId); - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""}); - } - public void notifyThreadUpdated(long threadId) { notifyConversationListeners(threadId); } @@ -929,6 +947,7 @@ public ThreadRecord getCurrent() { Uri snippetUri = getSnippetUri(cursor); boolean pinned = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.IS_PINNED)) != 0; String invitingAdmin = cursor.getString(cursor.getColumnIndexOrThrow(LokiMessageDatabase.invitingSessionId)); + String messageContentJson = cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_CONTENT)); if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { readReceiptCount = 0; @@ -938,17 +957,14 @@ public ThreadRecord getCurrent() { if (count > 0) { MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); - long messageTimestamp = mmsSmsDatabase.getLastMessageTimestamp(threadId); - if (messageTimestamp > 0) { - lastMessage = mmsSmsDatabase.getMessageForTimestamp(messageTimestamp); - } + lastMessage = mmsSmsDatabase.getLastMessage(threadId); } final GroupThreadStatus groupThreadStatus; if (recipient.isGroupV2Recipient() && retrieveGroupStatus) { GroupInfo.ClosedGroupInfo group = ConfigFactoryProtocolKt.getGroup( MessagingModuleConfiguration.getShared().getConfigFactory(), - new AccountId(recipient.getAddress().serialize()) + new AccountId(recipient.getAddress().toString()) ); if (group != null && group.getDestroyed()) { groupThreadStatus = GroupThreadStatus.Destroyed; @@ -961,9 +977,19 @@ public ThreadRecord getCurrent() { groupThreadStatus = GroupThreadStatus.None; } - return new ThreadRecord(body, snippetUri, lastMessage, recipient, date, count, + MessageContent messageContent; + try { + messageContent = (messageContentJson == null || messageContentJson.isEmpty()) ? null : json.decodeFromString( + MessageContent.Companion.serializer(), + messageContentJson + ); + } catch (Exception e) { + Log.e(TAG, "Failed to parse message content for thread: " + threadId, e); + messageContent = null; + } + return new ThreadRecord(body, snippetUri, lastMessage, recipient, date, count, unreadCount, unreadMentionCount, threadId, deliveryReceiptCount, status, type, - distributionType, archived, expiresIn, lastSeen, readReceiptCount, pinned, invitingAdmin, groupThreadStatus); + distributionType, archived, expiresIn, lastSeen, readReceiptCount, pinned, invitingAdmin, groupThreadStatus, messageContent); } private @Nullable Uri getSnippetUri(Cursor cursor) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Util.kt index 402ee6155f..d1e2dc7122 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Util.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Util.kt @@ -3,4 +3,17 @@ package org.thoughtcrime.securesms.database import android.content.Context import org.thoughtcrime.securesms.dependencies.DatabaseComponent -fun Context.threadDatabase() = DatabaseComponent.get(this).threadDatabase() \ No newline at end of file +fun Context.threadDatabase() = DatabaseComponent.get(this).threadDatabase() + +// Helper method to generate SQL placeholders (?, ?, ?) +fun generatePlaceholders(count: Int): String { + if (count <= 0) return "" + + val placeholders = StringBuilder() + for (i in 0.. 0) placeholders.append(", ") + placeholders.append("?") + } + + return placeholders.toString() +} \ No newline at end of file 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 e1568933cb..378b491057 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 @@ -8,15 +8,22 @@ import android.database.Cursor; import androidx.annotation.NonNull; import androidx.core.app.NotificationCompat; + +import com.google.common.io.Files; import com.squareup.phrase.Phrase; import java.io.File; +import java.io.IOException; + 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 network.loki.messenger.BuildConfig; import network.loki.messenger.R; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsignal.utilities.Log; +import org.session.libsignal.utilities.guava.Preconditions; import org.thoughtcrime.securesms.crypto.DatabaseSecret; import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase; @@ -92,15 +99,14 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV46 = 67; private static final int lokiV47 = 68; private static final int lokiV48 = 69; + private static final int lokiV49 = 70; + private static final int lokiV50 = 71; + private static final int lokiV51 = 72; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV48; + private static final int DATABASE_VERSION = lokiV51; 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 static final String DATABASE_NAME = "session.db"; public SQLCipherOpenHelper(@NonNull Context context, @NonNull DatabaseSecret databaseSecret) { super( @@ -113,13 +119,12 @@ public SQLCipherOpenHelper(@NonNull Context context, @NonNull DatabaseSecret dat null, new SQLiteDatabaseHook() { @Override - public void preKey(SQLiteConnection connection) { - SQLCipherOpenHelper.applySQLCipherPragmas(connection, true); - } + public void preKey(SQLiteConnection connection) {} @Override public void postKey(SQLiteConnection connection) { - SQLCipherOpenHelper.applySQLCipherPragmas(connection, true); + connection.execute("PRAGMA kdf_iter = 1;", null, null); + connection.execute("PRAGMA cipher_page_size = 4096;", null, null); // if not vacuumed in a while, perform that operation long currentTime = System.currentTimeMillis(); @@ -137,141 +142,6 @@ public void postKey(SQLiteConnection connection) { // 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.failures); - - NotificationChannel channel = new NotificationChannel(channelId, channelId, NotificationManager.IMPORTANCE_HIGH); - channel.enableVibration(true); - notificationManager.createNotificationChannel(channel); - - CharSequence errorTxt = Phrase.from(context, R.string.databaseErrorGeneric) - .put(APP_NAME_KEY, R.string.app_name) - .format(); - - 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.errorDatabase)) - .setContentText(errorTxt) - .setAutoCancel(true); - - notificationManager.notify(5874, builder.build()); - - // Throw the error (app will crash but there is nothing else we can do unfortunately) - throw e; - } } @Override @@ -361,6 +231,7 @@ public void onCreate(SQLiteDatabase db) { executeStatements(db, ReactionDatabase.CREATE_INDEXS); executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS); + executeStatements(db, ReactionDatabase.CREATE_MESSAGE_ID_MMS_INDEX); db.execSQL(RecipientDatabase.getAddWrapperHash()); db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests()); db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE); @@ -371,6 +242,8 @@ public void onCreate(SQLiteDatabase db) { db.execSQL(LokiMessageDatabase.getCreateThreadDeleteTrigger()); db.execSQL(SmsDatabase.ADD_IS_GROUP_UPDATE_COLUMN); db.execSQL(MmsDatabase.ADD_IS_GROUP_UPDATE_COLUMN); + db.execSQL(MmsDatabase.ADD_MESSAGE_CONTENT_COLUMN); + db.execSQL(ThreadDatabase.ADD_SNIPPET_CONTENT_COLUMN); } @Override @@ -661,6 +534,20 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL(MmsDatabase.ADD_IS_GROUP_UPDATE_COLUMN); } + if (oldVersion < lokiV49) { + db.execSQL(LokiMessageDatabase.getUpdateErrorMessageTableCommand()); + } + + if (oldVersion < lokiV50) { + executeStatements(db, ReactionDatabase.CREATE_MESSAGE_ID_MMS_INDEX); + } + + if (oldVersion < lokiV51) { + db.execSQL(MmsDatabase.ADD_MESSAGE_CONTENT_COLUMN); + db.execSQL(MmsDatabase.MIGRATE_EXPIRY_CONTROL_MESSAGES); + db.execSQL(ThreadDatabase.ADD_SNIPPET_CONTENT_COLUMN); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java deleted file mode 100644 index 21ed07ac66..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.thoughtcrime.securesms.database.loaders; - - -import android.Manifest; -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.provider.MediaStore; -import androidx.loader.content.CursorLoader; - -import org.thoughtcrime.securesms.permissions.Permissions; - -public class RecentPhotosLoader extends CursorLoader { - - public static Uri BASE_URL = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; - - private static final String[] PROJECTION = new String[] { - MediaStore.Images.ImageColumns._ID, - MediaStore.Images.ImageColumns.DATE_TAKEN, - MediaStore.Images.ImageColumns.DATE_MODIFIED, - MediaStore.Images.ImageColumns.ORIENTATION, - MediaStore.Images.ImageColumns.MIME_TYPE, - MediaStore.Images.ImageColumns.BUCKET_ID, - MediaStore.Images.ImageColumns.SIZE, - MediaStore.Images.ImageColumns.WIDTH, - MediaStore.Images.ImageColumns.HEIGHT - }; - - private final Context context; - - public RecentPhotosLoader(Context context) { - super(context); - this.context = context.getApplicationContext(); - } - - @Override - public Cursor loadInBackground() { - if (Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) { - return context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - PROJECTION, null, null, - MediaStore.Images.ImageColumns.DATE_MODIFIED + " DESC"); - } else { - return null; - } - } - - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/ThreadMediaLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/ThreadMediaLoader.java deleted file mode 100644 index 3f5c108356..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/ThreadMediaLoader.java +++ /dev/null @@ -1,37 +0,0 @@ -package org.thoughtcrime.securesms.database.loaders; - - -import android.content.Context; -import android.database.Cursor; - -import androidx.annotation.NonNull; - -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.recipients.Recipient; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.util.AbstractCursorLoader; - -public class ThreadMediaLoader extends AbstractCursorLoader { - - private final Address address; - private final boolean gallery; - - public ThreadMediaLoader(@NonNull Context context, @NonNull Address address, boolean gallery) { - super(context); - this.address = address; - this.gallery = gallery; - } - - @Override - public Cursor getCursor() { - long threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(Recipient.from(getContext(), address, true)); - - if (gallery) return DatabaseComponent.get(context).mediaDatabase().getGalleryMediaForThread(threadId); - else return DatabaseComponent.get(context).mediaDatabase().getDocumentMediaForThread(threadId); - } - - public Address getAddress() { - return address; - } - -} 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 6ae671c065..8faaab63a9 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 @@ -19,10 +19,13 @@ import android.content.Context; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.session.libsession.utilities.recipients.Recipient; import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate; +import org.thoughtcrime.securesms.database.model.content.MessageContent; /** * The base class for all message record models. Encapsulates basic data @@ -43,13 +46,13 @@ public abstract class DisplayRecord { private final int deliveryReceiptCount; private final int readReceiptCount; + @Nullable + private final MessageContent messageContent; + DisplayRecord(String body, Recipient recipient, long dateSent, - long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount, - long type, int readReceiptCount) + long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount, + long type, int readReceiptCount, @Nullable MessageContent messageContent) { - // 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; @@ -59,6 +62,7 @@ public abstract class DisplayRecord { this.deliveryReceiptCount = deliveryReceiptCount; this.readReceiptCount = readReceiptCount; this.deliveryStatus = deliveryStatus; + this.messageContent = messageContent; } public @NonNull String getBody() { @@ -78,20 +82,6 @@ public boolean isDelivered() { deliveryStatus < SmsDatabase.Status.STATUS_PENDING) || deliveryReceiptCount > 0; } - 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() { return MmsSmsColumns.Types.isFailedMessageType(type) || MmsSmsColumns.Types.isPendingSecureSmsFallbackType(type) @@ -105,44 +95,39 @@ public boolean isPending() { return isPending; } - public boolean isRead() { return readReceiptCount > 0; } - - public boolean isOutgoing() { - return MmsSmsColumns.Types.isOutgoingMessageType(type); + @Nullable + public MessageContent getMessageContent() { + return messageContent; } - public boolean isIncoming() { - return !MmsSmsColumns.Types.isOutgoingMessageType(type); - } - - public boolean isGroupUpdateMessage() { - return SmsDatabase.Types.isGroupUpdateMessage(type); - } - public boolean isExpirationTimerUpdate() { return SmsDatabase.Types.isExpirationTimerUpdate(type); } - public boolean isMediaSavedNotification() { return MmsSmsColumns.Types.isMediaSavedExtraction(type); } - public boolean isScreenshotNotification() { return MmsSmsColumns.Types.isScreenshotExtraction(type); } + public boolean isCallLog() { return SmsDatabase.Types.isCallLog(type); } public boolean isDataExtractionNotification() { return isMediaSavedNotification() || isScreenshotNotification(); } - public boolean isOpenGroupInvitation() { return MmsSmsColumns.Types.isOpenGroupInvitation(type); } - public boolean isCallLog() { - return SmsDatabase.Types.isCallLog(type); - } - public boolean isIncomingCall() { - return SmsDatabase.Types.isIncomingCall(type); - } - public boolean isOutgoingCall() { - return SmsDatabase.Types.isOutgoingCall(type); - } - public boolean isMissedCall() { - return SmsDatabase.Types.isMissedCall(type); - } - public boolean isFirstMissedCall() { - return SmsDatabase.Types.isFirstMissedCall(type); - } - public boolean isDeleted() { return MmsSmsColumns.Types.isDeletedMessage(type); } - public boolean isMessageRequestResponse() { return MmsSmsColumns.Types.isMessageRequestResponse(type); } + public boolean isDeleted() { return MmsSmsColumns.Types.isDeletedMessage(type); } + public boolean isFirstMissedCall() { return SmsDatabase.Types.isFirstMissedCall(type); } + public boolean isGroupUpdateMessage() { return SmsDatabase.Types.isGroupUpdateMessage(type); } + public boolean isIncoming() { return !MmsSmsColumns.Types.isOutgoingMessageType(type); } + public boolean isIncomingCall() { return SmsDatabase.Types.isIncomingCall(type); } + public boolean isMediaSavedNotification() { return MmsSmsColumns.Types.isMediaSavedExtraction(type); } + public boolean isMessageRequestResponse() { return MmsSmsColumns.Types.isMessageRequestResponse(type); } + public boolean isMissedCall() { return SmsDatabase.Types.isMissedCall(type); } + public boolean isOpenGroupInvitation() { return MmsSmsColumns.Types.isOpenGroupInvitation(type); } + public boolean isOutgoing() { return MmsSmsColumns.Types.isOutgoingMessageType(type); } + public boolean isOutgoingCall() { return SmsDatabase.Types.isOutgoingCall(type); } + public boolean isRead() { return readReceiptCount > 0; } + public boolean isResyncing() { return MmsSmsColumns.Types.isResyncingType(type); } + public boolean isScreenshotNotification() { return MmsSmsColumns.Types.isScreenshotExtraction(type); } + public boolean isSent() { return MmsSmsColumns.Types.isSentType(type); } + public boolean isSending() { return isOutgoing() && !isSent(); } + public boolean isSyncFailed() { return MmsSmsColumns.Types.isSyncFailedMessageType(type); } + public boolean isSyncing() { return MmsSmsColumns.Types.isSyncingType(type); } public boolean isControlMessage() { - return isGroupUpdateMessage() || isExpirationTimerUpdate() || isDataExtractionNotification() - || isMessageRequestResponse() || isCallLog(); + return isGroupUpdateMessage() || + isDataExtractionNotification() || + isMessageRequestResponse() || + isCallLog() || + (messageContent instanceof DisappearingMessageUpdate); } + + public boolean isGroupV2ExpirationTimerUpdate() { return false; } } 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 0383d17bda..7fbcf9669e 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 @@ -27,6 +27,7 @@ import org.session.libsession.utilities.NetworkFailure; import org.session.libsession.utilities.recipients.Recipient; import org.thoughtcrime.securesms.database.SmsDatabase.Status; +import org.thoughtcrime.securesms.database.model.content.MessageContent; import org.thoughtcrime.securesms.mms.SlideDeck; import java.util.List; @@ -53,12 +54,13 @@ public MediaMmsMessageRecord(long id, Recipient conversationRecipient, long expiresIn, long expireStarted, int readReceiptCount, @Nullable Quote quote, @NonNull List contacts, @NonNull List linkPreviews, - @NonNull List reactions, boolean unidentified, boolean hasMention) + @NonNull List reactions, boolean hasMention, + @Nullable MessageContent messageContent) { super(id, body, conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures, expiresIn, expireStarted, slideDeck, readReceiptCount, quote, contacts, - linkPreviews, unidentified, reactions, hasMention); + linkPreviews, reactions, hasMention, messageContent); this.partCount = partCount; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt index e7155aa781..0beef4738a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt @@ -1,13 +1,24 @@ package org.thoughtcrime.securesms.database.model +import android.os.Parcelable +import androidx.annotation.Keep +import kotlinx.parcelize.Parcelize + /** * Represents a pair of values that can be used to find a message. Because we have two tables, * that means this has both the primary key and a boolean indicating which table it's in. */ +@Parcelize data class MessageId( val id: Long, @get:JvmName("isMms") val mms: Boolean -) { +): Parcelable { + // Exists only because Kryo wants it + @Keep + private constructor(): this(0, false) + + val sms: Boolean get() = !mms + fun serialize(): String { return "$id|$mms" } 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 1a6773813d..281c7a7d26 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 @@ -37,6 +37,8 @@ import org.session.libsession.utilities.ThemeUtil; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.AccountId; +import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate; +import org.thoughtcrime.securesms.database.model.content.MessageContent; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import network.loki.messenger.R; @@ -55,7 +57,6 @@ public abstract class MessageRecord extends DisplayRecord { private final List networkFailures; private final long expiresIn; private final long expireStarted; - private final boolean unidentified; public final long id; private final List reactions; private final boolean hasMention; @@ -63,31 +64,31 @@ public abstract class MessageRecord extends DisplayRecord { @Nullable private UpdateMessageData groupUpdateMessage; - public final boolean isNotDisappearAfterRead() { - return expireStarted == getTimestamp(); - } - public abstract boolean isMms(); public abstract boolean isMmsNotification(); + public final MessageId getMessageId() { + return new MessageId(getId(), isMms()); + } + MessageRecord(long id, String body, Recipient conversationRecipient, - Recipient individualRecipient, - long dateSent, long dateReceived, long threadId, - int deliveryStatus, int deliveryReceiptCount, long type, - List mismatches, - List networkFailures, - long expiresIn, long expireStarted, - int readReceiptCount, boolean unidentified, List reactions, boolean hasMention) + Recipient individualRecipient, + long dateSent, long dateReceived, long threadId, + int deliveryStatus, int deliveryReceiptCount, long type, + List mismatches, + List networkFailures, + long expiresIn, long expireStarted, + int readReceiptCount, List reactions, boolean hasMention, + @Nullable MessageContent messageContent) { super(body, conversationRecipient, dateSent, dateReceived, - threadId, deliveryStatus, deliveryReceiptCount, type, readReceiptCount); + threadId, deliveryStatus, deliveryReceiptCount, type, readReceiptCount, messageContent); this.id = id; this.individualRecipient = individualRecipient; this.mismatches = mismatches; this.networkFailures = networkFailures; this.expiresIn = expiresIn; this.expireStarted = expireStarted; - this.unidentified = unidentified; this.reactions = reactions; this.hasMention = hasMention; } @@ -142,7 +143,7 @@ public CharSequence getDisplayBody(@NonNull Context context) { SpannableString text = new SpannableString(UpdateMessageBuilder.buildGroupUpdateMessage( context, - groupRecipient.isGroupV2Recipient() ? new AccountId(groupRecipient.getAddress().serialize()) : null, // accountId is only used for GroupsV2 + groupRecipient.isGroupV2Recipient() ? new AccountId(groupRecipient.getAddress().toString()) : null, // accountId is only used for GroupsV2 updateMessageData, MessagingModuleConfiguration.getShared().getConfigFactory(), isOutgoing(), @@ -157,13 +158,15 @@ public CharSequence getDisplayBody(@NonNull Context context) { } return text; - } else if (isExpirationTimerUpdate()) { - int seconds = (int) (getExpiresIn() / 1000); - boolean isGroup = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(getThreadId()).isGroupOrCommunityRecipient(); - return new SpannableString(UpdateMessageBuilder.INSTANCE.buildExpirationTimerMessage(context, seconds, isGroup, getIndividualRecipient().getAddress().serialize(), isOutgoing(), getTimestamp(), expireStarted)); + } else if (getMessageContent() instanceof DisappearingMessageUpdate) { + Recipient rec = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(getThreadId()); + if(rec == null) return ""; + boolean isGroup = rec.isGroupOrCommunityRecipient(); + return UpdateMessageBuilder.INSTANCE + .buildExpirationTimerMessage(context, ((DisappearingMessageUpdate) getMessageContent()).getExpiryMode(), isGroup, getIndividualRecipient().getAddress().toString(), isOutgoing()); } 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()))); + if (isScreenshotNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.SCREENSHOT, getIndividualRecipient().getAddress().toString()))); + else if (isMediaSavedNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED, getIndividualRecipient().getAddress().toString()))); } else if (isCallLog()) { CallMessageType callType; if (isIncomingCall()) { @@ -175,7 +178,7 @@ public CharSequence getDisplayBody(@NonNull Context context) { } else { callType = CallMessageType.CALL_FIRST_MISSED; } - return new SpannableString(UpdateMessageBuilder.INSTANCE.buildCallMessage(context, callType, getIndividualRecipient().getAddress().serialize())); + return new SpannableString(UpdateMessageBuilder.INSTANCE.buildCallMessage(context, callType, getIndividualRecipient().getAddress().toString())); } return new SpannableString(getBody()); 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 9f34f3fa0e..802d16b835 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 @@ -8,9 +8,11 @@ import org.session.libsession.utilities.IdentityKeyMismatch; import org.session.libsession.utilities.NetworkFailure; import org.session.libsession.utilities.recipients.Recipient; +import org.thoughtcrime.securesms.database.model.content.MessageContent; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; +import java.util.Arrays; import java.util.LinkedList; import java.util.List; @@ -21,15 +23,16 @@ public abstract class MmsMessageRecord extends MessageRecord { private final @NonNull List linkPreviews = new LinkedList<>(); MmsMessageRecord(long id, String body, Recipient conversationRecipient, - Recipient individualRecipient, long dateSent, - long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount, - long type, List mismatches, - List networkFailures, long expiresIn, - long expireStarted, @NonNull SlideDeck slideDeck, int readReceiptCount, - @Nullable Quote quote, @NonNull List contacts, - @NonNull List linkPreviews, boolean unidentified, List reactions, boolean hasMention) + Recipient individualRecipient, long dateSent, + long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount, + long type, List mismatches, + List networkFailures, long expiresIn, + long expireStarted, @NonNull SlideDeck slideDeck, int readReceiptCount, + @Nullable Quote quote, @NonNull List contacts, + @NonNull List linkPreviews, List reactions, boolean hasMention, + @Nullable MessageContent messageContent) { - super(id, body, conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, expiresIn, expireStarted, readReceiptCount, unidentified, reactions, hasMention); + super(id, body, conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, expiresIn, expireStarted, readReceiptCount, reactions, hasMention, messageContent); this.slideDeck = slideDeck; this.quote = quote; this.contacts.addAll(contacts); @@ -69,4 +72,17 @@ public boolean containsMediaSlide() { public @NonNull List getLinkPreviews() { return linkPreviews; } + + public boolean hasAttachmentUri() { + boolean hasData = false; + + for (Slide slide : slideDeck.getSlides()) { + if (slide.getUri() != null || slide.getThumbnailUri() != null) { + hasData = true; + break; + } + } + + return hasData; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.kt deleted file mode 100644 index eb742cd6b1..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (C) 2012 Moxie Marlinspike - * - * 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 . - */ -package org.thoughtcrime.securesms.database.model - -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.database.SmsDatabase -import org.thoughtcrime.securesms.mms.SlideDeck - -/** - * Represents the message record model for MMS messages that are - * notifications (ie: they're pointers to undownloaded media). - * - * @author Moxie Marlinspike - */ -class NotificationMmsMessageRecord( - id: Long, conversationRecipient: Recipient?, - individualRecipient: Recipient?, - dateSent: Long, - dateReceived: Long, - deliveryReceiptCount: Int, - threadId: Long, - private val messageSize: Long, - private val expiry: Long, - val status: Int, - mailbox: Long, - slideDeck: SlideDeck?, - readReceiptCount: Int, - hasMention: Boolean -) : MmsMessageRecord( - id, "", conversationRecipient, individualRecipient, - dateSent, dateReceived, threadId, SmsDatabase.Status.STATUS_NONE, deliveryReceiptCount, mailbox, - emptyList(), emptyList(), - 0, 0, slideDeck!!, readReceiptCount, null, emptyList(), emptyList(), false, emptyList(), hasMention -) { - fun getMessageSize(): Long { - return (messageSize + 1023) / 1024 - } - - val expiration: Long - get() = expiry * 1000 - - override fun isOutgoing(): Boolean { - return false - } - - override fun isPending(): Boolean { - return false - } - - override fun isMmsNotification(): Boolean { - return true - } - - override fun isMediaPending(): Boolean { - return true - } -} 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 deleted file mode 100644 index 4fd22ce8a4..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/Quote.java +++ /dev/null @@ -1,65 +0,0 @@ -package org.thoughtcrime.securesms.database.model; - - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -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; - private final Address author; - private final String text; - private final boolean missing; - private final SlideDeck attachment; - - public Quote(long id, @NonNull Address author, @Nullable String text, boolean missing, @NonNull SlideDeck attachment) { - this.id = id; - this.author = author; - this.text = text; - this.missing = missing; - this.attachment = attachment; - } - - public long getId() { - return id; - } - - public @NonNull Address getAuthor() { - return author; - } - - public @Nullable String getText() { - return text; - } - - public boolean isOriginalMissing() { - return missing; - } - - public @NonNull SlideDeck getAttachment() { - return attachment; - } - - 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/Quote.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/Quote.kt new file mode 100644 index 0000000000..d803b5f667 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/Quote.kt @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.database.model + +import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel +import org.session.libsession.utilities.Address +import org.thoughtcrime.securesms.mms.SlideDeck + +data class Quote( + val id: Long, + val author: Address, + val text: String?, + val isOriginalMissing: Boolean, + val attachment: SlideDeck +) { + val quoteModel: QuoteModel + get() = QuoteModel(id, author, text, this.isOriginalMissing, attachment.asAttachments()) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ReactionRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/ReactionRecord.kt index 187c5b2d4b..bbe7d854b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ReactionRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ReactionRecord.kt @@ -2,8 +2,7 @@ package org.thoughtcrime.securesms.database.model data class ReactionRecord( val id: Long = 0, - val messageId: Long, - val isMms: Boolean, + val messageId: MessageId, val author: String, val emoji: String, val serverId: String = "", 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 70e80d720e..c33c1efb6e 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 @@ -42,12 +42,12 @@ public SmsMessageRecord(long id, long type, long threadId, int status, List mismatches, long expiresIn, long expireStarted, - int readReceiptCount, boolean unidentified, List reactions, boolean hasMention) + int readReceiptCount, List reactions, boolean hasMention) { super(id, body, recipient, individualRecipient, dateSent, dateReceived, threadId, status, deliveryReceiptCount, type, mismatches, new LinkedList<>(), - expiresIn, expireStarted, readReceiptCount, unidentified, reactions, hasMention); + expiresIn, expireStarted, readReceiptCount, reactions, hasMention, null); } 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 35818af2cd..fd58198d7e 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 @@ -27,24 +27,20 @@ import android.net.Uri; import android.text.SpannableString; import android.text.TextUtils; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; - -import org.session.libsession.messaging.utilities.UpdateMessageBuilder; -import org.session.libsession.messaging.utilities.UpdateMessageData; import com.squareup.phrase.Phrase; - +import kotlin.Pair; +import network.loki.messenger.R; +import org.session.libsession.messaging.utilities.UpdateMessageData; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate; +import org.thoughtcrime.securesms.database.model.content.MessageContent; import org.thoughtcrime.securesms.ui.UtilKt; -import kotlin.Pair; -import network.loki.messenger.R; - /** * The message record model which represents thread heading messages. * @@ -73,11 +69,11 @@ public class ThreadRecord extends DisplayRecord { public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri, @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 snippetType, int distributionType, boolean archived, long expiresIn, long lastSeen, int readReceiptCount, boolean pinned, String invitingAdminId, - @NonNull GroupThreadStatus groupThreadStatus) + @NonNull GroupThreadStatus groupThreadStatus, @Nullable MessageContent messageContent) { - super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount); + super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount, messageContent); this.snippetUri = snippetUri; this.lastMessage = lastMessage; this.count = count; @@ -147,7 +143,7 @@ else if (isGroupUpdateMessage()) { return Phrase.from(context, R.string.callsMissedCallFrom) .put(NAME_KEY, getName()) .format().toString(); - } else if (SmsDatabase.Types.isExpirationTimerUpdate(type)) { + } else if (getMessageContent() instanceof DisappearingMessageUpdate) { // Use the same message as we would for displaying on the conversation screen. // lastMessage shouldn't be null here, but we'll check just in case. if (lastMessage != null) { @@ -155,7 +151,8 @@ else if (isGroupUpdateMessage()) { } else { return ""; } - } else if (MmsSmsColumns.Types.isMediaSavedExtraction(type)) { + } + else if (MmsSmsColumns.Types.isMediaSavedExtraction(type)) { return Phrase.from(context, R.string.attachmentsMediaSaved) .put(NAME_KEY, getName()) .format().toString(); @@ -167,7 +164,7 @@ else if (isGroupUpdateMessage()) { } else if (MmsSmsColumns.Types.isMessageRequestResponse(type)) { try { - if (lastMessage.getRecipient().getAddress().serialize().equals( + if (lastMessage.getRecipient().getAddress().toString().equals( TextSecurePreferences.getLocalNumber(context))) { return UtilKt.getSubbedCharSequence( context, @@ -214,7 +211,7 @@ public CharSequence getNonControlMessageDisplayBody(@NonNull Context context) { prefix = context.getString(R.string.you); } else if(lastMessage != null){ - prefix = lastMessage.getIndividualRecipient().toShortString(); + prefix = lastMessage.getIndividualRecipient().getName(); } return Phrase.from(context.getString(R.string.messageSnippetGroup)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/content/DisappearingMessageUpdate.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/content/DisappearingMessageUpdate.kt new file mode 100644 index 0000000000..ab42a31ab2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/content/DisappearingMessageUpdate.kt @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.database.model.content + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsignal.protos.SignalServiceProtos +import org.thoughtcrime.securesms.util.ProtobufEnumSerializer + + +@Serializable +@SerialName(DisappearingMessageUpdate.TYPE_NAME) +data class DisappearingMessageUpdate( + @SerialName(KEY_EXPIRY_TIME_SECONDS) + val expiryTimeSeconds: Long, + + @SerialName(KEY_EXPIRY_TYPE) + @Serializable(with = ExpirationTypeSerializer::class) + val expiryType: SignalServiceProtos.Content.ExpirationType, +) : MessageContent { + val expiryMode: ExpiryMode + get() = when (expiryType) { + SignalServiceProtos.Content.ExpirationType.DELETE_AFTER_SEND -> ExpiryMode.AfterSend(expiryTimeSeconds) + SignalServiceProtos.Content.ExpirationType.DELETE_AFTER_READ -> ExpiryMode.AfterRead(expiryTimeSeconds) + else -> ExpiryMode.NONE + } + + constructor(mode: ExpiryMode) : this( + expiryTimeSeconds = mode.expirySeconds, + expiryType = when (mode) { + is ExpiryMode.AfterSend -> SignalServiceProtos.Content.ExpirationType.DELETE_AFTER_SEND + is ExpiryMode.AfterRead -> SignalServiceProtos.Content.ExpirationType.DELETE_AFTER_READ + ExpiryMode.NONE -> SignalServiceProtos.Content.ExpirationType.UNKNOWN + } + ) + + class ExpirationTypeSerializer : ProtobufEnumSerializer() { + override fun fromNumber(number: Int): SignalServiceProtos.Content.ExpirationType + = SignalServiceProtos.Content.ExpirationType.forNumber(number) ?: SignalServiceProtos.Content.ExpirationType.UNKNOWN + } + + companion object { + const val TYPE_NAME = "disappearing_message_update" + + const val KEY_EXPIRY_TIME_SECONDS = "expiry_time_seconds" + const val KEY_EXPIRY_TYPE = "expiry_type" + + // These constants map to SignalServiceProtos.Content.ExpirationType but given we want to use + // a constants it's impossible to use the enum directly. Luckily the values aren't supposed + // to change so we can safely use these constants. + const val EXPIRY_MODE_AFTER_SENT = 2 + const val EXPIRY_MODE_AFTER_READ = 1 + const val EXPIRY_MODE_NONE = 0 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/content/MessageContent.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/content/MessageContent.kt new file mode 100644 index 0000000000..595549ff8b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/content/MessageContent.kt @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.database.model.content + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass + + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +@JsonClassDiscriminator(MessageContent.DISCRIMINATOR) +sealed interface MessageContent { + companion object { + const val DISCRIMINATOR = "type" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/content/MessageContentModule.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/content/MessageContentModule.kt new file mode 100644 index 0000000000..bd8e3cdb76 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/content/MessageContentModule.kt @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.database.model.content + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass + +@Module +@InstallIn(SingletonComponent::class) +class MessageContentModule { + @Provides + @IntoSet + fun provideMessageContentSerializersModule(): SerializersModule { + return SerializersModule { + polymorphic(MessageContent::class) { + subclass(DisappearingMessageUpdate::class) + defaultDeserializer { + UnknownMessageContent.serializer() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/content/UnknownMessageContent.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/content/UnknownMessageContent.kt new file mode 100644 index 0000000000..cc8abeedcd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/content/UnknownMessageContent.kt @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.database.model.content + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UnknownMessageContent( + @SerialName(MessageContent.DISCRIMINATOR) + val type: String, +) : MessageContent \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt index 828b3c3a1e..d89c3b78c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt @@ -1,21 +1,16 @@ package org.thoughtcrime.securesms.debugmenu -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.Composable import dagger.hilt.android.AndroidEntryPoint -import org.thoughtcrime.securesms.ui.setComposeContent - +import org.thoughtcrime.securesms.FullComposeActivity @AndroidEntryPoint -class DebugActivity : AppCompatActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) +class DebugActivity : FullComposeActivity() { - setComposeContent { - DebugMenuScreen( - onClose = { finish() } - ) - } + @Composable + override fun ComposeContent() { + DebugMenuScreen( + onClose = { finish() } + ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt index f45c8ebbad..d985f8d6f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -1,10 +1,13 @@ package org.thoughtcrime.securesms.debugmenu +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer @@ -13,19 +16,24 @@ 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.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DatePickerState import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField import androidx.compose.material3.TimePicker import androidx.compose.material3.TimePickerState import androidx.compose.material3.rememberDatePickerState @@ -38,20 +46,44 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min import network.loki.messenger.BuildConfig import network.loki.messenger.R -import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.* import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ChangeEnvironment +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ClearTrustedDownloads +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.Copy07PrefixedBlindedPublicKey +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.CopyAccountId +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.HideDeprecationChangeDialog +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.HideEnvironmentWarningDialog +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.OverrideDeprecationState +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ScheduleTokenNotification +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ShowDeprecationChangeDialog +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ShowEnvironmentWarningDialog +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.GenerateContacts +import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.Cell -import org.thoughtcrime.securesms.ui.DialogButtonModel +import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.LoadingDialog import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.Button +import org.thoughtcrime.securesms.ui.components.ButtonType import org.thoughtcrime.securesms.ui.components.DropDown +import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField +import org.thoughtcrime.securesms.ui.components.SessionSwitch +import org.thoughtcrime.securesms.ui.components.SlimOutlineButton +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -91,6 +123,10 @@ fun DebugMenu( Scaffold( modifier = modifier.fillMaxSize(), + topBar = { + // App bar + BackAppBar(title = "Debug Menu", onBack = onClose) + }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) } @@ -110,12 +146,12 @@ fun DebugMenu( text = "This will restart the app...", showCloseButton = false, // don't display the 'x' button buttons = listOf( - DialogButtonModel( + DialogButtonData( text = GetString(R.string.cancel), onClick = { sendCommand(HideDeprecationChangeDialog) } ), - DialogButtonModel( - text = GetString(R.string.ok), + DialogButtonData( + text = GetString(android.R.string.ok), onClick = { sendCommand(OverrideDeprecationState) } ) ) @@ -129,12 +165,12 @@ fun DebugMenu( text = "Changing this setting will result in all conversations and Snode data being cleared...", showCloseButton = false, // don't display the 'x' button buttons = listOf( - DialogButtonModel( + DialogButtonData( text = GetString(R.string.cancel), onClick = { sendCommand(HideEnvironmentWarningDialog) } ), - DialogButtonModel( - text = GetString(R.string.ok), + DialogButtonData( + text = GetString(android.R.string.ok), onClick = { sendCommand(ChangeEnvironment) } ) ) @@ -147,115 +183,322 @@ fun DebugMenu( Column( modifier = Modifier - .padding(contentPadding) - .fillMaxSize() - .background(color = LocalColors.current.background) + .background(LocalColors.current.background) + .padding(horizontal = LocalDimensions.current.spacing) + .verticalScroll(rememberScrollState()) ) { - // App bar - BackAppBar(title = "Debug Menu", onBack = onClose) + Spacer(modifier = Modifier.height(contentPadding.calculateTopPadding())) - Column( - modifier = Modifier - .padding(horizontal = LocalDimensions.current.spacing) - .verticalScroll(rememberScrollState()) + // Info pane + val clipboardManager = LocalClipboardManager.current + val appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - ${ + BuildConfig.GIT_HASH.take( + 6 + ) + })" + + DebugCell( + modifier = Modifier.clickable { + // clicking the cell copies the version number to the clipboard + clipboardManager.setText(AnnotatedString(appVersion)) + }, + title = "App Info" ) { - // Info pane - val clipboardManager = LocalClipboardManager.current - val appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - ${ - BuildConfig.GIT_HASH.take( - 6 + Text( + text = "Version: $appVersion", + style = LocalType.current.base + ) + } + + // Environment + DebugCell("Environment") { + DropDown( + modifier = Modifier.fillMaxWidth(0.6f), + selectedText = uiState.currentEnvironment, + values = uiState.environments, + onValueSelected = { + sendCommand(ShowEnvironmentWarningDialog(it)) + } + ) + } + + if (uiState.dbInspectorState != DebugMenuViewModel.DatabaseInspectorState.NOT_AVAILABLE) { + DebugCell("Database inspector") { + Button( + onClick = { + sendCommand(DebugMenuViewModel.Commands.ToggleDatabaseInspector) + }, + text = if (uiState.dbInspectorState == DebugMenuViewModel.DatabaseInspectorState.STOPPED) + "Start" + else "Stop", + type = ButtonType.AccentFill, ) - })" - - DebugCell( - modifier = Modifier.clickable { - // clicking the cell copies the version number to the clipboard - clipboardManager.setText(AnnotatedString(appVersion)) - }, - title = "App Info" - ) { + } + } + + // Session Pro + DebugCell( + "Session Pro", + verticalArrangement = Arrangement.spacedBy(0.dp)) { + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + DebugSwitchRow( + text = "Set current user as Pro", + checked = uiState.forceCurrentUserAsPro, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.ForceCurrentUserAsPro(it)) + } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + DebugSwitchRow( + text = "Set all incoming messages as Pro", + checked = uiState.forceIncomingMessagesAsPro, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.ForceIncomingMessagesAsPro(it)) + } + ) + + AnimatedVisibility(uiState.forceIncomingMessagesAsPro) { + Column{ + DebugCheckboxRow( + text = "Message Feature: Pro Badge", + minHeight = 30.dp, + checked = uiState.messageProFeature.contains(ProStatusManager.MessageProFeature.ProBadge), + onCheckedChange = { + sendCommand( + DebugMenuViewModel.Commands.SetMessageProFeature( + ProStatusManager.MessageProFeature.ProBadge, it + ) + ) + } + ) + + DebugCheckboxRow( + text = "Message Feature: Long Message", + minHeight = 30.dp, + checked = uiState.messageProFeature.contains(ProStatusManager.MessageProFeature.LongMessage), + onCheckedChange = { + sendCommand( + DebugMenuViewModel.Commands.SetMessageProFeature( + ProStatusManager.MessageProFeature.LongMessage, it + ) + ) + } + ) + + DebugCheckboxRow( + text = "Message Feature: Animated Avatar", + minHeight = 30.dp, + checked = uiState.messageProFeature.contains(ProStatusManager.MessageProFeature.AnimatedAvatar), + onCheckedChange = { + sendCommand( + DebugMenuViewModel.Commands.SetMessageProFeature( + ProStatusManager.MessageProFeature.AnimatedAvatar, it + ) + ) + } + ) + } + + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + DebugSwitchRow( + text = "Set app as post Pro launch", + checked = uiState.forcePostPro, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.ForcePostPro(it)) + } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + DebugSwitchRow( + text = "Set other users as Pro", + checked = uiState.forceOtherUsersAsPro, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.ForceOtherUsersAsPro(it)) + } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + DebugSwitchRow( + text = "Force 30sec TTL avatar", + checked = uiState.forceShortTTl, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.ForceShortTTl(it)) + } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xsSpacing) + ){ + Image( + modifier = Modifier.size(LocalDimensions.current.iconXSmall), + painter = painterResource(id = R.drawable.ic_triangle_alert), + colorFilter = ColorFilter.tint(LocalColors.current.warning), + contentDescription = null, + ) + Text( - text = "Version: $appVersion", - style = LocalType.current.base + text = "For avatar animation or Pro badge changes based on the values modified above, please restart the app", + style = LocalType.current.base.copy(color = LocalColors.current.warning) ) } + } - // Environment - DebugCell("Environment") { - DropDown( - modifier = Modifier.fillMaxWidth(0.6f), - selectedText = uiState.currentEnvironment, - values = uiState.environments, - onValueSelected = { - sendCommand(ShowEnvironmentWarningDialog(it)) - } + // Fake contacts + DebugCell("Generate fake contacts") { + var prefix by remember { mutableStateOf("User-") } + var count by remember { mutableStateOf("2000") } + + DebugRow("Prefix") { + SessionOutlinedTextField( + text = prefix, + innerPadding = PaddingValues(LocalDimensions.current.smallSpacing), + onChange = { prefix = it }, + modifier = Modifier.weight(2f) ) } - // Flags - DebugCell("Flags") { - DebugSwitchRow( - text = "Hide Message Requests", - checked = uiState.hideMessageRequests, - onCheckedChange = { - sendCommand(DebugMenuViewModel.Commands.HideMessageRequest(it)) - } + DebugRow("Count") { + SessionOutlinedTextField( + text = count, + innerPadding = PaddingValues(LocalDimensions.current.smallSpacing), + onChange = { value -> count = value.filter { it.isDigit() } }, + modifier = Modifier.weight(2f), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) ) + } - DebugSwitchRow( - text = "Hide Note to Self", - checked = uiState.hideNoteToSelf, - onCheckedChange = { - sendCommand(DebugMenuViewModel.Commands.HideNoteToSelf(it)) - } + SlimOutlineButton(modifier = Modifier.fillMaxWidth(), text = "Generate") { + sendCommand( + GenerateContacts( + prefix = prefix, + count = count.toInt(), + ) ) } + } - // Group deprecation state - DebugCell("Legacy Group Deprecation Overrides") { - DropDown( - selectedText = uiState.forceDeprecationState.displayName, - values = uiState.availableDeprecationState.map { it.displayName }, - ) { selected -> - val override = LegacyGroupDeprecationManager.DeprecationState.entries - .firstOrNull { it.displayName == selected } + // Session Token + DebugCell("Session Token") { + // Schedule a test token-drop notification for 10 seconds from now + SlimOutlineButton( + modifier = Modifier.fillMaxWidth(), + text = "Schedule Token Page Notification (10s)", + onClick = { sendCommand(ScheduleTokenNotification) } + ) + } - sendCommand(ShowDeprecationChangeDialog(override)) - } + // Keys + DebugCell("User Details") { - DebugRow(title = "Deprecating start date", modifier = Modifier.clickable { - datePickerState.applyFromZonedDateTime(uiState.deprecatingStartTime) - timePickerState.applyFromZonedDateTime(uiState.deprecatingStartTime) - showingDeprecatingStartDatePicker = true - }) { - Text(text = uiState.deprecatingStartTime.withZoneSameInstant(ZoneId.systemDefault()).toLocalDate().toString()) + SlimOutlineButton ( + text = "Copy Account ID", + modifier = Modifier.fillMaxWidth(), + onClick = { + sendCommand(CopyAccountId) } + ) - DebugRow(title = "Deprecating start time", modifier = Modifier.clickable { - datePickerState.applyFromZonedDateTime(uiState.deprecatingStartTime) - timePickerState.applyFromZonedDateTime(uiState.deprecatingStartTime) - showingDeprecatingStartTimePicker = true - }) { - Text(text = uiState.deprecatingStartTime.withZoneSameInstant(ZoneId.systemDefault()).toLocalTime().toString()) + SlimOutlineButton( + text = "Copy 07-prefixed Version Blinded Public Key", + modifier = Modifier.fillMaxWidth(), + onClick = { + sendCommand(Copy07PrefixedBlindedPublicKey) } + ) + } - DebugRow(title = "Deprecated date", modifier = Modifier.clickable { - datePickerState.applyFromZonedDateTime(uiState.deprecatedTime) - timePickerState.applyFromZonedDateTime(uiState.deprecatedTime) - showingDeprecatedDatePicker = true - }) { - Text(text = uiState.deprecatedTime.withZoneSameInstant(ZoneId.systemDefault()).toLocalDate().toString()) + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + // Flags + DebugCell("Flags") { + DebugSwitchRow( + text = "Hide Message Requests", + checked = uiState.hideMessageRequests, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.HideMessageRequest(it)) } + ) - DebugRow(title = "Deprecated time", modifier = Modifier.clickable { - datePickerState.applyFromZonedDateTime(uiState.deprecatedTime) - timePickerState.applyFromZonedDateTime(uiState.deprecatedTime) - showingDeprecatedTimePicker = true - }) { - Text(text = uiState.deprecatedTime.withZoneSameInstant(ZoneId.systemDefault()).toLocalTime().toString()) + DebugSwitchRow( + text = "Hide Note to Self", + checked = uiState.hideNoteToSelf, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.HideNoteToSelf(it)) } + ) + + SlimOutlineButton( + modifier = Modifier.fillMaxWidth(), + text = "Clear All Trusted Downloads", + ) { + sendCommand(ClearTrustedDownloads) + } + } + + // Group deprecation state + DebugCell("Legacy Group Deprecation Overrides") { + DropDown( + selectedText = uiState.forceDeprecationState.displayName, + values = uiState.availableDeprecationState.map { it.displayName }, + ) { selected -> + val override = LegacyGroupDeprecationManager.DeprecationState.entries + .firstOrNull { it.displayName == selected } + + sendCommand(ShowDeprecationChangeDialog(override)) + } + + DebugRow(title = "Deprecating start date", modifier = Modifier.clickable { + datePickerState.applyFromZonedDateTime(uiState.deprecatingStartTime) + timePickerState.applyFromZonedDateTime(uiState.deprecatingStartTime) + showingDeprecatingStartDatePicker = true + }) { + Text( + text = uiState.deprecatingStartTime.withZoneSameInstant(ZoneId.systemDefault()) + .toLocalDate().toString() + ) + } + + DebugRow(title = "Deprecating start time", modifier = Modifier.clickable { + datePickerState.applyFromZonedDateTime(uiState.deprecatingStartTime) + timePickerState.applyFromZonedDateTime(uiState.deprecatingStartTime) + showingDeprecatingStartTimePicker = true + }) { + Text( + text = uiState.deprecatingStartTime.withZoneSameInstant(ZoneId.systemDefault()) + .toLocalTime().toString() + ) + } + + DebugRow(title = "Deprecated date", modifier = Modifier.clickable { + datePickerState.applyFromZonedDateTime(uiState.deprecatedTime) + timePickerState.applyFromZonedDateTime(uiState.deprecatedTime) + showingDeprecatedDatePicker = true + }) { + Text( + text = uiState.deprecatedTime.withZoneSameInstant(ZoneId.systemDefault()) + .toLocalDate().toString() + ) + } + + DebugRow(title = "Deprecated time", modifier = Modifier.clickable { + datePickerState.applyFromZonedDateTime(uiState.deprecatedTime) + timePickerState.applyFromZonedDateTime(uiState.deprecatedTime) + showingDeprecatedTimePicker = true + }) { + Text( + text = uiState.deprecatedTime.withZoneSameInstant(ZoneId.systemDefault()) + .toLocalTime().toString() + ) } } + + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + Spacer(modifier = Modifier.height(contentPadding.calculateBottomPadding())) } // Deprecation date picker @@ -268,10 +511,18 @@ fun DebugMenu( confirmButton = { TextButton(onClick = { if (showingDeprecatedDatePicker) { - sendCommand(DebugMenuViewModel.Commands.OverrideDeprecatedTime(getPickedTime())) + sendCommand( + DebugMenuViewModel.Commands.OverrideDeprecatedTime( + getPickedTime() + ) + ) showingDeprecatedDatePicker = false } else { - sendCommand(DebugMenuViewModel.Commands.OverrideDeprecatingStartTime(getPickedTime())) + sendCommand( + DebugMenuViewModel.Commands.OverrideDeprecatingStartTime( + getPickedTime() + ) + ) showingDeprecatingStartDatePicker = false } }) { @@ -291,21 +542,29 @@ fun DebugMenu( }, title = "Set Time", buttons = listOf( - DialogButtonModel( + DialogButtonData( text = GetString(R.string.cancel), onClick = { showingDeprecatedTimePicker = false showingDeprecatingStartTimePicker = false } ), - DialogButtonModel( - text = GetString(R.string.ok), + DialogButtonData( + text = GetString(android.R.string.ok), onClick = { if (showingDeprecatedTimePicker) { - sendCommand(DebugMenuViewModel.Commands.OverrideDeprecatedTime(getPickedTime())) + sendCommand( + DebugMenuViewModel.Commands.OverrideDeprecatedTime( + getPickedTime() + ) + ) showingDeprecatedTimePicker = false } else { - sendCommand(DebugMenuViewModel.Commands.OverrideDeprecatingStartTime(getPickedTime())) + sendCommand( + DebugMenuViewModel.Commands.OverrideDeprecatingStartTime( + getPickedTime() + ) + ) showingDeprecatingStartTimePicker = false } } @@ -331,20 +590,23 @@ private fun TimePickerState.applyFromZonedDateTime(time: ZonedDateTime) { } -private val LegacyGroupDeprecationManager.DeprecationState?.displayName: String get() { - return this?.name ?: "No state override" -} +private val LegacyGroupDeprecationManager.DeprecationState?.displayName: String + get() { + return this?.name ?: "No state override" + } @Composable private fun DebugRow( title: String, modifier: Modifier = Modifier, + minHeight: Dp = LocalDimensions.current.minItemButtonHeight, content: @Composable RowScope.() -> Unit ) { Row( - modifier = modifier.heightIn(min = LocalDimensions.current.minItemButtonHeight), - verticalAlignment = Alignment.CenterVertically - ){ + modifier = modifier.heightIn(min = minHeight), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xsSpacing) + ) { Text( text = title, style = LocalType.current.base, @@ -361,13 +623,13 @@ fun DebugSwitchRow( checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier -){ +) { DebugRow( title = text, modifier = modifier .fillMaxWidth() .clickable { onCheckedChange(!checked) }, - ){ + ) { SessionSwitch( checked = checked, onCheckedChange = onCheckedChange @@ -376,29 +638,39 @@ fun DebugSwitchRow( } -// todo Get proper styling that works well with ax on all themes and then move this composable in the components file @Composable -fun SessionSwitch( +fun DebugCheckboxRow( + text: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier -){ - Switch( - checked = checked, - onCheckedChange = onCheckedChange, - colors = SwitchDefaults.colors( - checkedThumbColor = LocalColors.current.primary, - checkedTrackColor = LocalColors.current.background, - uncheckedThumbColor = LocalColors.current.text, - uncheckedTrackColor = LocalColors.current.background, + modifier: Modifier = Modifier, + minHeight: Dp = LocalDimensions.current.minItemButtonHeight, +) { + DebugRow( + title = text, + minHeight = minHeight, + modifier = modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) }, + ) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + colors = CheckboxDefaults.colors( + checkedColor = LocalColors.current.accent, + uncheckedColor = LocalColors.current.disabled, + checkmarkColor = LocalColors.current.background + ) ) - ) + } + } @Composable fun ColumnScope.DebugCell( title: String, modifier: Modifier = Modifier, + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(LocalDimensions.current.xsSpacing), content: @Composable ColumnScope.() -> Unit ) { Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) @@ -408,7 +680,7 @@ fun ColumnScope.DebugCell( ) { Column( modifier = Modifier.padding(LocalDimensions.current.spacing), - verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xsSpacing) + verticalArrangement = verticalArrangement ) { Text( text = title, @@ -437,7 +709,14 @@ fun PreviewDebugMenu() { forceDeprecationState = null, deprecatedTime = ZonedDateTime.now(), availableDeprecationState = emptyList(), - deprecatingStartTime = ZonedDateTime.now() + deprecatingStartTime = ZonedDateTime.now(), + forceCurrentUserAsPro = false, + forceIncomingMessagesAsPro = true, + forceOtherUsersAsPro = false, + forcePostPro = false, + forceShortTTl = false, + messageProFeature = setOf(ProStatusManager.MessageProFeature.AnimatedAvatar), + dbInspectorState = DebugMenuViewModel.DatabaseInspectorState.STARTED, ), sendCommand = {}, onClose = {} diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt index 0f5484dd74..d2efeaff41 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -1,32 +1,60 @@ package org.thoughtcrime.securesms.debugmenu -import android.app.Application +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.file_server.FileServerApi +import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState import org.session.libsession.utilities.Environment import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.upsertContact import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.ApplicationContext +import org.session.libsignal.utilities.hexEncodedPublicKey +import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.thoughtcrime.securesms.database.AttachmentDatabase +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.tokenpage.TokenPageNotificationManager import org.thoughtcrime.securesms.util.ClearDataUtils import java.time.ZonedDateTime import javax.inject.Inject + @HiltViewModel class DebugMenuViewModel @Inject constructor( - private val application: Application, + @param:ApplicationContext private val context: Context, private val textSecurePreferences: TextSecurePreferences, + private val tokenPageNotificationManager: TokenPageNotificationManager, private val configFactory: ConfigFactory, + private val storage: StorageProtocol, private val deprecationManager: LegacyGroupDeprecationManager, - private val clearDataUtils: ClearDataUtils + private val clearDataUtils: ClearDataUtils, + private val threadDb: ThreadDatabase, + private val recipientDatabase: RecipientDatabase, + private val attachmentDatabase: AttachmentDatabase, + private val databaseInspector: DatabaseInspector, ) : ViewModel() { private val TAG = "DebugMenu" @@ -44,15 +72,39 @@ class DebugMenuViewModel @Inject constructor( availableDeprecationState = listOf(null) + LegacyGroupDeprecationManager.DeprecationState.entries.toList(), deprecatedTime = deprecationManager.deprecatedTime.value, deprecatingStartTime = deprecationManager.deprecatingStartTime.value, + forceCurrentUserAsPro = textSecurePreferences.forceCurrentUserAsPro(), + forceOtherUsersAsPro = textSecurePreferences.forceOtherUsersAsPro(), + forceIncomingMessagesAsPro = textSecurePreferences.forceIncomingMessagesAsPro(), + forcePostPro = textSecurePreferences.forcePostPro(), + forceShortTTl = textSecurePreferences.forcedShortTTL(), + messageProFeature = textSecurePreferences.getDebugMessageFeatures(), + dbInspectorState = DatabaseInspectorState.NOT_AVAILABLE, ) ) val uiState: StateFlow get() = _uiState + init { + if (databaseInspector.available) { + viewModelScope.launch { + databaseInspector.enabled.collectLatest { started -> + _uiState.update { currentState -> + currentState.copy( + dbInspectorState = if (started) DatabaseInspectorState.STARTED else DatabaseInspectorState.STOPPED + ) + } + } + } + } + } + private var temporaryEnv: Environment? = null + private val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + private var temporaryDeprecatedState: LegacyGroupDeprecationManager.DeprecationState? = null + @OptIn(ExperimentalStdlibApi::class) fun onCommand(command: Commands) { when (command) { is Commands.ChangeEnvironment -> changeEnvironment() @@ -63,6 +115,41 @@ class DebugMenuViewModel @Inject constructor( is Commands.ShowEnvironmentWarningDialog -> showEnvironmentWarningDialog(command.environment) + is Commands.ScheduleTokenNotification -> { + tokenPageNotificationManager.scheduleTokenPageNotification( true) + Toast.makeText(context, "Scheduled a notification for 10s from now", Toast.LENGTH_LONG).show() + } + + is Commands.Copy07PrefixedBlindedPublicKey -> { + val secretKey = storage.getUserED25519KeyPair()?.secretKey?.data + ?: throw (FileServerApi.Error.NoEd25519KeyPair) + val userBlindedKeys = BlindKeyAPI.blindVersionKeyPair(secretKey) + + val clip = ClipData.newPlainText("07-prefixed Version Blinded Public Key", + "07" + userBlindedKeys.pubKey.data.toHexString()) + clipboardManager.setPrimaryClip(ClipData(clip)) + + // Show a toast if the version is below Android 13 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText(context, "Copied key to clipboard", Toast.LENGTH_SHORT).show() + } + } + + is Commands.CopyAccountId -> { + val accountId = textSecurePreferences.getLocalNumber() + val clip = ClipData.newPlainText("Account ID", accountId) + clipboardManager.setPrimaryClip(ClipData(clip)) + + // Show a toast if the version is below Android 13 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText( + context, + "Copied account ID to clipboard", + Toast.LENGTH_SHORT + ).show() + } + } + is Commands.HideMessageRequest -> { textSecurePreferences.setHasHiddenMessageRequests(command.hide) _uiState.value = _uiState.value.copy(hideMessageRequests = command.hide) @@ -107,6 +194,90 @@ class DebugMenuViewModel @Inject constructor( is Commands.ShowDeprecationChangeDialog -> showDeprecatedStateWarningDialog(command.state) + + is Commands.ClearTrustedDownloads -> { + clearTrustedDownloads() + } + + is Commands.GenerateContacts -> { + viewModelScope.launch { + _uiState.update { it.copy(showLoadingDialog = true) } + + withContext(Dispatchers.Default) { + val keys = List(command.count) { + KeyPairUtilities.generate() + } + + configFactory.withMutableUserConfigs { configs -> + for ((index, key) in keys.withIndex()) { + configs.contacts.upsertContact( + accountId = key.x25519KeyPair.hexEncodedPublicKey + ) { + name = "${command.prefix}$index" + approved = true + approvedMe = true + } + } + } + } + + _uiState.update { it.copy(showLoadingDialog = false) } + } + } + + is Commands.ForceCurrentUserAsPro -> { + textSecurePreferences.setForceCurrentUserAsPro(command.set) + _uiState.update { + it.copy(forceCurrentUserAsPro = command.set) + } + } + + is Commands.ForceOtherUsersAsPro -> { + textSecurePreferences.setForceOtherUsersAsPro(command.set) + _uiState.update { + it.copy(forceOtherUsersAsPro = command.set) + } + } + + is Commands.ForceIncomingMessagesAsPro -> { + textSecurePreferences.setForceIncomingMessagesAsPro(command.set) + _uiState.update { + it.copy(forceIncomingMessagesAsPro = command.set) + } + } + + is Commands.ForcePostPro -> { + textSecurePreferences.setForcePostPro(command.set) + _uiState.update { + it.copy(forcePostPro = command.set) + } + } + + is Commands.ForceShortTTl -> { + textSecurePreferences.setForcedShortTTL(command.set) + _uiState.update { + it.copy(forceShortTTl = command.set) + } + } + + is Commands.SetMessageProFeature -> { + val features = _uiState.value.messageProFeature.toMutableSet() + if(command.set) features.add(command.feature) else features.remove(command.feature) + textSecurePreferences.setDebugMessageFeatures(features) + _uiState.update { + it.copy(messageProFeature = features) + } + } + + Commands.ToggleDatabaseInspector -> { + if (databaseInspector.available) { + if (databaseInspector.enabled.value) { + databaseInspector.stop() + } else { + databaseInspector.start() + } + } + } } } @@ -157,6 +328,38 @@ class DebugMenuViewModel @Inject constructor( _uiState.value = _uiState.value.copy(showDeprecatedStateWarningDialog = true) } + private fun clearTrustedDownloads() { + // show a loading state + _uiState.value = _uiState.value.copy( + showEnvironmentWarningDialog = false, + showLoadingDialog = true + ) + + // clear trusted downloads for all recipients + viewModelScope.launch { + val conversations: List = threadDb.approvedConversationList.use { openCursor -> + threadDb.readerFor(openCursor).run { generateSequence { next }.toList() } + } + + conversations.filter { !it.recipient.isLocalNumber }.forEach { + recipientDatabase.setAutoDownloadAttachments(it.recipient, false) + } + + // set all attachments back to pending + attachmentDatabase.allAttachments.forEach { + attachmentDatabase.setTransferState(it.mmsId, it.attachmentId, AttachmentState.PENDING.value) + } + + Toast.makeText(context, "Cleared!", Toast.LENGTH_LONG).show() + + // hide loading + _uiState.value = _uiState.value.copy( + showEnvironmentWarningDialog = false, + showLoadingDialog = false + ) + } + } + data class UIState( val currentEnvironment: String, val environments: List, @@ -166,22 +369,47 @@ class DebugMenuViewModel @Inject constructor( val showDeprecatedStateWarningDialog: Boolean, val hideMessageRequests: Boolean, val hideNoteToSelf: Boolean, + val forceCurrentUserAsPro: Boolean, + val forceOtherUsersAsPro: Boolean, + val forceIncomingMessagesAsPro: Boolean, + val messageProFeature: Set, + val forcePostPro: Boolean, + val forceShortTTl: Boolean, val forceDeprecationState: LegacyGroupDeprecationManager.DeprecationState?, val availableDeprecationState: List, val deprecatedTime: ZonedDateTime, val deprecatingStartTime: ZonedDateTime, + val dbInspectorState: DatabaseInspectorState, ) + enum class DatabaseInspectorState { + NOT_AVAILABLE, + STARTED, + STOPPED, + } + sealed class Commands { object ChangeEnvironment : Commands() data class ShowEnvironmentWarningDialog(val environment: String) : Commands() object HideEnvironmentWarningDialog : Commands() + object ScheduleTokenNotification : Commands() + object Copy07PrefixedBlindedPublicKey : Commands() + object CopyAccountId : Commands() data class HideMessageRequest(val hide: Boolean) : Commands() data class HideNoteToSelf(val hide: Boolean) : Commands() + data class ForceCurrentUserAsPro(val set: Boolean) : Commands() + data class ForceOtherUsersAsPro(val set: Boolean) : Commands() + data class ForceIncomingMessagesAsPro(val set: Boolean) : Commands() + data class ForcePostPro(val set: Boolean) : Commands() + data class ForceShortTTl(val set: Boolean) : Commands() + data class SetMessageProFeature(val feature: ProStatusManager.MessageProFeature, val set: Boolean) : Commands() data class ShowDeprecationChangeDialog(val state: LegacyGroupDeprecationManager.DeprecationState?) : Commands() object HideDeprecationChangeDialog : Commands() object OverrideDeprecationState : Commands() data class OverrideDeprecatedTime(val time: ZonedDateTime) : Commands() data class OverrideDeprecatingStartTime(val time: ZonedDateTime) : Commands() + object ClearTrustedDownloads: Commands() + data class GenerateContacts(val prefix: String, val count: Int): Commands() + data object ToggleDatabaseInspector : Commands() } } \ No newline at end of file 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 bfe21c0304..ff12d6f112 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt @@ -8,28 +8,46 @@ import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.GlobalScope +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.plus import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier import org.session.libsession.utilities.AppTextSecurePreferences import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.database.model.content.MessageContent import org.thoughtcrime.securesms.groups.GroupManagerV2Impl -import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.DefaultConversationRepository import org.thoughtcrime.securesms.sskenvironment.ProfileManager +import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository +import org.thoughtcrime.securesms.tokenpage.TokenRepository +import org.thoughtcrime.securesms.tokenpage.TokenRepositoryImpl import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) class AppModule { - @Provides @Singleton - fun provideMessageNotifier(): MessageNotifier { - return OptimizedMessageNotifier(DefaultMessageNotifier()) + fun provideJson(modules: Set<@JvmSuppressWildcards SerializersModule>): Json { + return Json { + ignoreUnknownKeys = true + serializersModule += SerializersModule { + modules.forEach { include(it) } + } + } + } + + @Provides + @ManagerScope + fun provideGlobalCoroutineScope(): CoroutineScope { + return GlobalScope } } @@ -43,6 +61,9 @@ abstract class AppBindings { @Binds abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository + @Binds + abstract fun bindTokenRepository(repository: TokenRepositoryImpl): TokenRepository + @Binds abstract fun bindGroupManager(groupManager: GroupManagerV2Impl): GroupManagerV2 @@ -52,6 +73,12 @@ abstract class AppBindings { @Binds abstract fun bindConfigFactory(configFactory: ConfigFactory): ConfigFactoryProtocol + @Binds + abstract fun bindMessageNotifier(notifier: OptimizedMessageNotifier): MessageNotifier + + @Binds + abstract fun bindTypingIndicators(typingIndicators: TypingStatusRepository): SSKEnvironment.TypingIndicatorsProtocol + } @Module diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt index cd143d488f..74869ed1e6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -4,12 +4,12 @@ import android.content.Context import dagger.Lazy import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch 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.Curve25519 import network.loki.messenger.libsession_util.GroupInfoConfig import network.loki.messenger.libsession_util.GroupKeysConfig import network.loki.messenger.libsession_util.GroupMembersConfig @@ -20,14 +20,15 @@ import network.loki.messenger.libsession_util.MutableUserProfile 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.Bytes import network.loki.messenger.libsession_util.util.ConfigPush 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.Sodium +import network.loki.messenger.libsession_util.util.MultiEncrypt import network.loki.messenger.libsession_util.util.UserPic +import okio.ByteString.Companion.decodeBase64 import org.session.libsession.database.StorageProtocol -import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.snode.SnodeClock import org.session.libsession.snode.SwarmAuth @@ -43,6 +44,7 @@ import org.session.libsession.utilities.MutableUserConfigs import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.UserConfigs +import org.session.libsession.utilities.UsernameUtils import org.session.libsession.utilities.getGroup import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.utilities.AccountId @@ -72,6 +74,8 @@ class ConfigFactory @Inject constructor( private val textSecurePreferences: TextSecurePreferences, private val clock: SnodeClock, private val configToDatabaseSync: Lazy, + private val usernameUtils: Lazy, + @ManagerScope private val coroutineScope: CoroutineScope ) : ConfigFactoryProtocol { companion object { // This is a buffer period within which we will process messages which would result in a @@ -79,6 +83,9 @@ class ConfigFactory @Inject constructor( // before `lastConfigMessage.timestamp - configChangeBufferPeriod` will not actually have // it's changes applied (control text will still be added though) private const val CONFIG_CHANGE_BUFFER_PERIOD: Long = 2 * 60 * 1000L + + const val MAX_NAME_BYTES = 100 // max size in bytes for names + const val MAX_GROUP_DESCRIPTION_BYTES = 600 // max size in bytes for group descriptions } init { @@ -88,8 +95,6 @@ class ConfigFactory @Inject constructor( private val userConfigs = HashMap>() private val groupConfigs = HashMap>() - private val coroutineScope: CoroutineScope = GlobalScope - private val _configUpdateNotifications = MutableSharedFlow() override val configUpdateNotifications get() = _configUpdateNotifications @@ -99,38 +104,67 @@ class ConfigFactory @Inject constructor( }) private fun requiresCurrentUserED25519SecKey(): ByteArray = - requireNotNull(storage.get().getUserED25519KeyPair()?.secretKey?.asBytes) { + requireNotNull(storage.get().getUserED25519KeyPair()?.secretKey?.data) { "No logged in user" } private fun ensureUserConfigsInitialized(): Pair { val userAccountId = requiresCurrentUserAccountId() - return synchronized(userConfigs) { - userConfigs.getOrPut(userAccountId) { - ReentrantReadWriteLock() to UserConfigsImpl( - userEd25519SecKey = requiresCurrentUserED25519SecKey(), - userAccountId = userAccountId, - threadDb = threadDb, - configDatabase = configDatabase, - storage = storage.get() - ) + // Fast check and return if already initialized + synchronized(userConfigs) { + val instance = userConfigs[userAccountId] + if (instance != null) { + return instance } } + + // Once we reach here, we are going to create the config instance, but since we are + // not in the lock, there's a potential we could have created a duplicate instance. But it + // is not a problem in itself as we are going to take the lock and check + // again if another one already exists before setting it to use. + // This is to avoid having to do database operation inside the lock + val instance = ReentrantReadWriteLock() to UserConfigsImpl( + userEd25519SecKey = requiresCurrentUserED25519SecKey(), + userAccountId = userAccountId, + threadDb = threadDb, + configDatabase = configDatabase, + storage = storage.get(), + textSecurePreferences = textSecurePreferences, + usernameUtils = usernameUtils.get() + ) + + return synchronized(userConfigs) { + userConfigs.getOrPut(userAccountId) { instance } + } } private fun ensureGroupConfigsInitialized(groupId: AccountId): Pair { val groupAdminKey = getGroup(groupId)?.adminKey - return synchronized(groupConfigs) { - groupConfigs.getOrPut(groupId) { - ReentrantReadWriteLock() to GroupConfigsImpl( - userEd25519SecKey = requiresCurrentUserED25519SecKey(), - groupAccountId = groupId, - groupAdminKey = groupAdminKey, - configDatabase = configDatabase - ) + + // Fast check and return if already initialized + synchronized(groupConfigs) { + val instance = groupConfigs[groupId] + if (instance != null) { + return instance } } + + // Once we reach here, we are going to create the config instance, but since we are + // not in the lock, there's a potential we could have created a duplicate instance. But it + // is not a problem in itself as we are going to take the lock and check + // again if another one already exists before setting it to use. + // This is to avoid having to do database operation inside the lock + val instance = ReentrantReadWriteLock() to GroupConfigsImpl( + userEd25519SecKey = requiresCurrentUserED25519SecKey(), + groupAccountId = groupId, + groupAdminKey = groupAdminKey?.data, + configDatabase = configDatabase + ) + + return synchronized(groupConfigs) { + groupConfigs.getOrPut(groupId) { instance } + } } override fun withUserConfigs(cb: (UserConfigs) -> T): T { @@ -256,6 +290,7 @@ class ConfigFactory @Inject constructor( private fun doWithMutableGroupConfigs( groupId: AccountId, + fromMerge: Boolean, cb: (GroupConfigsImpl) -> Pair): T { val (lock, configs) = ensureGroupConfigsInitialized(groupId) val (result, changed) = lock.write { @@ -266,7 +301,7 @@ class ConfigFactory @Inject constructor( coroutineScope.launch { // Config change notifications are important so we must use suspend version of // emit (not tryEmit) - _configUpdateNotifications.emit(ConfigUpdateNotification.GroupConfigsUpdated(groupId)) + _configUpdateNotifications.emit(ConfigUpdateNotification.GroupConfigsUpdated(groupId, fromMerge = fromMerge)) } } @@ -277,11 +312,19 @@ class ConfigFactory @Inject constructor( groupId: AccountId, cb: (MutableGroupConfigs) -> T ): T { - return doWithMutableGroupConfigs(groupId = groupId) { + return doWithMutableGroupConfigs(groupId = groupId, fromMerge = false) { cb(it) to it.dumpIfNeeded(clock) } } + override fun removeContact(accountId: String) { + if(!accountId.startsWith(IdPrefix.STANDARD.value)) return + + withMutableUserConfigs { + it.contacts.erase(accountId) + } + } + override fun removeGroup(groupId: AccountId) { withMutableUserConfigs { it.userGroups.eraseClosedGroup(groupId.hexString) @@ -304,13 +347,13 @@ class ConfigFactory @Inject constructor( domain: String, closedGroupSessionId: AccountId ): ByteArray? { - return Sodium.decryptForMultipleSimple( + return MultiEncrypt.decryptForMultipleSimple( encoded = encoded, - ed25519SecretKey = requireNotNull(storage.get().getUserED25519KeyPair()?.secretKey?.asBytes) { + ed25519SecretKey = requireNotNull(storage.get().getUserED25519KeyPair()?.secretKey?.data) { "No logged in user" }, domain = domain, - senderPubKey = Sodium.ed25519PkToCurve25519(closedGroupSessionId.pubKeyBytes) + senderPubKey = Curve25519.pubKeyFromED25519(closedGroupSessionId.pubKeyBytes) ) } @@ -320,7 +363,7 @@ class ConfigFactory @Inject constructor( info: List, members: List ) { - val changed = doWithMutableGroupConfigs(groupId) { configs -> + val changed = doWithMutableGroupConfigs(groupId, fromMerge = true) { configs -> // Keys must be loaded first as they are used to decrypt the other config messages val keysLoaded = keys.fold(false) { acc, msg -> configs.groupKeys.loadKey(msg.data, msg.hash, msg.timestamp, configs.groupInfo.pointer, configs.groupMembers.pointer) || acc @@ -367,7 +410,7 @@ class ConfigFactory @Inject constructor( ) ) .filter { (push, _) -> push != null } - .onEach { (push, config) -> config.second.confirmPushed(push!!.first.seqNo, push.second.hash) } + .onEach { (push, config) -> config.second.confirmPushed(push!!.first.seqNo, push.second.hashes.toTypedArray()) } .map { (push, config) -> Triple(config.first.configVariant, config.second.dump(), push!!.second.timestamp) }.toList() to emptyList() @@ -390,13 +433,21 @@ class ConfigFactory @Inject constructor( return } - doWithMutableGroupConfigs(groupId) { configs -> - members?.let { (push, result) -> configs.groupMembers.confirmPushed(push.seqNo, result.hash) } - info?.let { (push, result) -> configs.groupInfo.confirmPushed(push.seqNo, result.hash) } - keysPush?.let { (hash, timestamp) -> + doWithMutableGroupConfigs(groupId, fromMerge = false) { configs -> + members?.let { (push, result) -> configs.groupMembers.confirmPushed(push.seqNo, result.hashes.toTypedArray()) } + info?.let { (push, result) -> configs.groupInfo.confirmPushed(push.seqNo, result.hashes.toTypedArray()) } + keysPush?.let { (hashes, timestamp) -> val pendingConfig = configs.groupKeys.pendingConfig() if (pendingConfig != null) { - configs.groupKeys.loadKey(pendingConfig, hash, timestamp, configs.groupInfo.pointer, configs.groupMembers.pointer) + for (hash in hashes) { + configs.groupKeys.loadKey( + pendingConfig, + hash, + timestamp, + configs.groupInfo.pointer, + configs.groupMembers.pointer + ) + } } } @@ -464,9 +515,9 @@ class ConfigFactory @Inject constructor( val group = getGroup(groupId) ?: return null return if (group.adminKey != null) { - OwnedSwarmAuth.ofClosedGroup(groupId, group.adminKey!!) + OwnedSwarmAuth.ofClosedGroup(groupId, group.adminKey!!.data) } else if (group.authData != null) { - GroupSubAccountSwarmAuth(groupId, this, group.authData!!) + GroupSubAccountSwarmAuth(groupId, this, group.authData!!.data) } else { null } @@ -544,20 +595,20 @@ private fun MutableUserGroupsConfig.initFrom(storage: StorageProtocol) { .asSequence().filter { it.isLegacyGroup && it.isActive && it.members.size > 1 } .mapNotNull { group -> val groupAddress = Address.fromSerialized(group.encodedId) - val groupPublicKey = GroupUtil.doubleDecodeGroupID(groupAddress.serialize()).toHexString() + val groupPublicKey = GroupUtil.doubleDecodeGroupID(groupAddress.toString()).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.associate { it.serialize() to true } - val members = group.members.filterNot { it.serialize() !in admins.keys }.associate { it.serialize() to false } + val admins = group.admins.associate { it.toString() to true } + val members = group.members.filterNot { it.toString() !in admins.keys }.associate { it.toString() to false } GroupInfo.LegacyGroupInfo( accountId = 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(), + encPubKey = Bytes((encryptionKeyPair.publicKey as DjbECPublicKey).publicKey), // 'serialize()' inserts an extra byte + encSecKey = Bytes(encryptionKeyPair.privateKey.serialize()), disappearingTimer = recipient.expireMessages.toLong(), joinedAtSecs = (group.formationTimestamp / 1000L) ) @@ -580,17 +631,17 @@ private fun MutableConversationVolatileConfig.initFrom(storage: StorageProtocol, recipient.isGroupV2Recipient -> { // It's probably safe to assume there will never be a case where new closed groups will ever be there before a dump is created... // but just in case... - getOrConstructClosedGroup(recipient.address.serialize()) + getOrConstructClosedGroup(recipient.address.toString()) } recipient.isLegacyGroupRecipient -> { - val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize()) + val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.toString()) getOrConstructLegacyGroup(groupPublicKey) } recipient.isContactRecipient -> { if (recipient.isLocalNumber) null // this is handled by the user profile NTS data else if (recipient.isCommunityInboxRecipient) null // specifically exclude - else if (!recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) null - else getOrConstructOneToOne(recipient.address.serialize()) + else if (!recipient.address.toString().startsWith(IdPrefix.STANDARD.value)) null + else getOrConstructOneToOne(recipient.address.toString()) } else -> null } @@ -606,13 +657,16 @@ private fun MutableConversationVolatileConfig.initFrom(storage: StorageProtocol, } } -private fun MutableUserProfile.initFrom(storage: StorageProtocol) { +private fun MutableUserProfile.initFrom(storage: StorageProtocol, + usernameUtils: UsernameUtils, + textSecurePreferences: TextSecurePreferences +) { val ownPublicKey = storage.getUserPublicKey() ?: return - val config = ConfigurationMessage.getCurrent(listOf()) ?: return - setName(config.displayName) - val picUrl = config.profilePicture - val picKey = config.profileKey - if (!picUrl.isNullOrEmpty() && picKey.isNotEmpty()) { + val displayName = usernameUtils.getCurrentUsername() ?: return + val picUrl = textSecurePreferences.getProfilePictureURL() + val picKey = textSecurePreferences.getProfileKey()?.decodeBase64()?.toByteArray() + setName(displayName) + if (!picUrl.isNullOrEmpty() && picKey != null && picKey.isNotEmpty()) { setPic(UserPic(picUrl, picKey)) } val ownThreadId = storage.getThreadId(Address.fromSerialized(ownPublicKey)) @@ -665,6 +719,8 @@ private class UserConfigsImpl( userEd25519SecKey: ByteArray, private val userAccountId: AccountId, private val configDatabase: ConfigDatabase, + textSecurePreferences: TextSecurePreferences, + usernameUtils: UsernameUtils, storage: StorageProtocol, threadDb: ThreadDatabase, contactsDump: ByteArray? = configDatabase.retrieveConfigAndHashes( @@ -712,7 +768,7 @@ private class UserConfigsImpl( } if (userProfileDump == null) { - userProfile.initFrom(storage) + userProfile.initFrom(storage, usernameUtils, textSecurePreferences) } if (convoInfoDump == null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ContentModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ContentModule.kt index 89098a0f16..07ca3ee5ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ContentModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ContentModule.kt @@ -6,12 +6,18 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import org.thoughtcrime.securesms.util.RecipientChangeSource +import org.thoughtcrime.securesms.util.ContentObserverRecipientChangeSource @Module @InstallIn(SingletonComponent::class) object ContentModule { @Provides - fun providesContentResolver(@ApplicationContext context: Context) =context.contentResolver + fun providesContentResolver(@ApplicationContext context: Context) = context.contentResolver + + @Provides + fun provideRecipientChangeSource(@ApplicationContext context: Context): RecipientChangeSource = + ContentObserverRecipientChangeSource(context.contentResolver) } \ 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 179a463fc8..8bd4bfc27d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt @@ -10,7 +10,6 @@ import org.session.libsession.database.MessageDataProvider 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.AttachmentDatabase import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase import org.thoughtcrime.securesms.database.ConfigDatabase @@ -26,17 +25,16 @@ import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.LokiUserDatabase import org.thoughtcrime.securesms.database.MediaDatabase -import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.PushDatabase import org.thoughtcrime.securesms.database.ReactionDatabase import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.SearchDatabase 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.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.migration.DatabaseMigrationManager +import javax.inject.Provider import javax.inject.Singleton @Module @@ -54,115 +52,103 @@ object DatabaseModule { @Provides @Singleton - fun provideOpenHelper(@ApplicationContext context: Context): SQLCipherOpenHelper { - val dbSecret = DatabaseSecretProvider(context).orCreateDatabaseSecret - SQLCipherOpenHelper.migrateSqlCipher3To4IfNeeded(context, dbSecret) - return SQLCipherOpenHelper(context, dbSecret) + fun provideOpenHelper(manager: DatabaseMigrationManager): SQLCipherOpenHelper { + return manager.openHelper } @Provides @Singleton - fun provideSmsDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = SmsDatabase(context, openHelper) + fun provideSmsDatabase(@ApplicationContext context: Context, openHelper: Provider) = SmsDatabase(context, openHelper) - @Provides - @Singleton - fun provideMmsDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = MmsDatabase(context, openHelper) @Provides @Singleton fun provideAttachmentDatabase(@ApplicationContext context: Context, - openHelper: SQLCipherOpenHelper, + openHelper: Provider, attachmentSecret: AttachmentSecret) = AttachmentDatabase(context, openHelper, attachmentSecret) @Provides @Singleton - fun provideMediaDatbase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = MediaDatabase(context, openHelper) + fun provideMediaDatbase(@ApplicationContext context: Context, openHelper: Provider) = MediaDatabase(context, openHelper) @Provides @Singleton - fun provideThread(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = ThreadDatabase(context,openHelper) + fun provideMmsSms(@ApplicationContext context: Context, openHelper: Provider) = MmsSmsDatabase(context, openHelper) @Provides @Singleton - fun provideMmsSms(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = MmsSmsDatabase(context, openHelper) + fun provideDraftDatabase(@ApplicationContext context: Context, openHelper: Provider) = DraftDatabase(context, openHelper) @Provides @Singleton - fun provideDraftDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = DraftDatabase(context, openHelper) + fun providePushDatabase(@ApplicationContext context: Context, openHelper: Provider) = PushDatabase(context,openHelper) @Provides @Singleton - fun providePushDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = PushDatabase(context,openHelper) + fun provideGroupDatabase(@ApplicationContext context: Context, openHelper: Provider) = GroupDatabase(context,openHelper) @Provides @Singleton - fun provideGroupDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = GroupDatabase(context,openHelper) + fun provideRecipientDatabase(@ApplicationContext context: Context, openHelper: Provider) = RecipientDatabase(context,openHelper) @Provides @Singleton - fun provideRecipientDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = RecipientDatabase(context,openHelper) + fun provideGroupReceiptDatabase(@ApplicationContext context: Context, openHelper: Provider) = GroupReceiptDatabase(context,openHelper) @Provides @Singleton - fun provideGroupReceiptDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = GroupReceiptDatabase(context,openHelper) + fun searchDatabase(@ApplicationContext context: Context, openHelper: Provider) = SearchDatabase(context,openHelper) @Provides @Singleton - fun searchDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = SearchDatabase(context,openHelper) + fun provideLokiApiDatabase(@ApplicationContext context: Context, openHelper: Provider) = LokiAPIDatabase(context,openHelper) @Provides @Singleton - fun provideLokiApiDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = LokiAPIDatabase(context,openHelper) + fun provideLokiMessageDatabase(@ApplicationContext context: Context, openHelper: Provider) = LokiMessageDatabase(context,openHelper) @Provides @Singleton - fun provideLokiMessageDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = LokiMessageDatabase(context,openHelper) + fun provideLokiThreadDatabase(@ApplicationContext context: Context, openHelper: Provider) = LokiThreadDatabase(context,openHelper) @Provides @Singleton - fun provideLokiThreadDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = LokiThreadDatabase(context,openHelper) + fun provideLokiUserDatabase(@ApplicationContext context: Context, openHelper: Provider) = LokiUserDatabase(context,openHelper) @Provides @Singleton - fun provideLokiUserDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = LokiUserDatabase(context,openHelper) + fun provideLokiBackupFilesDatabase(@ApplicationContext context: Context, openHelper: Provider) = LokiBackupFilesDatabase(context,openHelper) - @Provides - @Singleton - fun provideLokiBackupFilesDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = LokiBackupFilesDatabase(context,openHelper) - - @Provides - @Singleton - fun provideSessionJobDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = SessionJobDatabase(context, openHelper) @Provides @Singleton - fun provideSessionContactDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = SessionContactDatabase(context,openHelper) + fun provideSessionContactDatabase(@ApplicationContext context: Context, openHelper: Provider) = SessionContactDatabase(context,openHelper) @Provides @Singleton - fun provideBlindedIdMappingDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = BlindedIdMappingDatabase(context, openHelper) + fun provideBlindedIdMappingDatabase(@ApplicationContext context: Context, openHelper: Provider) = BlindedIdMappingDatabase(context, openHelper) @Provides @Singleton - fun provideGroupMemberDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = GroupMemberDatabase(context, openHelper) + fun provideGroupMemberDatabase(@ApplicationContext context: Context, openHelper: Provider) = GroupMemberDatabase(context, openHelper) @Provides @Singleton - fun provideReactionDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = ReactionDatabase(context, openHelper) + fun provideReactionDatabase(@ApplicationContext context: Context, openHelper: Provider) = ReactionDatabase(context, openHelper) @Provides @Singleton - fun provideEmojiSearchDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = EmojiSearchDatabase(context, openHelper) + fun provideEmojiSearchDatabase(@ApplicationContext context: Context, openHelper: Provider) = EmojiSearchDatabase(context, openHelper) @Provides @Singleton - fun provideExpirationConfigurationDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = ExpirationConfigurationDatabase(context, openHelper) + fun provideExpirationConfigurationDatabase(@ApplicationContext context: Context, openHelper: Provider) = ExpirationConfigurationDatabase(context, openHelper) @Provides @Singleton - fun provideAttachmentProvider(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper): MessageDataProvider = DatabaseAttachmentProvider(context, openHelper) + fun provideAttachmentProvider(@ApplicationContext context: Context, openHelper: Provider): MessageDataProvider = DatabaseAttachmentProvider(context, openHelper) @Provides @Singleton - fun provideConfigDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper): ConfigDatabase = ConfigDatabase(context, openHelper) + fun provideConfigDatabase(@ApplicationContext context: Context, openHelper: Provider): ConfigDatabase = ConfigDatabase(context, openHelper) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ManagerScope.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ManagerScope.kt new file mode 100644 index 0000000000..d9f762cbc5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ManagerScope.kt @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.dependencies + +import javax.inject.Qualifier + +@Retention(AnnotationRetention.SOURCE) +@Qualifier +@Target( + AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION, + AnnotationTarget.CONSTRUCTOR +) +annotation class ManagerScope \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkModule.kt new file mode 100644 index 0000000000..e31b73e96f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkModule.kt @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.dependencies + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import org.thoughtcrime.securesms.tokenpage.TokenRepository +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient{ + return OkHttpClient() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponent.kt new file mode 100644 index 0000000000..f4016499ba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponent.kt @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.dependencies + +/** + * An interface for components that need to be initialized, or get notified when the app starts. + * + * After implementing this interface, you need to add this component into [OnAppStartupComponents] + */ +interface OnAppStartupComponent { + fun onPostAppStarted() { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt new file mode 100644 index 0000000000..358a97f163 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt @@ -0,0 +1,95 @@ +package org.thoughtcrime.securesms.dependencies + +import org.session.libsession.messaging.notifications.TokenFetcher +import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerManager +import org.session.libsession.messaging.sending_receiving.pollers.PollerManager +import org.session.libsession.snode.SnodeClock +import org.thoughtcrime.securesms.configs.ConfigUploader +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.disguise.AppDisguiseManager +import org.thoughtcrime.securesms.emoji.EmojiIndexLoader +import org.thoughtcrime.securesms.groups.ExpiredGroupManager +import org.thoughtcrime.securesms.groups.GroupPollerManager +import org.thoughtcrime.securesms.groups.handler.AdminStateSync +import org.thoughtcrime.securesms.groups.handler.CleanupInvitationHandler +import org.thoughtcrime.securesms.groups.handler.DestroyedGroupSync +import org.thoughtcrime.securesms.groups.handler.RemoveGroupMemberHandler +import org.thoughtcrime.securesms.logging.PersistentLogger +import org.thoughtcrime.securesms.migration.DatabaseMigrationManager +import org.thoughtcrime.securesms.notifications.BackgroundPollManager +import org.thoughtcrime.securesms.notifications.PushRegistrationHandler +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.service.ExpiringMessageManager +import org.thoughtcrime.securesms.tokenpage.TokenDataManager +import org.thoughtcrime.securesms.util.AppVisibilityManager +import org.thoughtcrime.securesms.util.CurrentActivityObserver +import org.thoughtcrime.securesms.util.VersionDataFetcher +import org.thoughtcrime.securesms.webrtc.CallMessageProcessor +import org.thoughtcrime.securesms.webrtc.WebRtcCallBridge +import javax.inject.Inject + +class OnAppStartupComponents private constructor( + private val components: List +) { + fun onPostAppStarted() { + components.forEach { it.onPostAppStarted() } + } + + @Inject constructor( + configUploader: ConfigUploader, + snodeClock: SnodeClock, + backgroundPollManager: BackgroundPollManager, + appVisibilityManager: AppVisibilityManager, + groupPollerManager: GroupPollerManager, + expiredGroupManager: ExpiredGroupManager, + openGroupPollerManager: OpenGroupPollerManager, + databaseMigrationManager: DatabaseMigrationManager, + tokenManager: TokenDataManager, + expiringMessageManager: ExpiringMessageManager, + currentActivityObserver: CurrentActivityObserver, + webRtcCallBridge: WebRtcCallBridge, + cleanupInvitationHandler: CleanupInvitationHandler, + pollerManager: PollerManager, + proStatusManager: ProStatusManager, + persistentLogger: PersistentLogger, + appDisguiseManager: AppDisguiseManager, + removeGroupMemberHandler: RemoveGroupMemberHandler, + destroyedGroupSync: DestroyedGroupSync, + adminStateSync: AdminStateSync, + callMessageProcessor: CallMessageProcessor, + pushRegistrationHandler: PushRegistrationHandler, + tokenFetcher: TokenFetcher, + versionDataFetcher: VersionDataFetcher, + threadDatabase: ThreadDatabase, + emojiIndexLoader: EmojiIndexLoader, + ): this( + components = listOf( + configUploader, + snodeClock, + backgroundPollManager, + appVisibilityManager, + groupPollerManager, + expiredGroupManager, + openGroupPollerManager, + databaseMigrationManager, + tokenManager, + expiringMessageManager, + currentActivityObserver, + webRtcCallBridge, + cleanupInvitationHandler, + pollerManager, + proStatusManager, + persistentLogger, + appDisguiseManager, + removeGroupMemberHandler, + destroyedGroupSync, + adminStateSync, + callMessageProcessor, + pushRegistrationHandler, + tokenFetcher, + versionDataFetcher, + threadDatabase, + emojiIndexLoader, + ) + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt index 521a3a621d..1819c46871 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt @@ -10,12 +10,11 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.GlobalScope -import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupScope -import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager -import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2 -import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.UsernameUtils +import org.thoughtcrime.securesms.database.SessionContactDatabase +import org.thoughtcrime.securesms.util.UsernameUtilsImpl import javax.inject.Named import javax.inject.Singleton @@ -35,27 +34,20 @@ object SessionUtilModule { @Named(POLLER_SCOPE) fun provideExecutor(): CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1) - @Provides - @Singleton - fun provideSnodeClock() = SnodeClock() - @Provides @Singleton fun provideGroupScope() = GroupScope() - @Provides - @Singleton - fun provideLegacyGroupPoller( - storage: StorageProtocol, - deprecationManager: LegacyGroupDeprecationManager - ): LegacyClosedGroupPollerV2 { - return LegacyClosedGroupPollerV2(storage, deprecationManager) - } - @Provides @Singleton - fun provideLegacyGroupDeprecationManager(prefs: TextSecurePreferences): LegacyGroupDeprecationManager { - return LegacyGroupDeprecationManager(prefs) - } + fun provideUsernameUtils( + prefs: TextSecurePreferences, + configFactory: ConfigFactory, + sessionContactDatabase: SessionContactDatabase, + ): UsernameUtils = UsernameUtilsImpl( + prefs = prefs, + configFactory = configFactory, + sessionContactDatabase = sessionContactDatabase, + ) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/disguise/AppDisguiseManager.kt b/app/src/main/java/org/thoughtcrime/securesms/disguise/AppDisguiseManager.kt new file mode 100644 index 0000000000..0b94e7a268 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/disguise/AppDisguiseManager.kt @@ -0,0 +1,165 @@ +package org.thoughtcrime.securesms.disguise + +import android.app.Application +import android.content.ComponentName +import android.content.Intent +import android.content.pm.PackageManager +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import org.thoughtcrime.securesms.util.CurrentActivityObserver +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manage the app disguise feature, where you can observe the list of app aliases and selected alias. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@Singleton +class AppDisguiseManager @Inject constructor( + application: Application, + private val prefs: TextSecurePreferences, + private val currentActivityObserver: CurrentActivityObserver, + @param:ManagerScope private val scope: CoroutineScope, +) : OnAppStartupComponent { + val allAppAliases: Flow> = flow { + emit( + application.packageManager + .queryIntentActivities( + Intent(Intent.ACTION_MAIN) + .setPackage(application.packageName) + .addCategory(Intent.CATEGORY_LAUNCHER), + PackageManager.GET_ACTIVITIES or PackageManager.MATCH_DISABLED_COMPONENTS + ) + .asSequence() + .filter { + it.activityInfo.targetActivity != null + } + .map { info -> + AppAlias( + activityAliasName = info.activityInfo.name, + defaultEnabled = info.activityInfo.enabled, + appName = info.activityInfo.labelRes.takeIf { it != 0 } + ?: info.labelRes.takeIf { it != 0 } + ?: R.string.app_name, + appIcon = info.activityInfo.icon.takeIf { it != 0 } + ?: info.iconResource.takeIf { it != 0 }, + ) + } + .toList() + ) + }.flowOn(Dispatchers.Default) + .shareIn(scope, started = SharingStarted.Lazily, replay = 1) + + private val prefChangeNotification = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + /** + * The currently selected app alias name. This doesn't equate to if the app disguise is on or off. + */ + val selectedAppAliasName: StateFlow = prefChangeNotification + .mapLatest { prefs.selectedActivityAliasName } + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = prefs.selectedActivityAliasName + ) + + init { + scope.launch { + combine( + selectedAppAliasName, + allAppAliases, + ) { selected, all -> + val enabledAlias = all.firstOrNull { it.activityAliasName == selected } + ?: all.first { it.defaultEnabled } + + all.map { alias -> + val state = if (alias === enabledAlias) PackageManager.COMPONENT_ENABLED_STATE_ENABLED + else PackageManager.COMPONENT_ENABLED_STATE_DISABLED + + Triple( + ComponentName(application, alias.activityAliasName), + state, + alias.defaultEnabled + ) + } + }.collectLatest { all -> + val packageManager = application.packageManager + if (android.os.Build.VERSION.SDK_INT >= 33) { + packageManager.setComponentEnabledSettings( + all.map { (name, state) -> + PackageManager.ComponentEnabledSetting( + name, state, PackageManager.DONT_KILL_APP or PackageManager.SYNCHRONOUS + ) + } + ) + } else { + // Query current enable state for each component + val changed = all.filter { (name, desiredState, defaultEnabled) -> + val state = packageManager.getComponentEnabledSetting(name) + val wasEnabled = when (state) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> true + PackageManager.COMPONENT_ENABLED_STATE_DISABLED -> false + else -> defaultEnabled + } + + val willBeEnabled = (desiredState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) || + (desiredState == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT && defaultEnabled) + wasEnabled != willBeEnabled + } + + changed.forEach { (name, state) -> + packageManager.setComponentEnabledSetting( + name, + state, + PackageManager.DONT_KILL_APP + ) + } + + if (changed.isNotEmpty()) { + // Finish current activity if the disguise is on + currentActivityObserver.currentActivity.value?.finishAffinity() + } + } + } + } + } + + fun setSelectedAliasName(name: String?) { + Log.d(TAG, "setSelectedAliasName: $name") + prefs.selectedActivityAliasName = name + prefChangeNotification.tryEmit(Unit) + } + + data class AppAlias( + val activityAliasName: String, + val defaultEnabled: Boolean, + @StringRes val appName: Int?, + @DrawableRes val appIcon: Int?, + ) +} + +private const val TAG = "AppDisguiseManager" \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiIndexLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiIndexLoader.kt new file mode 100644 index 0000000000..0dd0b00dcc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiIndexLoader.kt @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.emoji + +import android.app.Application +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.EmojiSearchDatabase +import org.thoughtcrime.securesms.database.model.EmojiSearchData +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import java.io.IOException +import javax.inject.Inject + +class EmojiIndexLoader @Inject constructor( + private val application: Application, + private val emojiSearchDb: EmojiSearchDatabase, + @param:ManagerScope private val scope: CoroutineScope, +) : OnAppStartupComponent { + override fun onPostAppStarted() { + scope.launch { + if (emojiSearchDb.query("face", 1).isEmpty()) { + try { + application.assets.open("emoji/emoji_search_index.json").use { inputStream -> + val searchIndex = listOf( + *JsonUtil.fromJson( + inputStream, + Array::class.java + ) + ) + emojiSearchDb.setSearchIndex(searchIndex) + } + } catch (e: IOException) { + Log.e( + "EmojiIndexLoader", + "Failed to load emoji search index", + e + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/PartProgressEvent.java b/app/src/main/java/org/thoughtcrime/securesms/events/PartProgressEvent.java deleted file mode 100644 index 1be748f54b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/events/PartProgressEvent.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.thoughtcrime.securesms.events; - - -import androidx.annotation.NonNull; - -import org.session.libsession.messaging.sending_receiving.attachments.Attachment; - -public class PartProgressEvent { - - public final Attachment attachment; - public final long total; - public final long progress; - - public PartProgressEvent(@NonNull Attachment attachment, long total, long progress) { - this.attachment = attachment; - this.total = total; - this.progress = progress; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java index a9edadfcb1..67c27f5032 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.giph.ui; import android.annotation.SuppressLint; -import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.AsyncTask; @@ -12,25 +11,25 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentPagerAdapter; -import androidx.viewpager.widget.ViewPager; +import androidx.fragment.app.FragmentActivity; +import androidx.viewpager2.adapter.FragmentStateAdapter; -import com.google.android.material.tabs.TabLayout; +import com.google.android.material.tabs.TabLayoutMediator; import org.session.libsession.utilities.MediaTypes; import org.session.libsession.utilities.NonTranslatableStringConstants; -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; +import org.thoughtcrime.securesms.ScreenLockActionBarActivity; import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.providers.BlobUtils; import org.session.libsession.utilities.ViewUtil; import java.io.IOException; import java.util.concurrent.ExecutionException; import network.loki.messenger.R; +import network.loki.messenger.databinding.GiphyActivityBinding; -public class GiphyActivity extends PassphraseRequiredActionBarActivity +public class GiphyActivity extends ScreenLockActionBarActivity implements GiphyActivityToolbar.OnLayoutChangedListener, GiphyActivityToolbar.OnFilterChangedListener, GiphyAdapter.OnItemClickListener @@ -46,11 +45,14 @@ public class GiphyActivity extends PassphraseRequiredActionBarActivity private GiphyStickerFragment stickerFragment; private boolean forMms; + private GiphyActivityBinding binding; + private GiphyAdapter.GiphyViewHolder finishingImage; @Override public void onCreate(Bundle bundle, boolean ready) { - setContentView(R.layout.giphy_activity); + binding = GiphyActivityBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); initializeToolbar(); initializeResources(); @@ -69,19 +71,15 @@ private void initializeToolbar() { } private void initializeResources() { - ViewPager viewPager = ViewUtil.findById(this, R.id.giphy_pager); - TabLayout tabLayout = ViewUtil.findById(this, R.id.tab_layout); - this.gifFragment = new GiphyGifFragment(); this.stickerFragment = new GiphyStickerFragment(); this.forMms = getIntent().getBooleanExtra(EXTRA_IS_MMS, false); - gifFragment.setClickListener(this); - stickerFragment.setClickListener(this); + binding.giphyPager.setAdapter(new GiphyFragmentPagerAdapter(this)); - viewPager.setAdapter(new GiphyFragmentPagerAdapter(this, getSupportFragmentManager(), - gifFragment, stickerFragment)); - tabLayout.setupWithViewPager(viewPager); + new TabLayoutMediator(binding.tabLayout, binding.giphyPager, (tab, position) -> { + tab.setText(position == 0 ? NonTranslatableStringConstants.GIF : getString(R.string.stickers)); + }).attach(); } @Override @@ -109,10 +107,11 @@ protected Uri doInBackground(Void... params) { try { byte[] data = viewHolder.getData(forMms); - return BlobProvider.getInstance() + return BlobUtils.getInstance() .forData(data) .withMimeType(MediaTypes.IMAGE_GIF) - .createForSingleSessionOnDisk(GiphyActivity.this, e -> Log.w(TAG, "Failed to write to disk.", e)); + .createForSingleSessionOnDisk(GiphyActivity.this, e -> Log.w(TAG, "Failed to write to disk.", e)) + .get(); } catch (InterruptedException | ExecutionException | IOException e) { Log.w(TAG, e); return null; @@ -136,39 +135,23 @@ protected void onPostExecute(@Nullable Uri uri) { }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } - private static class GiphyFragmentPagerAdapter extends FragmentPagerAdapter { - - private final Context context; - private final GiphyGifFragment gifFragment; - private final GiphyStickerFragment stickerFragment; + private class GiphyFragmentPagerAdapter extends FragmentStateAdapter { - private GiphyFragmentPagerAdapter(@NonNull Context context, - @NonNull FragmentManager fragmentManager, - @NonNull GiphyGifFragment gifFragment, - @NonNull GiphyStickerFragment stickerFragment) + private GiphyFragmentPagerAdapter(@NonNull FragmentActivity activity) { - super(fragmentManager); - this.context = context.getApplicationContext(); - this.gifFragment = gifFragment; - this.stickerFragment = stickerFragment; + super(activity); } + @NonNull @Override - public Fragment getItem(int position) { - if (position == 0) return gifFragment; - else return stickerFragment; + public Fragment createFragment(int position) { + return position == 0 ? gifFragment : stickerFragment; } @Override - public int getCount() { + public int getItemCount() { return 2; } - - @Override - public CharSequence getPageTitle(int position) { - if (position == 0) return NonTranslatableStringConstants.GIF; - else return context.getString(R.string.stickers); - } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java index d4b1d642dc..753e43b833 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java @@ -36,9 +36,9 @@ public abstract class GiphyFragment extends Fragment implements LoaderManager.Lo private RecyclerView recyclerView; private View loadingProgress; private TextView noResultsView; - private GiphyAdapter.OnItemClickListener listener; protected String searchString; + private Boolean pendingGridLayout = null; @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) { @@ -47,6 +47,18 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, this.loadingProgress = ViewUtil.findById(container, R.id.loading_progress); this.noResultsView = ViewUtil.findById(container, R.id.no_results); + // Now that views are ready, apply the searchString if it's set + applySearchStringToUI(); + + // Apply pending layout if it was set before view was ready + if (pendingGridLayout != null) { + setLayoutManager(pendingGridLayout); + pendingGridLayout = null; + } else { + // Or set default + setLayoutManager(TextSecurePreferences.isGifSearchInGridLayout(getContext())); + } + return container; } @@ -57,7 +69,6 @@ public void onActivityCreated(Bundle bundle) { this.giphyAdapter = new GiphyAdapter(getActivity(), Glide.with(this), new LinkedList<>()); this.giphyAdapter.setListener(this); - setLayoutManager(TextSecurePreferences.isGifSearchInGridLayout(getContext())); this.recyclerView.setItemAnimator(new DefaultItemAnimator()); this.recyclerView.setAdapter(giphyAdapter); this.recyclerView.addOnScrollListener(new GiphyScrollListener()); @@ -82,7 +93,11 @@ public void onLoaderReset(@NonNull Loader> loader) { } public void setLayoutManager(boolean gridLayout) { - recyclerView.setLayoutManager(getLayoutManager(gridLayout)); + if (recyclerView != null) { + recyclerView.setLayoutManager(getLayoutManager(gridLayout)); + } else { + pendingGridLayout = gridLayout; + } } private RecyclerView.LayoutManager getLayoutManager(boolean gridLayout) { @@ -90,19 +105,26 @@ private RecyclerView.LayoutManager getLayoutManager(boolean gridLayout) { : new LinearLayoutManager(getActivity()); } - public void setClickListener(GiphyAdapter.OnItemClickListener listener) { - this.listener = listener; - } public void setSearchString(@Nullable String searchString) { this.searchString = searchString; - this.noResultsView.setVisibility(View.GONE); - this.getLoaderManager().restartLoader(0, null, this); + if (this.noResultsView != null) { + applySearchStringToUI(); + } + } + + private void applySearchStringToUI() { + if (this.noResultsView != null) { + this.noResultsView.setVisibility(View.GONE); + this.getLoaderManager().restartLoader(0, null, this); + } } @Override public void onClick(GiphyAdapter.GiphyViewHolder viewHolder) { - if (listener != null) listener.onClick(viewHolder); + if (getActivity() instanceof GiphyAdapter.OnItemClickListener) { + ((GiphyAdapter.OnItemClickListener) getActivity()).onClick(viewHolder); + } } private class GiphyScrollListener extends InfiniteScrollListener { diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarFetcher.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarFetcher.kt index 38d51877de..4f43dc7c10 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarFetcher.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarFetcher.kt @@ -7,14 +7,13 @@ import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.data.DataFetcher import org.session.libsession.avatars.PlaceholderAvatarPhoto import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.util.AvatarPlaceholderGenerator +import org.thoughtcrime.securesms.util.AvatarUtils -class PlaceholderAvatarFetcher(private val context: Context, - private val photo: PlaceholderAvatarPhoto): DataFetcher { +class PlaceholderAvatarFetcher(private val photo: PlaceholderAvatarPhoto): DataFetcher { override fun loadData(priority: Priority,callback: DataFetcher.DataCallback) { try { - val avatar = AvatarPlaceholderGenerator.generate(context, 128, photo.hashString, photo.displayName) + val avatar = photo.bitmap callback.onDataReady(avatar) } catch (e: Exception) { Log.e("Loki", "Error in fetching avatar") 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 b163b5ed90..705fe94187 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt @@ -1,6 +1,5 @@ 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 @@ -9,7 +8,7 @@ import com.bumptech.glide.load.model.ModelLoaderFactory import com.bumptech.glide.load.model.MultiModelLoaderFactory import org.session.libsession.avatars.PlaceholderAvatarPhoto -class PlaceholderAvatarLoader(private val appContext: Context): ModelLoader { +class PlaceholderAvatarLoader(): ModelLoader { override fun buildLoadData( model: PlaceholderAvatarPhoto, @@ -17,14 +16,14 @@ class PlaceholderAvatarLoader(private val appContext: Context): ModelLoader { - return LoadData(model, PlaceholderAvatarFetcher(appContext, model)) + return LoadData(model, PlaceholderAvatarFetcher(model)) } override fun handles(model: PlaceholderAvatarPhoto): Boolean = true - class Factory(private val appContext: Context) : ModelLoaderFactory { + class Factory() : ModelLoaderFactory { override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader { - return PlaceholderAvatarLoader(appContext) + return PlaceholderAvatarLoader() } override fun teardown() {} } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt index e40983e16d..92997ffbb4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -3,11 +3,15 @@ package org.thoughtcrime.securesms.groups import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dagger.assisted.AssistedFactory import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart @@ -15,29 +19,36 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.allWithStatus -import network.loki.messenger.libsession_util.util.GroupDisplayInfo import network.loki.messenger.libsession_util.util.GroupMember import org.session.libsession.database.StorageProtocol +import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigUpdateNotification +import org.session.libsession.utilities.GroupDisplayInfo +import org.session.libsession.utilities.UsernameUtils import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUtils import java.util.EnumSet - abstract class BaseGroupMembersViewModel ( private val groupId: AccountId, @ApplicationContext private val context: Context, private val storage: StorageProtocol, - private val configFactory: ConfigFactoryProtocol + private val usernameUtils: UsernameUtils, + private val configFactory: ConfigFactoryProtocol, + private val avatarUtils: AvatarUtils, + private val proStatusManager: ProStatusManager, ) : ViewModel() { // Output: the source-of-truth group information. Other states are derived from this. protected val groupInfo: StateFlow>?> = - configFactory.configUpdateNotifications + (configFactory.configUpdateNotifications .filter { it is ConfigUpdateNotification.GroupConfigsUpdated && it.groupId == groupId || it is ConfigUpdateNotification.UserConfigsMerged - } - .onStart { emit(ConfigUpdateNotification.GroupConfigsUpdated(groupId)) } + } as Flow<*>) + .onStart { emit(Unit) } .map { _ -> withContext(Dispatchers.Default) { val currentUserId = AccountId(checkNotNull(storage.getUserPublicKey()) { @@ -47,37 +58,52 @@ abstract class BaseGroupMembersViewModel ( val displayInfo = storage.getClosedGroupDisplayInfo(groupId.hexString) ?: return@withContext null - val memberState = configFactory.withGroupConfigs(groupId) { it.groupMembers.allWithStatus() } - .map { (member, status) -> - createGroupMember( - member = member, - status = status, - myAccountId = currentUserId, - amIAdmin = displayInfo.isUserAdmin, - ) - } - .toList() + val rawMembers = configFactory.withGroupConfigs(groupId) { it.groupMembers.allWithStatus() } + + val memberState = mutableListOf() + for ((member, status) in rawMembers) { + memberState.add(createGroupMember(member, status, currentUserId, displayInfo.isUserAdmin)) + } displayInfo to sortMembers(memberState, currentUserId) } }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + private val mutableSearchQuery = MutableStateFlow("") + val searchQuery: StateFlow get() = mutableSearchQuery + // Output: the list of the members and their state in the group. - val members: StateFlow> = groupInfo - .map { it?.second.orEmpty() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + @OptIn(FlowPreview::class) + val members: StateFlow> = combine( + groupInfo.map { it?.second.orEmpty() }, + mutableSearchQuery.debounce(100L), + ::filterContacts + ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + fun onSearchQueryChanged(query: String) { + mutableSearchQuery.value = query + } - private fun createGroupMember( + private fun filterContacts( + contacts: List, + query: String, + ): List { + return if(query.isBlank()) contacts + else contacts.filter { it.name.contains(query, ignoreCase = true) } + } + + private suspend fun createGroupMember( member: GroupMember, status: GroupMember.Status, myAccountId: AccountId, amIAdmin: Boolean, ): GroupMemberState { - val isMyself = member.accountId == myAccountId + val memberAccountId = AccountId(member.accountId()) + val isMyself = memberAccountId == myAccountId val name = if (isMyself) { context.getString(R.string.you) } else { - storage.getContactNameWithAccountID(member.accountId.hexString, groupId) + usernameUtils.getContactNameWithAccountID(memberAccountId.hexString, groupId) } val highlightStatus = status in EnumSet.of( @@ -86,20 +112,22 @@ abstract class BaseGroupMembersViewModel ( ) return GroupMemberState( - accountId = member.accountId, + accountId = memberAccountId, name = name, - canRemove = amIAdmin && member.accountId != myAccountId + canRemove = amIAdmin && memberAccountId != myAccountId && !member.isAdminOrBeingPromoted(status) && !member.isRemoved(status), - canPromote = amIAdmin && member.accountId != myAccountId + canPromote = amIAdmin && memberAccountId != myAccountId && !member.isAdminOrBeingPromoted(status) && !member.isRemoved(status), - canResendPromotion = amIAdmin && member.accountId != myAccountId + canResendPromotion = amIAdmin && memberAccountId != myAccountId && status == GroupMember.Status.PROMOTION_FAILED && !member.isRemoved(status), - canResendInvite = amIAdmin && member.accountId != myAccountId + canResendInvite = amIAdmin && memberAccountId != myAccountId && !member.isRemoved(status) && (status == GroupMember.Status.INVITE_SENT || status == GroupMember.Status.INVITE_FAILED), status = status.takeIf { !isMyself }, // Status is only meant for other members highlightStatus = highlightStatus, showAsAdmin = member.isAdminOrBeingPromoted(status), + showProBadge = proStatusManager.shouldShowProBadge(Address.fromSerialized(member.accountId())), + avatarUIData = avatarUtils.getUIDataFromAccountId(memberAccountId.hexString), clickable = !isMyself, statusLabel = getMemberLabel(status, context, amIAdmin), ) @@ -136,44 +164,21 @@ abstract class BaseGroupMembersViewModel ( // Refer to notion doc for the sorting logic private fun sortMembers(members: List, currentUserId: AccountId) = members.sortedWith( - compareBy{ - when (it.status) { - GroupMember.Status.INVITE_FAILED -> 0 - GroupMember.Status.INVITE_NOT_SENT -> 1 - GroupMember.Status.INVITE_SENDING -> 2 - GroupMember.Status.INVITE_SENT -> 3 - GroupMember.Status.INVITE_UNKNOWN -> 4 - GroupMember.Status.REMOVED, - GroupMember.Status.REMOVED_UNKNOWN, - GroupMember.Status.REMOVED_INCLUDING_MESSAGES -> 5 - GroupMember.Status.PROMOTION_FAILED -> 6 - GroupMember.Status.PROMOTION_NOT_SENT -> 7 - GroupMember.Status.PROMOTION_SENDING -> 8 - GroupMember.Status.PROMOTION_SENT -> 9 - GroupMember.Status.PROMOTION_UNKNOWN -> 10 - null, - GroupMember.Status.INVITE_ACCEPTED, - GroupMember.Status.PROMOTION_ACCEPTED -> 11 - } - } + compareBy{ it.accountId != currentUserId } // Current user comes first .thenBy { !it.showAsAdmin } // Admins come first - .thenBy { it.accountId != currentUserId } // Being myself comes first .thenComparing(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) // Sort by name (case insensitive) .thenBy { it.accountId } // Last resort: sort by account ID ) - - @AssistedFactory - interface Factory { - fun create(groupId: AccountId): EditGroupViewModel - } } data class GroupMemberState( val accountId: AccountId, + val avatarUIData: AvatarUIData, val name: String, val status: GroupMember.Status?, val highlightStatus: Boolean, val showAsAdmin: Boolean, + val showProBadge: Boolean, val canResendInvite: Boolean, val canResendPromotion: Boolean, val canRemove: Boolean, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupEditingOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupEditingOptionsBottomSheet.kt deleted file mode 100644 index 4991d3098e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupEditingOptionsBottomSheet.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.thoughtcrime.securesms.groups - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import network.loki.messenger.databinding.FragmentClosedGroupEditBottomSheetBinding - -class ClosedGroupEditingOptionsBottomSheet : BottomSheetDialogFragment() { - private lateinit var binding: FragmentClosedGroupEditBottomSheetBinding - var onRemoveTapped: (() -> Unit)? = null - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = FragmentClosedGroupEditBottomSheetBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.removeFromGroup.setOnClickListener { onRemoveTapped?.invoke() } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt index d0d1c65741..9d5a7dbbc5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt @@ -2,9 +2,9 @@ package org.thoughtcrime.securesms.groups import android.content.Context import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.util.Bytes import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 -import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2 import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupUtil @@ -25,7 +25,6 @@ object ClosedGroupManager { // Notify the PN server PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = groupPublicKey, publicKey = userPublicKey) // Stop polling - MessagingModuleConfiguration.shared.legacyClosedGroupPollerV2.stopPolling(groupPublicKey) storage.cancelPendingMessageSendJobs(threadId) ApplicationContext.getInstance(context).messageNotifier.updateNotification(context) if (delete) { @@ -44,13 +43,13 @@ object ClosedGroupManager { val groups = it.userGroups val legacyInfo = groups.getOrConstructLegacyGroupInfo(groupPublicKey) - val latestMemberMap = GroupUtil.createConfigMemberMap(group.members.map(Address::serialize), group.admins.map(Address::serialize)) + val latestMemberMap = GroupUtil.createConfigMemberMap(group.members.map(Address::toString), group.admins.map(Address::toString)) 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() + encPubKey = Bytes((latestKeyPair.publicKey as DjbECPublicKey).publicKey), // 'serialize()' inserts an extra byte + encSecKey = Bytes(latestKeyPair.privateKey.serialize()) ) groups.set(toSet) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt deleted file mode 100644 index 6a27b213a6..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.thoughtcrime.securesms.groups - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import org.thoughtcrime.securesms.conversation.start.NullStartConversationDelegate -import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate -import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.groups.compose.CreateGroupScreen -import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme - -class CreateGroupFragment : Fragment() { - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return ComposeView(requireContext()).apply { - val delegate = (parentFragment as? StartConversationDelegate) - ?: (activity as? StartConversationDelegate) - ?: NullStartConversationDelegate - - setContent { - SessionMaterialTheme { - CreateGroupScreen( - onNavigateToConversationScreen = { threadID -> - startActivity( - Intent(requireContext(), ConversationActivityV2::class.java) - .putExtra(ConversationActivityV2.THREAD_ID, threadID) - ) - }, - onBack = delegate::onDialogBackPressed, - onClose = delegate::onDialogClosePressed, - fromLegacyGroupId = null, - ) - } - } - } - } -} - diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/DefaultGroupsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/DefaultGroupsViewModel.kt deleted file mode 100644 index c93b1e6026..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/DefaultGroupsViewModel.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.thoughtcrime.securesms.groups - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.asLiveData -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart -import nl.komponents.kovenant.functional.map -import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.thoughtcrime.securesms.util.State - -typealias DefaultGroups = List -typealias GroupState = State - -class DefaultGroupsViewModel : ViewModel() { - - init { - OpenGroupApi.getDefaultServerCapabilities().map { - OpenGroupApi.getDefaultRoomsIfNeeded() - } - } - - val defaultRooms = OpenGroupApi.defaultRooms.map { - State.Success(it) - }.onStart { - emit(State.Loading) - }.asLiveData() -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupActivity.kt deleted file mode 100644 index 8d706942c5..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupActivity.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.thoughtcrime.securesms.groups - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.compose.setContent -import dagger.hilt.android.AndroidEntryPoint -import org.session.libsignal.utilities.AccountId -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.groups.compose.EditGroupScreen -import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme - -@AndroidEntryPoint -class EditGroupActivity: PassphraseRequiredActionBarActivity() { - - companion object { - private const val EXTRA_GROUP_ID = "EditClosedGroupActivity_groupID" - - fun createIntent(context: Context, groupSessionId: String): Intent { - return Intent(context, EditGroupActivity::class.java).apply { - putExtra(EXTRA_GROUP_ID, groupSessionId) - } - } - } - - override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { - setContent { - SessionMaterialTheme { - EditGroupScreen( - groupId = AccountId(intent.getStringExtra(EXTRA_GROUP_ID)!!), - onBack = this::finish - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt index 9adf50c361..6380935cf9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt @@ -12,7 +12,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -21,41 +20,30 @@ import network.loki.messenger.libsession_util.getOrNull import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupInviteException import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.UsernameUtils import org.session.libsignal.utilities.AccountId -import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.textSizeInBytes +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.util.AvatarUtils -const val MAX_GROUP_NAME_BYTES = 100 @HiltViewModel(assistedFactory = EditGroupViewModel.Factory::class) class EditGroupViewModel @AssistedInject constructor( @Assisted private val groupId: AccountId, @ApplicationContext private val context: Context, - private val storage: StorageProtocol, + storage: StorageProtocol, private val configFactory: ConfigFactoryProtocol, private val groupManager: GroupManagerV2, -) : BaseGroupMembersViewModel(groupId, context, storage, configFactory) { - // Input/Output state - private val mutableEditingName = MutableStateFlow(null) - - // Input/Output: the name that has been written and submitted for change to push to the server, - // but not yet confirmed by the server. When this state is present, it takes precedence over - // the group name in the group info. - private val mutablePendingEditedName = MutableStateFlow(null) - - // Output: The name of the group being edited. Null if it's not in edit mode, not to be confused - // with empty string, where it's a valid editing state. - val editingName: StateFlow get() = mutableEditingName - - // Output: whether the group name can be edited. This is true if the group is loaded successfully. - val canEditGroupName: StateFlow = groupInfo - .map { it != null } - .stateIn(viewModelScope, SharingStarted.Eagerly, false) + private val usernameUtils: UsernameUtils, + private val avatarUtils: AvatarUtils, + proStatusManager: ProStatusManager +) : BaseGroupMembersViewModel(groupId, context, storage, usernameUtils, configFactory, avatarUtils, proStatusManager) { // Output: The name of the group. This is the current name of the group, not the name being edited. - val groupName: StateFlow = combine(groupInfo - .map { it?.first?.name.orEmpty() }, mutablePendingEditedName) { name, pendingName -> pendingName ?: name } + val groupName: StateFlow = groupInfo + .map { it?.first?.name.orEmpty() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") // Output: whether we should show the "add members" button @@ -76,15 +64,15 @@ class EditGroupViewModel @AssistedInject constructor( val error: StateFlow get() = mutableError // Output: - val excludingAccountIDsFromContactSelection: Set - get() = groupInfo.value?.second?.mapTo(hashSetOf()) { it.accountId }.orEmpty() + val excludingAccountIDsFromContactSelection: Set + get() = groupInfo.value?.second?.mapTo(hashSetOf()) { it.accountId.hexString }.orEmpty() - fun onContactSelected(contacts: Set) { + fun onContactSelected(contacts: Set
) { performGroupOperation( showLoading = false, errorMessage = { err -> if (err is GroupInviteException) { - err.format(context, storage).toString() + err.format(context, usernameUtils).toString() } else { null } @@ -92,7 +80,7 @@ class EditGroupViewModel @AssistedInject constructor( ) { groupManager.inviteMembers( groupId, - contacts.toList(), + contacts.map { AccountId(it.toString()) }.toList(), shareHistory = false, isReinvite = false, ) @@ -104,7 +92,7 @@ class EditGroupViewModel @AssistedInject constructor( showLoading = false, errorMessage = { err -> if (err is GroupInviteException) { - err.format(context, storage).toString() + err.format(context, usernameUtils).toString() } else { null } @@ -145,48 +133,6 @@ class EditGroupViewModel @AssistedInject constructor( } } - fun onEditNameClicked() { - mutableEditingName.value = groupInfo.value?.first?.name.orEmpty() - } - - fun onCancelEditingNameClicked() { - mutableEditingName.value = null - } - - fun onEditingNameChanged(value: String) { - mutableEditingName.value = value - } - - fun onEditNameConfirmClicked() { - val newName = mutableEditingName.value - - if (newName.isNullOrBlank()) { - mutableError.value = context.getString(R.string.groupNameEnterPlease) - return - } - - // validate name length (needs to be less than 100 bytes) - if(newName.textSizeInBytes() > MAX_GROUP_NAME_BYTES){ - mutableError.value = context.getString(R.string.groupNameEnterShorter) - return - } - - // Move the edited name into the pending state - mutableEditingName.value = null - mutablePendingEditedName.value = newName - - performGroupOperation { - try { - groupManager.setName(groupId, newName) - } finally { - // As soon as the operation is done, clear the pending state, - // no matter if it's successful or not. So that we update the UI to reflect the - // real state. - mutablePendingEditedName.value = null - } - } - } - fun onDismissError() { mutableError.value = null } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EnterCommunityUrlFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EnterCommunityUrlFragment.kt deleted file mode 100644 index a23e71c640..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EnterCommunityUrlFragment.kt +++ /dev/null @@ -1,95 +0,0 @@ -package org.thoughtcrime.securesms.groups - -import android.graphics.BitmapFactory -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory -import androidx.core.view.isVisible -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import com.google.android.material.chip.Chip -import network.loki.messenger.R -import network.loki.messenger.databinding.FragmentEnterCommunityUrlBinding -import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.thoughtcrime.securesms.BaseActionBarActivity -import org.thoughtcrime.securesms.util.State -import org.thoughtcrime.securesms.util.hideKeyboard -import java.util.Locale - -class EnterCommunityUrlFragment : Fragment() { - private lateinit var binding: FragmentEnterCommunityUrlBinding - private val viewModel by activityViewModels() - - var delegate: EnterCommunityUrlDelegate? = null - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = FragmentEnterCommunityUrlBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.communityUrlEditText.imeOptions = binding.communityUrlEditText.imeOptions or 16777216 // Always use incognito keyboard - binding.communityUrlEditText.addTextChangedListener { text -> binding.joinCommunityButton.isEnabled = !text.isNullOrBlank() } - binding.communityUrlEditText.setOnFocusChangeListener { _, hasFocus -> binding.defaultRoomsContainer.isVisible = !hasFocus } - binding.mainContainer.setOnTouchListener { _, _ -> - binding.defaultRoomsContainer.isVisible = true - binding.communityUrlEditText.clearFocus() - binding.communityUrlEditText.hideKeyboard() - true - } - binding.joinCommunityButton.setOnClickListener { joinCommunityIfPossible() } - viewModel.defaultRooms.observe(viewLifecycleOwner) { state -> - binding.defaultRoomsContainer.isVisible = state is State.Success - binding.defaultRoomsLoaderContainer.isVisible = state is State.Loading - binding.defaultRoomsLoader.isVisible = state is State.Loading - when (state) { - State.Loading -> { - // TODO: Show a binding.loader - } - is State.Error -> { - // TODO: Hide the binding.loader - } - is State.Success -> { - populateDefaultGroups(state.value) - } - } - } - } - - private fun populateDefaultGroups(groups: List) { - binding.defaultRoomsFlexboxLayout.removeAllViews() - groups.iterator().forEach { defaultGroup -> - val chip = layoutInflater.inflate(R.layout.default_group_chip, binding.defaultRoomsFlexboxLayout, false) as Chip - val drawable = defaultGroup.image?.let { bytes -> - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - RoundedBitmapDrawableFactory.create(resources, bitmap).apply { - isCircular = true - } - } - chip.chipIcon = drawable - chip.text = defaultGroup.name - chip.setOnClickListener { - delegate?.handleCommunityUrlEntered(defaultGroup.joinURL) - } - binding.defaultRoomsFlexboxLayout.addView(chip) - } - } - - // region Convenience - private fun joinCommunityIfPossible() { - val inputMethodManager = requireContext().getSystemService(BaseActionBarActivity.INPUT_METHOD_SERVICE) as InputMethodManager - inputMethodManager.hideSoftInputFromWindow(binding.communityUrlEditText.windowToken, 0) - val communityUrl = binding.communityUrlEditText.text.trim().toString().lowercase(Locale.US) - delegate?.handleCommunityUrlEntered(communityUrl) - } - // endregion -} - -fun interface EnterCommunityUrlDelegate { - fun handleCommunityUrlEntered(url: String) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ExpiredGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ExpiredGroupManager.kt index f2ea8f09cb..a061c0c8d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ExpiredGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ExpiredGroupManager.kt @@ -1,6 +1,6 @@ package org.thoughtcrime.securesms.groups -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.mapNotNull @@ -8,6 +8,8 @@ import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.stateIn import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import javax.inject.Inject import javax.inject.Singleton @@ -22,7 +24,8 @@ import javax.inject.Singleton @Singleton class ExpiredGroupManager @Inject constructor( pollerManager: GroupPollerManager, -) { + @ManagerScope scope: CoroutineScope +) : OnAppStartupComponent { @Suppress("OPT_IN_USAGE") val expiredGroups: StateFlow> = pollerManager.watchAllGroupPollingState() .mapNotNull { (groupId, state) -> @@ -52,5 +55,5 @@ class ExpiredGroupManager @Inject constructor( } } - .stateIn(GlobalScope, SharingStarted.Eagerly, emptySet()) + .stateIn(scope, SharingStarted.Eagerly, emptySet()) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt new file mode 100644 index 0000000000..468b3d5ea9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt @@ -0,0 +1,143 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException +import org.session.libsession.messaging.groups.GroupScope +import org.session.libsession.messaging.messages.control.GroupUpdated +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.messaging.utilities.UpdateMessageData +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.getGroup +import org.session.libsession.utilities.waitUntilGroupConfigsPushed +import org.session.libsignal.exceptions.NonRetryableException +import org.session.libsignal.protos.SignalServiceProtos.DataMessage +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.LokiAPIDatabase +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.dependencies.ConfigFactory + +@HiltWorker +class GroupLeavingWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val storage: Storage, + private val configFactory: ConfigFactory, + private val groupScope: GroupScope, + private val lokiAPIDatabase: LokiAPIDatabase, +) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + val groupId = requireNotNull(inputData.getString(KEY_GROUP_ID)) { + "Group ID must be provided" + }.let(::AccountId) + + Log.d(TAG, "Group leaving work started for $groupId") + + return groupScope.launchAndWait(groupId, "GroupLeavingWorker") { + val group = configFactory.getGroup(groupId) + + // Make sure we only have one group leaving control message + storage.deleteGroupInfoMessages(groupId, UpdateMessageData.Kind.GroupLeaving::class.java) + storage.insertGroupInfoLeaving(groupId) + + try { + if (group?.destroyed != true) { + // Only send the left/left notification group message when we are not kicked and we are not the only admin (only admin has a special treatment) + val weAreTheOnlyAdmin = configFactory.withGroupConfigs(groupId) { config -> + val allMembers = config.groupMembers.all() + allMembers.count { it.admin } == 1 && + allMembers.first { it.admin } + .accountId() == storage.getUserPublicKey() + } + + if (group != null && !group.kicked && !weAreTheOnlyAdmin) { + val address = Address.fromSerialized(groupId.hexString) + + // Always send a "XXX left" message to the group if we can + MessageSender.send( + GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberLeftNotificationMessage(DataMessage.GroupUpdateMemberLeftNotificationMessage.getDefaultInstance()) + .build() + ), + address + ) + + // If we are not the only admin, send a left message for other admin to handle the member removal + MessageSender.send( + GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance()) + .build() + ), + address, + ) + } + + // If we are the only admin, leaving this group will destroy the group + if (weAreTheOnlyAdmin) { + configFactory.withMutableGroupConfigs(groupId) { configs -> + configs.groupInfo.destroyGroup() + } + + // Must wait until the config is pushed, otherwise if we go through the rest + // of the code it will destroy the conversation, destroying the necessary configs + // along the way, we won't be able to push the "destroyed" state anymore. + configFactory.waitUntilGroupConfigsPushed(groupId, timeoutMills = 0L) + } + } + + // Delete conversation and group configs + storage.getThreadId(Address.fromSerialized(groupId.hexString)) + ?.let(storage::deleteConversation) + configFactory.removeGroup(groupId) + lokiAPIDatabase.clearLastMessageHashes(groupId.hexString) + lokiAPIDatabase.clearReceivedMessageHashValues(groupId.hexString) + Log.d(TAG, "Group $groupId left successfully") + Result.success() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + storage.insertGroupInfoErrorQuit(groupId) + Log.e(TAG, "Failed to leave group $groupId", e) + if (e is NonRetryableException) { + Result.failure() + } else { + Result.retry() + } + } finally { + storage.deleteGroupInfoMessages(groupId, UpdateMessageData.Kind.GroupLeaving::class.java) + } + } + } + + companion object { + private const val TAG = "GroupLeavingWorker" + + private const val KEY_GROUP_ID = "group_id" + + fun schedule(context: Context, groupId: AccountId) { + WorkManager.getInstance(context) + .enqueue( + OneTimeWorkRequestBuilder() + .addTag(KEY_GROUP_ID) + .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED)) + .setInputData( + Data.Builder().putString(KEY_GROUP_ID, groupId.hexString).build() + ) + .build() + ) + } + } +} 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 d4c5acf4ed..b1a0c5d9fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -63,7 +63,6 @@ public static long getThreadIDFromGroupID(String groupID, @NonNull Context cont long threadID = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor( groupRecipient, DistributionTypes.CONVERSATION); - DatabaseComponent.get(context).threadDatabase().setThreadArchived(threadID); return new GroupActionResult(groupRecipient, threadID); } @@ -76,7 +75,7 @@ public static boolean deleteGroup(@NonNull String groupId, long threadId = threadDatabase.getThreadIdIfExistsFor(groupRecipient); if (threadId != -1L) { - threadDatabase.deleteConversation(threadId); + DatabaseComponent.get(context).storage().deleteConversation(threadId); } return groupDatabase.delete(groupId); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index dc1ea686f5..5eb6fa1602 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.groups import android.content.Context import com.google.protobuf.ByteString +import com.squareup.phrase.Phrase import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -12,6 +13,9 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import network.loki.messenger.R import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.Namespace +import network.loki.messenger.libsession_util.util.Bytes.Companion.toBytes import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.GroupInfo @@ -32,7 +36,6 @@ import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.MessageAuthentication.buildDeleteMemberContentSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.snode.SnodeAPI @@ -42,6 +45,7 @@ import org.session.libsession.snode.model.BatchResponse import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.waitUntilGroupConfigsPushed @@ -54,7 +58,6 @@ import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateM import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Namespace import org.thoughtcrime.securesms.configs.ConfigUploader import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase @@ -95,10 +98,16 @@ class GroupManagerV2Impl @Inject constructor( return checkNotNull( configFactory.getGroup(group) ?.adminKey + ?.data ?.takeIf { it.isNotEmpty() } ) { "Only admin is allowed to invite members" } } + // Comparator to sort group members, ensuring a consistent order. + // This is more for the benefit of testing rather than correctness. + private val groupMemberComparator: GroupMemberComparator get() = + GroupMemberComparator(AccountId(requireNotNull(storage.getUserPublicKey()) { "User not logged in"})) + override suspend fun createGroup( groupName: String, groupDescription: String, @@ -119,8 +128,8 @@ class GroupManagerV2Impl @Inject constructor( ) } - val adminKey = checkNotNull(group.adminKey) { "Admin key is null for new group creation." } - val groupId = group.groupAccountId + val adminKey = checkNotNull(group.adminKey?.data) { "Admin key is null for new group creation." } + val groupId = AccountId(group.groupAccountId) val memberAsRecipients = members.map { Recipient.from(application, Address.fromSerialized(it.hexString), false) @@ -136,7 +145,7 @@ class GroupManagerV2Impl @Inject constructor( // Add members for (member in memberAsRecipients) { newGroupConfigs.groupMembers.set( - newGroupConfigs.groupMembers.getOrConstruct(member.address.serialize()).apply { + newGroupConfigs.groupMembers.getOrConstruct(member.address.toString()).apply { setName(member.name) setProfilePic(member.profileAvatar?.let { url -> member.profileKey?.let { key -> UserPic(url, key) } @@ -267,7 +276,7 @@ class GroupManagerV2Impl @Inject constructor( } configs.rekey() - newMembers.map { configs.groupKeys.getSubAccountToken(it) } + newMembers.map { configs.groupKeys.getSubAccountToken(it.hexString) } } // Call un-revocate API on new members, in case they have been removed before @@ -299,6 +308,8 @@ class GroupManagerV2Impl @Inject constructor( configs.groupInfo.getName().orEmpty() } + Log.w(TAG, "Failed to invite members to group $group", e) + throw GroupInviteException( isPromotion = false, inviteeAccountIds = newMembers.map { it.hexString }, @@ -330,16 +341,16 @@ class GroupManagerV2Impl @Inject constructor( newMembers: Collection, ) { val timestamp = clock.currentTimeMills() - val signature = SodiumUtilities.sign( - buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.ADDED, timestamp), - adminKey + val signature = ED25519.sign( + message = buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.ADDED, timestamp), + ed25519PrivateKey = adminKey ) val updatedMessage = GroupUpdated( GroupUpdateMessage.newBuilder() .setMemberChangeMessage( GroupUpdateMemberChangeMessage.newBuilder() - .addAllMemberSessionIds(newMembers.map { it.hexString }) + .addAllMemberSessionIds(newMembers.sortedWith(groupMemberComparator).map { it.hexString }) .setType(GroupUpdateMemberChangeMessage.Type.ADDED) .setAdminSignature(ByteString.copyFrom(signature)) ) @@ -367,18 +378,18 @@ class GroupManagerV2Impl @Inject constructor( ) val timestamp = clock.currentTimeMills() - val signature = SodiumUtilities.sign( - buildMemberChangeSignature( + val signature = ED25519.sign( + message = buildMemberChangeSignature( GroupUpdateMemberChangeMessage.Type.REMOVED, timestamp ), - adminKey + ed25519PrivateKey = adminKey ) val updateMessage = GroupUpdateMessage.newBuilder() .setMemberChangeMessage( GroupUpdateMemberChangeMessage.newBuilder() - .addAllMemberSessionIds(removedMembers.map { it.hexString }) + .addAllMemberSessionIds(removedMembers.sortedWith(groupMemberComparator).map { it.hexString }) .setType(GroupUpdateMemberChangeMessage.Type.REMOVED) .setAdminSignature(ByteString.copyFrom(signature)) ) @@ -401,7 +412,7 @@ class GroupManagerV2Impl @Inject constructor( if (threadId != null) { for (member in members) { for (msg in mmsSmsDatabase.getUserMessages(threadId, member.hexString)) { - val serverHash = lokiDatabase.getMessageServerHash(msg.id, msg.isMms) + val serverHash = lokiDatabase.getMessageServerHash(msg.messageId) if (serverHash != null) { messagesToDelete.add(serverHash) } @@ -415,16 +426,32 @@ class GroupManagerV2Impl @Inject constructor( return@launchAndWait } - val groupAdminAuth = configFactory.getGroup(groupAccountId)?.adminKey?.let { + val groupAdminAuth = configFactory.getGroup(groupAccountId)?.adminKey?.data?.let { OwnedSwarmAuth.ofClosedGroup(groupAccountId, it) } ?: return@launchAndWait SnodeAPI.deleteMessage(groupAccountId.hexString, groupAdminAuth, messagesToDelete) } + override suspend fun clearAllMessagesForEveryone(groupAccountId: AccountId, deletedHashes: List) { + // only admins can perform these tasks + val groupAdminAuth = configFactory.getGroup(groupAccountId)?.adminKey?.data?.let { + OwnedSwarmAuth.ofClosedGroup(groupAccountId, it) + } ?: return + + // change the delete_before + configFactory.withMutableGroupConfigs(groupAccountId) { configs -> + configs.groupInfo.setDeleteBefore(clock.currentTimeSeconds()) + } + + // remove messages from swarm SnodeAPI.deleteMessage + val cleanedHashes: List = deletedHashes.filter { !it.isNullOrEmpty() }.filterNotNull() + if(cleanedHashes.isNotEmpty()) SnodeAPI.deleteMessage(groupAccountId.hexString, groupAdminAuth, cleanedHashes) + } + override suspend fun handleMemberLeftMessage(memberId: AccountId, group: AccountId) = scope.launchAndWait(group, "Handle member left message") { val closedGroup = configFactory.getGroup(group) ?: return@launchAndWait - val groupAdminKey = closedGroup.adminKey + val groupAdminKey = closedGroup.adminKey?.data if (groupAdminKey != null) { flagMembersForRemoval( @@ -437,73 +464,11 @@ class GroupManagerV2Impl @Inject constructor( } override suspend fun leaveGroup(groupId: AccountId) { - scope.launchAndWait(groupId, "Leave group") { - withContext(SupervisorJob()) { - val group = configFactory.getGroup(groupId) - - storage.insertGroupInfoLeaving(groupId) - - try { - if (group?.destroyed != true) { - // Only send the left/left notification group message when we are not kicked and we are not the only admin (only admin has a special treatment) - val weAreTheOnlyAdmin = configFactory.withGroupConfigs(groupId) { config -> - val allMembers = config.groupMembers.all() - allMembers.count { it.admin } == 1 && - allMembers.first { it.admin } - .accountIdString() == storage.getUserPublicKey() - } - - if (group != null && !group.kicked && !weAreTheOnlyAdmin) { - val address = Address.fromSerialized(groupId.hexString) - - // Always send a "XXX left" message to the group if we can - MessageSender.send( - GroupUpdated( - GroupUpdateMessage.newBuilder() - .setMemberLeftNotificationMessage(DataMessage.GroupUpdateMemberLeftNotificationMessage.getDefaultInstance()) - .build() - ), - address - ) - - // If we are not the only admin, send a left message for other admin to handle the member removal - MessageSender.send( - GroupUpdated( - GroupUpdateMessage.newBuilder() - .setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance()) - .build() - ), - address, - ) - } - - // If we are the only admin, leaving this group will destroy the group - if (weAreTheOnlyAdmin) { - configFactory.withMutableGroupConfigs(groupId) { configs -> - configs.groupInfo.destroyGroup() - } + // Insert the control message immediately so we can see the leaving message + storage.insertGroupInfoLeaving(groupId) - // Must wait until the config is pushed, otherwise if we go through the rest - // of the code it will destroy the conversation, destroying the necessary configs - // along the way, we won't be able to push the "destroyed" state anymore. - configFactory.waitUntilGroupConfigsPushed(groupId) - } - } - - // Delete conversation and group configs - storage.getThreadId(Address.fromSerialized(groupId.hexString)) - ?.let(storage::deleteConversation) - configFactory.removeGroup(groupId) - lokiAPIDatabase.clearLastMessageHashes(groupId.hexString) - lokiAPIDatabase.clearReceivedMessageHashValues(groupId.hexString) - } catch (e: Exception) { - storage.insertGroupInfoErrorQuit(groupId) - throw e - } finally { - storage.deleteGroupInfoMessages(groupId, UpdateMessageData.Kind.GroupLeaving::class.java) - } - } - } + // The group leaving work could start or wait depend on the network condition + GroupLeavingWorker.schedule(context = application, groupId) } override suspend fun promoteMember( @@ -525,16 +490,16 @@ class GroupManagerV2Impl @Inject constructor( // Build a group update message to the group telling members someone has been promoted val timestamp = clock.currentTimeMills() - val signature = SodiumUtilities.sign( - buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.PROMOTED, timestamp), - adminKey + val signature = ED25519.sign( + message = buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.PROMOTED, timestamp), + ed25519PrivateKey = adminKey ) val message = GroupUpdated( GroupUpdateMessage.newBuilder() .setMemberChangeMessage( GroupUpdateMemberChangeMessage.newBuilder() - .addAllMemberSessionIds(members.map { it.hexString }) + .addAllMemberSessionIds(members.sortedWith(groupMemberComparator).map { it.hexString }) .setType(GroupUpdateMemberChangeMessage.Type.PROMOTED) .setAdminSignature(ByteString.copyFrom(signature)) ) @@ -642,7 +607,10 @@ class GroupManagerV2Impl @Inject constructor( if (approved) { approveGroupInvite(group, groupInviteMessageHash) } else { - configFactory.withMutableUserConfigs { it.userGroups.eraseClosedGroup(groupId.hexString) } + configFactory.withMutableUserConfigs { + it.userGroups.eraseClosedGroup(groupId.hexString) + it.convoInfoVolatile.eraseClosedGroup(groupId.hexString) + } storage.deleteConversation(threadId) if (groupInviteMessageHash != null) { @@ -678,14 +646,15 @@ class GroupManagerV2Impl @Inject constructor( withTimeout(20_000L) { // We must tell the poller to poll once, as we could have received this invitation // in the background where the poller isn't running - groupPollerManager.pollOnce(group.groupAccountId) + val groupId = AccountId(group.groupAccountId) + groupPollerManager.pollOnce(groupId) - groupPollerManager.watchGroupPollingState(group.groupAccountId) + groupPollerManager.watchGroupPollingState(groupId) .filter { it.hadAtLeastOneSuccessfulPoll } .first() } - val adminKey = group.adminKey + val adminKey = group.adminKey?.data if (adminKey == null) { // Send an invite response to the group if we are invited as a regular member val inviteResponse = GroupUpdateInviteResponseMessage.newBuilder() @@ -696,12 +665,12 @@ class GroupManagerV2Impl @Inject constructor( // this will fail the first couple of times :) MessageSender.sendNonDurably( responseMessage, - Destination.ClosedGroup(group.groupAccountId.hexString), + Destination.ClosedGroup(group.groupAccountId), isSyncMessage = false ) } else { // If we are invited as admin, we can just update the group info ourselves - configFactory.withMutableGroupConfigs(group.groupAccountId) { configs -> + configFactory.withMutableGroupConfigs(AccountId(group.groupAccountId)) { configs -> configs.groupKeys.loadAdminKey(adminKey) configs.groupMembers.get(key)?.let { member -> @@ -777,7 +746,7 @@ class GroupManagerV2Impl @Inject constructor( val adminKey = GroupInfo.ClosedGroupInfo.adminKeyFromSeed(adminKeySeed) configFactory.withMutableUserConfigs { - it.userGroups.set(group.copy(adminKey = adminKey)) + it.userGroups.set(group.copy(adminKey = adminKey.toBytes())) } // Update our promote state @@ -830,9 +799,9 @@ class GroupManagerV2Impl @Inject constructor( val shouldAutoApprove = storage.getRecipientApproved(Address.fromSerialized(inviter.hexString)) val closedGroupInfo = GroupInfo.ClosedGroupInfo( - groupAccountId = groupId, - adminKey = authDataOrAdminSeed.takeIf { fromPromotion }?.let { GroupInfo.ClosedGroupInfo.adminKeyFromSeed(it) }, - authData = authDataOrAdminSeed.takeIf { !fromPromotion }, + groupAccountId = groupId.hexString, + adminKey = authDataOrAdminSeed.takeIf { fromPromotion }?.let { GroupInfo.ClosedGroupInfo.adminKeyFromSeed(it) }?.toBytes(), + authData = authDataOrAdminSeed.takeIf { !fromPromotion }?.toBytes(), priority = PRIORITY_VISIBLE, invited = !shouldAutoApprove, name = groupName, @@ -885,7 +854,7 @@ class GroupManagerV2Impl @Inject constructor( } val adminKey = configFactory.getGroup(groupId)?.adminKey - if (adminKey == null || adminKey.isEmpty()) { + if (adminKey == null || adminKey.data.isEmpty()) { return@launchAndWait // We don't have the admin key, we can't process the invite response } @@ -955,9 +924,9 @@ class GroupManagerV2Impl @Inject constructor( } val timestamp = clock.currentTimeMills() - val signature = SodiumUtilities.sign( - buildInfoChangeSignature(GroupUpdateInfoChangeMessage.Type.NAME, timestamp), - adminKey + val signature = ED25519.sign( + message = buildInfoChangeSignature(GroupUpdateInfoChangeMessage.Type.NAME, timestamp), + ed25519PrivateKey = adminKey ) val message = GroupUpdated( @@ -977,6 +946,17 @@ class GroupManagerV2Impl @Inject constructor( MessageSender.sendAndAwait(message, Address.fromSerialized(groupId.hexString)) } + override suspend fun setDescription(groupId: AccountId, newDescription: String): Unit = + scope.launchAndWait(groupId, "Set group description") { + requireAdminAccess(groupId) + + configFactory.withMutableGroupConfigs(groupId) { configs -> + if (configs.groupInfo.getDescription() != newDescription) { + configs.groupInfo.setDescription(newDescription) + } + } + } + override suspend fun requestMessageDeletion( groupId: AccountId, messageHashes: Set @@ -1006,7 +986,7 @@ class GroupManagerV2Impl @Inject constructor( } // If we are admin, we can delete the messages from the group swarm - group.adminKey?.let { adminKey -> + group.adminKey?.data?.let { adminKey -> SnodeAPI.deleteMessage( publicKey = groupId.hexString, swarmAuth = OwnedSwarmAuth.ofClosedGroup(groupId, adminKey), @@ -1016,14 +996,14 @@ class GroupManagerV2Impl @Inject constructor( // Construct a message to ask members to delete the messages, sign if we are admin, then send val timestamp = clock.currentTimeMills() - val signature = group.adminKey?.let { key -> - SodiumUtilities.sign( - buildDeleteMemberContentSignature( + val signature = group.adminKey?.data?.let { key -> + ED25519.sign( + message = buildDeleteMemberContentSignature( memberIds = emptyList(), messageHashes, timestamp ), - key + ed25519PrivateKey = key ) } val message = GroupUpdated( @@ -1093,7 +1073,7 @@ class GroupManagerV2Impl @Inject constructor( } // Delete from swarm if we are admin - val adminKey = configFactory.getGroup(groupId)?.adminKey + val adminKey = configFactory.getGroup(groupId)?.adminKey?.data if (adminKey != null) { // If hashes are given, these are the messages to delete. To be able to delete these @@ -1160,9 +1140,9 @@ class GroupManagerV2Impl @Inject constructor( // Construct a message to notify the group members about the expiration timer change val timestamp = clock.currentTimeMills() - val signature = SodiumUtilities.sign( - buildInfoChangeSignature(GroupUpdateInfoChangeMessage.Type.DISAPPEARING_MESSAGES, timestamp), - adminKey + val signature = ED25519.sign( + message = buildInfoChangeSignature(GroupUpdateInfoChangeMessage.Type.DISAPPEARING_MESSAGES, timestamp), + ed25519PrivateKey = adminKey ) val message = GroupUpdated( @@ -1185,6 +1165,47 @@ class GroupManagerV2Impl @Inject constructor( storage.insertGroupInfoChange(message, groupId) } + override fun getLeaveGroupConfirmationDialogData(groupId: AccountId, name: String): GroupManagerV2.ConfirmDialogData? { + val groupData = configFactory.getGroup(groupId) ?: return null + + var title = R.string.groupDelete + var message: CharSequence = "" + var positiveButton = R.string.delete + var positiveQaTag = R.string.qa_conversation_settings_dialog_delete_group_confirm + var negativeQaTag = R.string.qa_conversation_settings_dialog_delete_group_cancel + + + if(!groupData.shouldPoll){ + message = Phrase.from(application, R.string.groupDeleteDescriptionMember) + .put(GROUP_NAME_KEY, name) + .format() + + } else if (groupData.hasAdminKey()) { + message = Phrase.from(application, R.string.groupDeleteDescription) + .put(GROUP_NAME_KEY, name) + .format() + } else { + message = Phrase.from(application, R.string.groupLeaveDescription) + .put(GROUP_NAME_KEY, name) + .format() + + title = R.string.groupLeave + positiveButton = R.string.leave + positiveQaTag = R.string.qa_conversation_settings_dialog_leave_group_confirm + negativeQaTag = R.string.qa_conversation_settings_dialog_leave_group_cancel + } + + + return GroupManagerV2.ConfirmDialogData( + title = application.getString(title), + message = message, + positiveText = positiveButton, + negativeText = R.string.cancel, + positiveQaTag = positiveQaTag, + negativeQaTag = negativeQaTag, + ) + } + private fun BatchResponse.requireAllRequestsSuccessful(errorMessage: String) { val firstError = this.results.firstOrNull { it.code != 200 } require(firstError == null) { "$errorMessage: ${firstError!!.body}" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMemberComparator.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMemberComparator.kt new file mode 100644 index 0000000000..93bc3958d9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMemberComparator.kt @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.groups + +import org.session.libsignal.utilities.AccountId + +/** + * A comparator for group members that ensures the current user appears first in the list, + * then sorts the rest of the members by their pub key. + */ +class GroupMemberComparator( + private val currentUserAccountId: AccountId +) : Comparator { + override fun compare(o1: AccountId, o2: AccountId): Int { + if (o1 == currentUserAccountId) { + return -1 // Current user should come first + } else if (o2 == currentUserAccountId) { + return 1 // Current user should come first + } else { + return o1.hexString.compareTo(o2.hexString) // Compare other members normally + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersActivity.kt index a02852a1aa..b5a43d0788 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersActivity.kt @@ -1,37 +1,38 @@ package org.thoughtcrime.securesms.groups -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.compose.setContent +import androidx.compose.runtime.Composable +import androidx.hilt.navigation.compose.hiltViewModel import dagger.hilt.android.AndroidEntryPoint import org.session.libsignal.utilities.AccountId -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.groups.compose.EditGroupScreen +import org.thoughtcrime.securesms.FullComposeScreenLockActivity import org.thoughtcrime.securesms.groups.compose.GroupMembersScreen -import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme +/** + * Forced to add an activity entry point for this screen + * (which is otherwise accessed without an activity through the ConversationSettingsNavHost) + * because this is navigated to from the conversation app bar + */ @AndroidEntryPoint -class GroupMembersActivity: PassphraseRequiredActionBarActivity() { +class GroupMembersActivity: FullComposeScreenLockActivity() { - companion object { - private const val EXTRA_GROUP_ID = "GroupMembersActivity_groupID" + private val groupId: String by lazy { + intent.getStringExtra(GROUP_ID) ?: "" + } - fun createIntent(context: Context, groupSessionId: String): Intent { - return Intent(context, GroupMembersActivity::class.java).apply { - putExtra(EXTRA_GROUP_ID, groupSessionId) + @Composable + override fun ComposeContent() { + val viewModel: GroupMembersViewModel = + hiltViewModel { factory -> + factory.create(AccountId(groupId)) } - } + + GroupMembersScreen( + viewModel = viewModel, + onBack = { finish() }, + ) } - override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { - setContent { - SessionMaterialTheme { - GroupMembersScreen ( - groupId = AccountId(intent.getStringExtra(EXTRA_GROUP_ID)!!), - onBack = this::finish - ) - } - } + companion object { + const val GROUP_ID = "group_id" } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersViewModel.kt index 6c03f96179..1180a8de5e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersViewModel.kt @@ -1,26 +1,60 @@ package org.thoughtcrime.securesms.groups import android.content.Context +import android.content.Intent +import androidx.lifecycle.viewModelScope import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.session.libsession.database.StorageProtocol +import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.UsernameUtils import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.util.AvatarUtils @HiltViewModel(assistedFactory = GroupMembersViewModel.Factory::class) class GroupMembersViewModel @AssistedInject constructor( @Assisted private val groupId: AccountId, - @ApplicationContext context: Context, - storage: StorageProtocol, - configFactory: ConfigFactoryProtocol -) : BaseGroupMembersViewModel(groupId, context, storage, configFactory) { + @ApplicationContext private val context: Context, + private val storage: StorageProtocol, + proStatusManager: ProStatusManager, + configFactory: ConfigFactoryProtocol, + usernameUtils: UsernameUtils, + avatarUtils: AvatarUtils +) : BaseGroupMembersViewModel(groupId, context, storage, usernameUtils, configFactory, avatarUtils, proStatusManager) { + + private val _navigationActions = Channel() + val navigationActions get() = _navigationActions.receiveAsFlow() @AssistedFactory interface Factory { fun create(groupId: AccountId): GroupMembersViewModel } + + fun onMemberClicked(accountId: AccountId) { + viewModelScope.launch(Dispatchers.Default) { + val address = Address.fromSerialized(accountId.hexString) + val threadId = storage.getThreadId(address) + + val intent = Intent( + context, + ConversationActivityV2::class.java + ) + intent.putExtra(ConversationActivityV2.ADDRESS, address) + intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) + + _navigationActions.send(intent) + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt index 84012a6d1b..a7edacf787 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt @@ -1,5 +1,8 @@ package org.thoughtcrime.securesms.groups +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.async @@ -13,6 +16,7 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope +import network.loki.messenger.libsession_util.Namespace import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.MessageReceiveParameters @@ -29,7 +33,6 @@ import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.Snode import org.thoughtcrime.securesms.util.AppVisibilityManager import org.thoughtcrime.securesms.util.getRootCause @@ -37,14 +40,15 @@ import java.time.Instant import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.days -class GroupPoller( - scope: CoroutineScope, - private val groupId: AccountId, +class GroupPoller @AssistedInject constructor( + @Assisted scope: CoroutineScope, + @Assisted private val groupId: AccountId, private val configFactoryProtocol: ConfigFactoryProtocol, private val lokiApiDatabase: LokiAPIDatabaseProtocol, private val clock: SnodeClock, private val appVisibilityManager: AppVisibilityManager, private val groupRevokedMessageHandler: GroupRevokedMessageHandler, + private val batchMessageReceiveJobFactory: BatchMessageReceiveJob.Factory, ) { companion object { private const val POLL_INTERVAL = 3_000L @@ -202,9 +206,9 @@ class GroupPoller( configFactoryProtocol.getGroupAuth(groupId) ?: return@supervisorScope val configHashesToExtends = configFactoryProtocol.withGroupConfigs(groupId) { buildSet { - addAll(it.groupKeys.currentHashes()) - addAll(it.groupInfo.currentHashes()) - addAll(it.groupMembers.currentHashes()) + addAll(it.groupKeys.activeHashes()) + addAll(it.groupInfo.activeHashes()) + addAll(it.groupMembers.activeHashes()) } } @@ -429,7 +433,10 @@ class GroupPoller( rawResponse = body, snode = snode, publicKey = groupId.hexString, - decrypt = it.groupKeys::decrypt, + decrypt = { data -> + val (decrypted, sender) = it.groupKeys.decrypt(data) ?: return@parseRawMessagesResponse null + decrypted to AccountId(sender) + }, namespace = Namespace.GROUP_MESSAGES(), ) } @@ -443,8 +450,7 @@ class GroupPoller( } parameters.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { chunk -> - val job = BatchMessageReceiveJob(chunk) - JobQueue.shared.add(job) + JobQueue.shared.add(batchMessageReceiveJobFactory.create(chunk)) } if (messages.isNotEmpty()) { @@ -457,4 +463,9 @@ class GroupPoller( * one token will trigger one poll, as the poller may batch multiple requests together. */ private data class PollOnceToken(val resultCallback: SendChannel) + + @AssistedFactory + interface Factory { + fun create(scope: CoroutineScope, groupId: AccountId): GroupPoller + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPollerManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPollerManager.kt index cd58bc11a2..38932fdc6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPollerManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPollerManager.kt @@ -24,14 +24,12 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.supervisorScope -import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.ConfigUpdateNotification import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.util.AppVisibilityManager +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.util.NetworkConnectivity import javax.inject.Inject import javax.inject.Singleton @@ -52,13 +50,10 @@ import javax.inject.Singleton @Singleton class GroupPollerManager @Inject constructor( configFactory: ConfigFactory, - lokiApiDatabase: LokiAPIDatabaseProtocol, - clock: SnodeClock, preferences: TextSecurePreferences, - appVisibilityManager: AppVisibilityManager, connectivity: NetworkConnectivity, - groupRevokedMessageHandler: GroupRevokedMessageHandler, -) { + pollFactory: GroupPoller.Factory, +) : OnAppStartupComponent { @Suppress("OPT_IN_USAGE") private val groupPollers: StateFlow> = combine( @@ -78,6 +73,7 @@ class GroupPollerManager @Inject constructor( configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() } .mapNotNullTo(hashSetOf()) { group -> group.groupAccountId.takeIf { group.shouldPoll } + ?.let(::AccountId) } } .distinctUntilChanged() @@ -108,14 +104,9 @@ class GroupPollerManager @Inject constructor( Log.d(TAG, "Starting poller for $groupId") val scope = CoroutineScope(Dispatchers.Default) poller = GroupPollerHandle( - poller = GroupPoller( + poller = pollFactory.create( scope = scope, groupId = groupId, - configFactoryProtocol = configFactory, - lokiApiDatabase = lokiApiDatabase, - clock = clock, - appVisibilityManager = appVisibilityManager, - groupRevokedMessageHandler = groupRevokedMessageHandler, ), scope = scope ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupRevokedMessageHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupRevokedMessageHandler.kt index eee2117809..5153ceb531 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupRevokedMessageHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupRevokedMessageHandler.kt @@ -1,6 +1,6 @@ package org.thoughtcrime.securesms.groups -import network.loki.messenger.libsession_util.util.Sodium +import network.loki.messenger.libsession_util.util.MultiEncrypt import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.utilities.ConfigFactoryProtocol @@ -22,7 +22,7 @@ class GroupRevokedMessageHandler @Inject constructor( rawMessages.forEach { data -> val decoded = configFactoryProtocol.decryptForUser( data, - Sodium.KICKED_DOMAIN, + MultiEncrypt.KICKED_DOMAIN, groupId, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt deleted file mode 100644 index 8590372cf4..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt +++ /dev/null @@ -1,163 +0,0 @@ -package org.thoughtcrime.securesms.groups - -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import com.google.android.material.tabs.TabLayoutMediator -import com.squareup.phrase.Phrase -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import network.loki.messenger.R -import network.loki.messenger.databinding.FragmentJoinCommunityBinding -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.GroupUtil -import org.session.libsession.utilities.OpenGroupUrlParser -import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY -import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate -import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.ui.getSubbedString -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities - -@AndroidEntryPoint -class JoinCommunityFragment : Fragment() { - - private lateinit var binding: FragmentJoinCommunityBinding - - lateinit var delegate: StartConversationDelegate - - var lastUrl: String? = null - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentJoinCommunityBinding.inflate(inflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.backButton.setOnClickListener { delegate.onDialogBackPressed() } - binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() } - - fun showLoader() { - binding.loader.visibility = View.VISIBLE - binding.loader.animate().setDuration(150).alpha(1.0f).start() - } - - fun hideLoader() { - binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { - - override fun onAnimationEnd(animation: Animator) { - super.onAnimationEnd(animation) - binding.loader.visibility = View.GONE - } - }) - } - - fun joinCommunityIfPossible(url: String) { - // Currently this won't try again on a failed URL but once we rework the whole - // fragment into Compose with a ViewModel this won't be an issue anymore as the error - // and state will come from Flows. - if(lastUrl == url) return - lastUrl = url - - lifecycleScope.launch(Dispatchers.Main) { - val openGroup = try { - OpenGroupUrlParser.parseUrl(url) - } catch (e: OpenGroupUrlParser.Error) { - when (e) { - is OpenGroupUrlParser.Error.MalformedURL, OpenGroupUrlParser.Error.NoRoom -> { - return@launch Toast.makeText( - activity, - context?.resources?.getString(R.string.communityJoinError), - Toast.LENGTH_SHORT - ).show() - } - - is OpenGroupUrlParser.Error.InvalidPublicKey, OpenGroupUrlParser.Error.NoPublicKey -> { - return@launch Toast.makeText( - activity, - R.string.communityEnterUrlErrorInvalidDescription, - Toast.LENGTH_SHORT - ).show() - } - } - } - - showLoader() - - withContext(Dispatchers.IO) { - try { - val sanitizedServer = openGroup.server.removeSuffix("/") - val openGroupID = "$sanitizedServer.${openGroup.room}" - OpenGroupManager.add( - sanitizedServer, - openGroup.room, - openGroup.serverPublicKey, - requireContext() - ) - val storage = MessagingModuleConfiguration.shared.storage - storage.onOpenGroupAdded(sanitizedServer, openGroup.room) - val threadID = - GroupManager.getOpenGroupThreadID(openGroupID, requireContext()) - val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) - - withContext(Dispatchers.Main) { - val recipient = Recipient.from( - requireContext(), - Address.fromSerialized(groupID), - false - ) - openConversationActivity(requireContext(), threadID, recipient) - delegate.onDialogClosePressed() - } - } catch (e: Exception) { - Log.e("Loki", "Couldn't join community.", e) - withContext(Dispatchers.Main) { - hideLoader() - val txt = context?.getSubbedString(R.string.groupErrorJoin, - GROUP_NAME_KEY to url) - Toast.makeText(activity, txt, Toast.LENGTH_SHORT).show() - } - } - } - } - } - val urlDelegate = { url: String -> joinCommunityIfPossible(url) } - binding.viewPager.adapter = JoinCommunityFragmentAdapter( - parentFragment = this, - enterCommunityUrlDelegate = urlDelegate, - scanQrCodeDelegate = urlDelegate - ) - val mediator = TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, pos -> - tab.text = when (pos) { - 0 -> getString(R.string.communityUrl) - 1 -> getString(R.string.qrScan) - else -> throw IllegalStateException() - } - } - mediator.attach() - } - - private fun openConversationActivity(context: Context, threadId: Long, recipient: Recipient) { - val intent = Intent(context, ConversationActivityV2::class.java) - intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) - intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) - context.startActivity(intent) - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragmentAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragmentAdapter.kt deleted file mode 100644 index a49635476b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragmentAdapter.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.thoughtcrime.securesms.groups - -import androidx.fragment.app.Fragment -import androidx.viewpager2.adapter.FragmentStateAdapter -import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment -import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate - -class JoinCommunityFragmentAdapter( - private val parentFragment: Fragment, - private val enterCommunityUrlDelegate: EnterCommunityUrlDelegate, - private val scanQrCodeDelegate: ScanQRCodeWrapperFragmentDelegate -) : FragmentStateAdapter(parentFragment) { - - override fun getItemCount(): Int = 2 - - override fun createFragment(position: Int): Fragment { - return when (position) { - 0 -> EnterCommunityUrlFragment().apply { delegate = enterCommunityUrlDelegate } - 1 -> ScanQRCodeWrapperFragment().apply { delegate = scanQrCodeDelegate } - else -> throw IllegalStateException() - } - } -} \ No newline at end of file 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 01c1564ae1..b343e63e03 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -2,87 +2,53 @@ package org.thoughtcrime.securesms.groups import android.content.Context import android.widget.Toast -import androidx.annotation.WorkerThread import com.squareup.phrase.Phrase import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import java.util.concurrent.Executors import network.loki.messenger.R import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.database.StorageProtocol 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.libsession.messaging.sending_receiving.pollers.OpenGroupPollerManager import org.session.libsession.snode.utilities.await +import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.StringSubstitutionConstants.COMMUNITY_NAME_KEY import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.dependencies.DatabaseComponent - -object OpenGroupManager { - private val executorService = Executors.newScheduledThreadPool(4) - private val pollers = mutableMapOf() // One for each server - private var isPolling = false - private val pollUpdaterLock = Any() - - val isAllCaughtUp: Boolean - get() { - pollers.values.forEach { poller -> - val jobID = poller.secondToLastJob?.id - jobID?.let { - val storage = MessagingModuleConfiguration.shared.storage - if (storage.getMessageReceiveJob(jobID) == null) { - // If the second to last job is done, it means we are now handling the last job - poller.isCaughtUp = true - poller.secondToLastJob = null - } - } - if (!poller.isCaughtUp) { return false } - } - return true - } +import org.thoughtcrime.securesms.database.GroupMemberDatabase +import org.thoughtcrime.securesms.database.LokiThreadDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manage common operations for open groups, such as adding, deleting, and updating them. + */ +@Singleton +class OpenGroupManager @Inject constructor( + private val storage: StorageProtocol, + private val lokiThreadDB: LokiThreadDatabase, + private val threadDb: ThreadDatabase, + private val configFactory: ConfigFactoryProtocol, + private val groupMemberDatabase: GroupMemberDatabase, + private val pollerManager: OpenGroupPollerManager, +) { // flow holding information on write access for our current communities private val _communityWriteAccess: MutableStateFlow> = MutableStateFlow(emptyMap()) - fun startPolling() { - if (isPolling) { return } - isPolling = true - val storage = MessagingModuleConfiguration.shared.storage - 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() } - } - } - } - - fun stopPolling() { - synchronized(pollUpdaterLock) { - pollers.forEach { it.value.stop() } - pollers.clear() - isPolling = false - } - } fun getCommunitiesWriteAccessFlow() = _communityWriteAccess.asStateFlow() - @WorkerThread - suspend fun add(server: String, room: String, publicKey: String, context: Context): Pair { + suspend fun add(server: String, room: String, publicKey: String, context: Context) { val openGroupID = "$server.$room" 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 threadID to null } + val existingOpenGroup = lokiThreadDB.getOpenGroupChat(threadID) + if (existingOpenGroup != null) { + return + } // Clear any existing data if needed storage.removeLastDeletionServerID(room, server) storage.removeLastMessageServerID(room, server) @@ -97,48 +63,28 @@ object OpenGroupManager { if (threadID < 0) { GroupManager.createOpenGroup(openGroupID, context, null, info.name) } + OpenGroupPoller.handleRoomPollInfo( + storage = storage, server = server, roomToken = room, pollInfo = info.toPollInfo(), - createGroupIfMissingWithPublicKey = publicKey + createGroupIfMissingWithPublicKey = publicKey, + memberDb = groupMemberDatabase, ) - return threadID to info - } - fun restartPollerForServer(server: String) { - // Start the poller if needed - synchronized(pollUpdaterLock) { - pollers[server]?.stop() - pollers[server]?.startIfNeeded() ?: run { - val poller = OpenGroupPoller(server, executorService) - Log.d("Loki", "Starting poller for open group: $server") - pollers[server] = poller - poller.startIfNeeded() - } - } + // If existing poller for the same server exist, we'll request a poll once now so new room + // can be polled immediately. + pollerManager.pollers.value[server]?.poller?.requestPollOnce() } - @WorkerThread fun delete(server: String, room: String, context: Context) { try { - val storage = MessagingModuleConfiguration.shared.storage - val configFactory = MessagingModuleConfiguration.shared.configFactory - val threadDB = DatabaseComponent.get(context).threadDatabase() 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() + val recipient = threadDb.getRecipientForThreadId(threadID) ?: return + val groupID = recipient.address.toString() // Stop the poller if needed - val openGroups = storage.getAllOpenGroups().filter { it.value.server == server } - if (openGroups.isNotEmpty()) { - synchronized(pollUpdaterLock) { - val poller = pollers[server] - poller?.stop() - pollers.remove(server) - } - } configFactory.withMutableUserConfigs { it.userGroups.eraseCommunity(server, room) it.convoInfoVolatile.eraseCommunity(server, room) @@ -148,7 +94,6 @@ object OpenGroupManager { storage.removeLastMessageServerID(room, server) storage.removeLastInboxMessageId(server) storage.removeLastOutboxMessageId(server) - val lokiThreadDB = DatabaseComponent.get(context).lokiThreadDatabase() lokiThreadDB.removeOpenGroupChat(threadID) storage.deleteConversation(threadID) // Must be invoked on a background thread GroupManager.deleteGroup(groupID, context) // Must be invoked on a background thread @@ -161,20 +106,18 @@ object OpenGroupManager { } } - @WorkerThread - suspend fun addOpenGroup(urlAsString: String, context: Context): OpenGroupApi.RoomInfo? { - val url = urlAsString.toHttpUrlOrNull() ?: return null + suspend fun addOpenGroup(urlAsString: String, context: Context) { + val url = urlAsString.toHttpUrlOrNull() ?: return val server = OpenGroup.getServer(urlAsString) - val room = url.pathSegments.firstOrNull() ?: return null - val publicKey = url.queryParameter("public_key") ?: return null + val room = url.pathSegments.firstOrNull() ?: return + val publicKey = url.queryParameter("public_key") ?: return - return add(server.toString().removeSuffix("/"), room, publicKey, context).second // assume migrated from calling function + add(server.toString().removeSuffix("/"), room, publicKey, context) // assume migrated from calling function } fun updateOpenGroup(openGroup: OpenGroup, context: Context) { - val threadDB = DatabaseComponent.get(context).lokiThreadDatabase() val threadID = GroupManager.getOpenGroupThreadID(openGroup.groupId, context) - threadDB.setOpenGroupChat(openGroup, threadID) + lokiThreadDB.setOpenGroupChat(openGroup, threadID) // update write access for this community val writeAccesses = _communityWriteAccess.value.toMutableMap() @@ -182,11 +125,13 @@ object OpenGroupManager { _communityWriteAccess.value = writeAccesses } - fun isUserModerator(context: Context, groupId: String, standardPublicKey: String, blindedPublicKey: String? = null): Boolean { - val memberDatabase = DatabaseComponent.get(context).groupMemberDatabase() - val standardRoles = memberDatabase.getGroupMemberRoles(groupId, standardPublicKey) - val blindedRoles = blindedPublicKey?.let { memberDatabase.getGroupMemberRoles(groupId, it) } ?: emptyList() - return standardRoles.any { it.isModerator } || blindedRoles.any { it.isModerator } + fun isUserModerator( + groupId: String, + standardPublicKey: String, + blindedPublicKey: String? = null + ): Boolean { + val standardRole = groupMemberDatabase.getGroupMemberRole(groupId, standardPublicKey) + val blindedRole = blindedPublicKey?.let { groupMemberDatabase.getGroupMemberRole(groupId, it) } + return standardRole?.isModerator == true || blindedRole?.isModerator == true } - } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt index 9702b81827..1f67fb319a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt @@ -8,12 +8,9 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -27,27 +24,31 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.home.search.getSearchName +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUtils @OptIn(FlowPreview::class) @HiltViewModel(assistedFactory = SelectContactsViewModel.Factory::class) -class SelectContactsViewModel @AssistedInject constructor( +open class SelectContactsViewModel @AssistedInject constructor( private val configFactory: ConfigFactory, + private val avatarUtils: AvatarUtils, + private val proStatusManager: ProStatusManager, @ApplicationContext private val appContext: Context, - @Assisted private val excludingAccountIDs: Set, - @Assisted private val scope: CoroutineScope, + @Assisted private val excludingAccountIDs: Set
, + @Assisted private val contactFiltering: (Recipient) -> Boolean, // default will filter out blocked and unapproved contacts ) : ViewModel() { // Input: The search query private val mutableSearchQuery = MutableStateFlow("") // Input: The selected contact account IDs - private val mutableSelectedContactAccountIDs = MutableStateFlow(emptySet()) + private val mutableSelectedContactAccountIDs = MutableStateFlow(emptySet
()) // Input: The manually added items to select from. This will be combined (and deduped) with the contacts // the user has. This is useful for selecting contacts that are not in the user's contacts list. - private val mutableManuallyAddedContacts = MutableStateFlow(emptySet()) + private val mutableManuallyAddedContacts = MutableStateFlow(emptySet
()) // Output: The search query val searchQuery: StateFlow get() = mutableSearchQuery @@ -61,15 +62,9 @@ class SelectContactsViewModel @AssistedInject constructor( ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) // Output - val currentSelected: Set + val currentSelected: Set
get() = mutableSelectedContactAccountIDs.value - override fun onCleared() { - super.onCleared() - - scope.cancel() - } - @OptIn(ExperimentalCoroutinesApi::class) private fun observeContacts() = (configFactory.configUpdateNotifications as Flow) .debounce(100L) @@ -80,45 +75,50 @@ class SelectContactsViewModel @AssistedInject constructor( val allContacts = (configFactory.withUserConfigs { configs -> configs.contacts.all() } .asSequence() - .map { AccountId(it.id) } + manuallyAdded) + .map { Address.fromSerialized(it.id) } + manuallyAdded) - if (excludingAccountIDs.isEmpty()) { + val recipientContacts = if (excludingAccountIDs.isEmpty()) { allContacts.toSet() } else { allContacts.filterNotTo(mutableSetOf()) { it in excludingAccountIDs } }.map { Recipient.from( appContext, - Address.fromSerialized(it.hexString), + it, false ) } + + recipientContacts.filter(contactFiltering) } } } - private fun filterContacts( + private suspend fun filterContacts( contacts: Collection, query: String, - selectedAccountIDs: Set + selectedAccountIDs: Set
): List { - return contacts - .asSequence() - .filter { query.isBlank() || it.getSearchName().contains(query, ignoreCase = true) } - .map { contact -> - val accountId = AccountId(contact.address.serialize()) - ContactItem( - name = contact.getSearchName(), - accountID = accountId, - selected = selectedAccountIDs.contains(accountId), + val items = mutableListOf() + for (contact in contacts) { + if (query.isBlank() || contact.getSearchName().contains(query, ignoreCase = true)) { + val avatarData = avatarUtils.getUIDataFromRecipient(contact) + items.add( + ContactItem( + name = contact.getSearchName(), + address = contact.address, + avatarUIData = avatarData, + selected = selectedAccountIDs.contains(contact.address), + showProBadge = proStatusManager.shouldShowProBadge(contact.address) + ) ) } - .toList() - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + } + return items.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) } - fun setManuallyAddedContacts(accountIDs: Set) { + fun setManuallyAddedContacts(accountIDs: Set
) { mutableManuallyAddedContacts.value = accountIDs } @@ -126,29 +126,39 @@ class SelectContactsViewModel @AssistedInject constructor( mutableSearchQuery.value = query } - fun onContactItemClicked(accountID: AccountId) { + open fun onContactItemClicked(address: Address) { val newSet = mutableSelectedContactAccountIDs.value.toHashSet() - if (!newSet.remove(accountID)) { - newSet.add(accountID) + if (!newSet.remove(address)) { + newSet.add(address) } mutableSelectedContactAccountIDs.value = newSet } - fun selectAccountIDs(accountIDs: Set) { + fun selectAccountIDs(accountIDs: Set
) { mutableSelectedContactAccountIDs.value += accountIDs } + fun clearSelection(){ + mutableSelectedContactAccountIDs.value = emptySet() + } + @AssistedFactory interface Factory { fun create( - excludingAccountIDs: Set = emptySet(), - scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), + excludingAccountIDs: Set
= emptySet(), + contactFiltering: (Recipient) -> Boolean = defaultFiltering, ): SelectContactsViewModel + + companion object { + val defaultFiltering: (Recipient) -> Boolean = { !it.isBlocked && it.isApproved } + } } } data class ContactItem( - val accountID: AccountId, + val address: Address, val name: String, + val avatarUIData: AvatarUIData, val selected: Boolean, + val showProBadge: Boolean ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt index b90d342ed6..c510d9d4b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt @@ -3,16 +3,13 @@ package org.thoughtcrime.securesms.groups.compose import androidx.compose.foundation.background 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.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment.Companion.CenterVertically @@ -22,16 +19,21 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import network.loki.messenger.R -import org.session.libsignal.utilities.AccountId +import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.groups.ContactItem -import org.thoughtcrime.securesms.ui.Avatar +import org.thoughtcrime.securesms.ui.ProBadgeText +import org.thoughtcrime.securesms.ui.components.Avatar import org.thoughtcrime.securesms.ui.components.RadioButtonIndicator import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.primaryBlue import org.thoughtcrime.securesms.ui.theme.primaryOrange +import org.thoughtcrime.securesms.util.AvatarBadge +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUIElement @Composable @@ -49,24 +51,26 @@ fun GroupMinimumVersionBanner(modifier: Modifier = Modifier) { horizontal = LocalDimensions.current.spacing, vertical = LocalDimensions.current.xxxsSpacing ) - .qaTag(stringResource(R.string.AccessibilityId_versionWarning)) + .qaTag(R.string.AccessibilityId_versionWarning) ) } @Composable fun MemberItem( - accountId: AccountId, + address: Address, title: String, + avatarUIData: AvatarUIData, showAsAdmin: Boolean, + showProBadge: Boolean, modifier: Modifier = Modifier, - onClick: ((accountId: AccountId) -> Unit)? = null, + onClick: ((address: Address) -> Unit)? = null, subtitle: String? = null, subtitleColor: Color = LocalColors.current.textSecondary, content: @Composable RowScope.() -> Unit = {}, ) { var itemModifier = modifier if(onClick != null){ - itemModifier = itemModifier.clickable(onClick = { onClick(accountId) }) + itemModifier = itemModifier.clickable(onClick = { onClick(address) }) } Row( @@ -75,25 +79,24 @@ fun MemberItem( horizontal = LocalDimensions.current.smallSpacing, vertical = LocalDimensions.current.xsSpacing ) - .qaTag(stringResource(R.string.AccessibilityId_contact)), + .qaTag(R.string.AccessibilityId_contact), horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing), verticalAlignment = CenterVertically, ) { Avatar( - accountId = accountId, - isAdmin = showAsAdmin, - modifier = Modifier.size(LocalDimensions.current.iconLarge) + size = LocalDimensions.current.iconLarge, + data = avatarUIData, + badge = if (showAsAdmin) { AvatarBadge.Admin } else AvatarBadge.None ) Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsSpacing) ) { - Text( - style = LocalType.current.h8, + ProBadgeText( text = title, - color = LocalColors.current.text, - modifier = Modifier.qaTag(stringResource(R.string.AccessibilityId_contact)) + textStyle = LocalType.current.h8.copy(color = LocalColors.current.text), + showBadge = showProBadge ) if (!subtitle.isNullOrEmpty()) { @@ -101,7 +104,7 @@ fun MemberItem( text = subtitle, style = LocalType.current.small, color = subtitleColor, - modifier = Modifier.qaTag(stringResource(R.string.AccessibilityId_contactStatus)) + modifier = Modifier.qaTag(R.string.AccessibilityId_contactStatus) ) } } @@ -114,21 +117,25 @@ fun MemberItem( fun RadioMemberItem( enabled: Boolean, selected: Boolean, - accountId: AccountId, + address: Address, + avatarUIData: AvatarUIData, title: String, - onClick: (accountId: AccountId) -> Unit, + onClick: (address: Address) -> Unit, showAsAdmin: Boolean, + showProBadge: Boolean, modifier: Modifier = Modifier, subtitle: String? = null, subtitleColor: Color = LocalColors.current.textSecondary ) { MemberItem( - accountId = accountId, + address = address, + avatarUIData = avatarUIData, title = title, subtitle = subtitle, subtitleColor = subtitleColor, onClick = if(enabled) onClick else null, showAsAdmin = showAsAdmin, + showProBadge = showProBadge, modifier = modifier ){ RadioButtonIndicator( @@ -141,28 +148,22 @@ fun RadioMemberItem( fun LazyListScope.multiSelectMemberList( contacts: List, modifier: Modifier = Modifier, - onContactItemClicked: (accountId: AccountId) -> Unit, + onContactItemClicked: (address: Address) -> Unit, enabled: Boolean = true, ) { items(contacts.size) { index -> val contact = contacts[index] - Column(modifier = modifier) { - if (index == 0) { - // Show top divider for the first item only - HorizontalDivider(color = LocalColors.current.borders) - } - - RadioMemberItem( - enabled = enabled, - selected = contact.selected, - accountId = contact.accountID, - title = contact.name, - showAsAdmin = false, - onClick = { onContactItemClicked(contact.accountID) } - ) - - HorizontalDivider(color = LocalColors.current.borders) - } + RadioMemberItem( + modifier = modifier, + enabled = enabled, + selected = contact.selected, + address = contact.address, + avatarUIData = contact.avatarUIData, + title = contact.name, + showAsAdmin = false, + showProBadge = contact.showProBadge, + onClick = { onContactItemClicked(contact.address) } + ) } } @@ -176,14 +177,32 @@ fun PreviewMemberList() { multiSelectMemberList( contacts = listOf( ContactItem( - accountID = AccountId(random), + address = Address.fromSerialized(random), name = "Person", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), selected = false, + showProBadge = false, ), ContactItem( - accountID = AccountId(random), + address = Address.fromSerialized(random), name = "Cow", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), selected = true, + showProBadge = true, ) ), onContactItemClicked = {} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt deleted file mode 100644 index 2be4aef546..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt +++ /dev/null @@ -1,252 +0,0 @@ -package org.thoughtcrime.securesms.groups.compose - -import android.widget.Toast -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.rememberNestedScrollInteropConnection -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import network.loki.messenger.R -import org.session.libsignal.utilities.AccountId -import org.thoughtcrime.securesms.groups.ContactItem -import org.thoughtcrime.securesms.groups.CreateGroupEvent -import org.thoughtcrime.securesms.groups.CreateGroupViewModel -import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox -import org.thoughtcrime.securesms.ui.LoadingArcOr -import org.thoughtcrime.securesms.ui.SearchBar -import org.thoughtcrime.securesms.ui.components.BackAppBar -import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton -import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField -import org.thoughtcrime.securesms.ui.qaTag -import org.thoughtcrime.securesms.ui.theme.LocalColors -import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.LocalType -import org.thoughtcrime.securesms.ui.theme.PreviewTheme - - -@Composable -fun CreateGroupScreen( - fromLegacyGroupId: String?, - onNavigateToConversationScreen: (threadID: Long) -> Unit, - onBack: () -> Unit, - onClose: () -> Unit, -) { - val viewModel = hiltViewModel { factory -> - factory.create(fromLegacyGroupId) - } - - val context = LocalContext.current - - LaunchedEffect(viewModel) { - viewModel.events.collect { event -> - when (event) { - is CreateGroupEvent.NavigateToConversation -> { - onClose() - onNavigateToConversationScreen(event.threadID) - } - - is CreateGroupEvent.Error -> { - Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() - } - } - } - } - - CreateGroup( - groupName = viewModel.groupName.collectAsState().value, - onGroupNameChanged = viewModel::onGroupNameChanged, - groupNameError = viewModel.groupNameError.collectAsState().value, - contactSearchQuery = viewModel.selectContactsViewModel.searchQuery.collectAsState().value, - onContactSearchQueryChanged = viewModel.selectContactsViewModel::onSearchQueryChanged, - onContactItemClicked = viewModel.selectContactsViewModel::onContactItemClicked, - showLoading = viewModel.isLoading.collectAsState().value, - items = viewModel.selectContactsViewModel.contacts.collectAsState().value, - onCreateClicked = viewModel::onCreateClicked, - onBack = onBack, - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CreateGroup( - groupName: String, - onGroupNameChanged: (String) -> Unit, - groupNameError: String, - contactSearchQuery: String, - onContactSearchQueryChanged: (String) -> Unit, - onContactItemClicked: (accountID: AccountId) -> Unit, - showLoading: Boolean, - items: List, - onCreateClicked: () -> Unit, - onBack: () -> Unit, - modifier: Modifier = Modifier -) { - val focusManager = LocalFocusManager.current - - Scaffold( - containerColor = LocalColors.current.backgroundSecondary, - topBar = { - BackAppBar( - title = stringResource(id = R.string.groupCreate), - backgroundColor = LocalColors.current.backgroundSecondary, - onBack = onBack, - ) - } - ) { paddings -> - Box(modifier = modifier.padding(paddings)) { - Column( - modifier = modifier.padding(vertical = LocalDimensions.current.xsSpacing), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - GroupMinimumVersionBanner() - - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - - SessionOutlinedTextField( - text = groupName, - onChange = onGroupNameChanged, - placeholder = stringResource(R.string.groupNameEnter), - textStyle = LocalType.current.base, - modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing) - .qaTag(stringResource(R.string.AccessibilityId_groupNameEnter)), - error = groupNameError.takeIf { it.isNotBlank() }, - enabled = !showLoading, - innerPadding = PaddingValues(LocalDimensions.current.smallSpacing), - onContinue = focusManager::clearFocus - ) - - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - - SearchBar( - query = contactSearchQuery, - onValueChanged = onContactSearchQueryChanged, - placeholder = stringResource(R.string.searchContacts), - modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing) - .qaTag(stringResource(R.string.AccessibilityId_groupNameSearch)), - enabled = !showLoading - ) - - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - - BottomFadingEdgeBox( - modifier = Modifier.weight(1f) - .nestedScroll(rememberNestedScrollInteropConnection()), - fadingColor = LocalColors.current.backgroundSecondary - ) { bottomContentPadding -> - if(items.isEmpty()){ - Text( - modifier = Modifier.fillMaxWidth() - .padding(top = LocalDimensions.current.xsSpacing), - text = stringResource(R.string.contactNone), - textAlign = TextAlign.Center, - style = LocalType.current.base.copy(color = LocalColors.current.textSecondary) - ) - } else { - LazyColumn( - contentPadding = PaddingValues(bottom = bottomContentPadding) - ) { - multiSelectMemberList( - contacts = items, - onContactItemClicked = onContactItemClicked, - enabled = !showLoading - ) - } - } - } - - Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) - - PrimaryOutlineButton( - onClick = onCreateClicked, - modifier = Modifier - .padding(horizontal = LocalDimensions.current.spacing) - .widthIn(min = LocalDimensions.current.minButtonWidth) - .qaTag(stringResource(R.string.AccessibilityId_groupCreate)) - ) { - LoadingArcOr(loading = showLoading) { - Text(stringResource(R.string.create)) - } - } - } - } - - } - -} - -@Preview -@Composable -private fun CreateGroupPreview( -) { - val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" - val previewMembers = listOf( - ContactItem(accountID = AccountId(random), name = "Alice", false), - ContactItem(accountID = AccountId(random), name = "Bob", true), - ) - - PreviewTheme { - CreateGroup( - groupName = "", - onGroupNameChanged = {}, - groupNameError = "", - contactSearchQuery = "", - onContactSearchQueryChanged = {}, - onContactItemClicked = {}, - showLoading = false, - items = previewMembers, - onCreateClicked = {}, - onBack = {}, - modifier = Modifier.background(LocalColors.current.backgroundSecondary), - ) - } - -} - -@Preview -@Composable -private fun CreateEmptyGroupPreview( -) { - val previewMembers = emptyList() - - PreviewTheme { - CreateGroup( - groupName = "", - onGroupNameChanged = {}, - groupNameError = "", - contactSearchQuery = "", - onContactSearchQueryChanged = {}, - onContactItemClicked = {}, - showLoading = false, - items = previewMembers, - onCreateClicked = {}, - onBack = {}, - modifier = Modifier.background(LocalColors.current.backgroundSecondary), - ) - } - -} - diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt index d6768f6fed..2a5b7071b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -1,24 +1,26 @@ package org.thoughtcrime.securesms.groups.compose import android.widget.Toast -import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -35,90 +37,63 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.rememberNavController import com.squareup.phrase.Phrase -import kotlinx.serialization.Serializable import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.libsession_util.util.GroupMember +import org.session.libsession.utilities.Address import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.groups.EditGroupViewModel import org.thoughtcrime.securesms.groups.GroupMemberState import org.thoughtcrime.securesms.ui.AlertDialog -import org.thoughtcrime.securesms.ui.DialogButtonModel +import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.LoadingDialog import org.thoughtcrime.securesms.ui.components.ActionSheet import org.thoughtcrime.securesms.ui.components.ActionSheetItemData import org.thoughtcrime.securesms.ui.components.BackAppBar -import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton -import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField +import org.thoughtcrime.securesms.ui.components.AccentOutlineButton import org.thoughtcrime.securesms.ui.components.annotatedStringResource -import org.thoughtcrime.securesms.ui.horizontalSlideComposable import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.primaryBlue +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUIElement @Composable fun EditGroupScreen( - groupId: AccountId, + viewModel: EditGroupViewModel, + navigateToInviteContact: (Set) -> Unit, onBack: () -> Unit, ) { - val navController = rememberNavController() - val viewModel = hiltViewModel { factory -> - factory.create(groupId) - } - - NavHost(navController = navController, startDestination = RouteEditGroup) { - horizontalSlideComposable { - EditGroup( - onBack = onBack, - onAddMemberClick = { navController.navigate(RouteSelectContacts) }, - onResendInviteClick = viewModel::onResendInviteClicked, - onPromoteClick = viewModel::onPromoteContact, - onRemoveClick = viewModel::onRemoveContact, - onEditNameClicked = viewModel::onEditNameClicked, - onEditNameCancelClicked = viewModel::onCancelEditingNameClicked, - onEditNameConfirmed = viewModel::onEditNameConfirmClicked, - onEditingNameValueChanged = viewModel::onEditingNameChanged, - editingName = viewModel.editingName.collectAsState().value, - members = viewModel.members.collectAsState().value, - groupName = viewModel.groupName.collectAsState().value, - showAddMembers = viewModel.showAddMembers.collectAsState().value, - canEditName = viewModel.canEditGroupName.collectAsState().value, - onResendPromotionClick = viewModel::onResendPromotionClicked, - showingError = viewModel.error.collectAsState().value, - onErrorDismissed = viewModel::onDismissError, - onMemberClicked = viewModel::onMemberClicked, - hideActionSheet = viewModel::hideActionBottomSheet, - clickedMember = viewModel.clickedMember.collectAsState().value, - showLoading = viewModel.inProgress.collectAsState().value, - ) - } - - horizontalSlideComposable { - InviteContactsScreen( - excludingAccountIDs = viewModel.excludingAccountIDsFromContactSelection, - onDoneClicked = { - viewModel.onContactSelected(it) - navController.popBackStack() - }, - onBackClicked = { navController.popBackStack() }, - ) - } - } - + EditGroup( + onBack = onBack, + onAddMemberClick = { navigateToInviteContact(viewModel.excludingAccountIDsFromContactSelection) }, + onResendInviteClick = viewModel::onResendInviteClicked, + onPromoteClick = viewModel::onPromoteContact, + onRemoveClick = viewModel::onRemoveContact, + members = viewModel.members.collectAsState().value, + groupName = viewModel.groupName.collectAsState().value, + showAddMembers = viewModel.showAddMembers.collectAsState().value, + onResendPromotionClick = viewModel::onResendPromotionClicked, + showingError = viewModel.error.collectAsState().value, + onErrorDismissed = viewModel::onDismissError, + onMemberClicked = viewModel::onMemberClicked, + hideActionSheet = viewModel::hideActionBottomSheet, + clickedMember = viewModel.clickedMember.collectAsState().value, + showLoading = viewModel.inProgress.collectAsState().value, + ) } -@Serializable -private object RouteEditGroup @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -129,15 +104,9 @@ fun EditGroup( onResendPromotionClick: (accountId: AccountId) -> Unit, onPromoteClick: (accountId: AccountId) -> Unit, onRemoveClick: (accountId: AccountId, removeMessages: Boolean) -> Unit, - onEditingNameValueChanged: (String) -> Unit, - editingName: String?, - onEditNameClicked: () -> Unit, - onEditNameConfirmed: () -> Unit, - onEditNameCancelClicked: () -> Unit, onMemberClicked: (GroupMemberState) -> Unit, hideActionSheet: () -> Unit, clickedMember: GroupMemberState?, - canEditName: Boolean, groupName: String, members: List, showAddMembers: Boolean, @@ -154,125 +123,66 @@ fun EditGroup( Scaffold( topBar = { BackAppBar( - title = stringResource(id = R.string.groupEdit), + title = stringResource(id = R.string.manageMembers), onBack = onBack, ) - } + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), ) { paddingValues -> - Box { - Column(modifier = Modifier.padding(paddingValues)) { - GroupMinimumVersionBanner() - - // Group name title - Crossfade(editingName != null, label = "Editable group name") { showNameEditing -> - if (showNameEditing) { - GroupNameContainer { - IconButton( - modifier = Modifier.size(LocalDimensions.current.spacing), - onClick = onEditNameCancelClicked - ) { - Icon( - painter = painterResource(R.drawable.ic_x), - contentDescription = stringResource(R.string.AccessibilityId_cancel), - tint = LocalColors.current.text, - ) - } - - SessionOutlinedTextField( - modifier = Modifier - .widthIn( - min = LocalDimensions.current.mediumSpacing, - max = maxNameWidth - ) - .qaTag(stringResource(R.string.AccessibilityId_groupName)), - text = editingName.orEmpty(), - onChange = onEditingNameValueChanged, - textStyle = LocalType.current.h8, - singleLine = true, - innerPadding = PaddingValues( - horizontal = LocalDimensions.current.spacing, - vertical = LocalDimensions.current.smallSpacing - ) - ) - - IconButton( - modifier = Modifier.size(LocalDimensions.current.spacing), - onClick = onEditNameConfirmed - ) { - Icon( - painter = painterResource(R.drawable.check), - contentDescription = stringResource(R.string.AccessibilityId_confirm), - tint = LocalColors.current.text, - ) - } - } - - - } else { - GroupNameContainer { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = groupName, - style = LocalType.current.h4, - textAlign = TextAlign.Center, - modifier = Modifier - .widthIn(max = maxNameWidth) - .padding(vertical = LocalDimensions.current.smallSpacing), - ) - - Box(modifier = Modifier.weight(1f)) { - if (canEditName) { - IconButton( - modifier = Modifier.qaTag(stringResource(R.string.AccessibilityId_groupName)), - onClick = onEditNameClicked - ) { - Icon( - painterResource(R.drawable.ic_baseline_edit_24), - contentDescription = stringResource(R.string.edit), - tint = LocalColors.current.text, - ) - } - } - } - } - } - } + Column(modifier = Modifier.padding(paddingValues).consumeWindowInsets(paddingValues)) { + GroupMinimumVersionBanner() + + // Group name title + Text( + text = groupName, + style = LocalType.current.h4, + textAlign = TextAlign.Center, + modifier = Modifier + .align(CenterHorizontally) + .widthIn(max = maxNameWidth) + .padding(vertical = LocalDimensions.current.smallSpacing), + ) - // Header & Add member button - Row( - modifier = Modifier.padding( - horizontal = LocalDimensions.current.smallSpacing, - vertical = LocalDimensions.current.xxsSpacing - ), - verticalAlignment = CenterVertically - ) { - Text( - stringResource(R.string.groupMembers), - modifier = Modifier.weight(1f), - style = LocalType.current.large, - color = LocalColors.current.text - ) + // Header & Add member button + Row( + modifier = Modifier.padding( + horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xxsSpacing + ), + verticalAlignment = CenterVertically + ) { + Text( + stringResource(R.string.groupMembers), + modifier = Modifier.weight(1f), + style = LocalType.current.large, + color = LocalColors.current.text + ) - if (showAddMembers) { - PrimaryOutlineButton( - stringResource(R.string.membersInvite), - onClick = onAddMemberClick, - modifier = Modifier.qaTag(stringResource(R.string.AccessibilityId_membersInvite)) - ) - } + if (showAddMembers) { + AccentOutlineButton( + stringResource(R.string.membersInvite), + onClick = onAddMemberClick, + modifier = Modifier.qaTag(R.string.AccessibilityId_membersInvite) + ) } + } + + // List of members + LazyColumn(modifier = Modifier.weight(1f).imePadding()) { + items(members) { member -> + // Each member's view + EditMemberItem( + modifier = Modifier.fillMaxWidth(), + member = member, + onClick = { onMemberClicked(member) } + ) + } - // List of members - LazyColumn(modifier = Modifier) { - items(members) { member -> - // Each member's view - EditMemberItem( - modifier = Modifier.fillMaxWidth(), - member = member, - onClick = { onMemberClicked(member) } - ) - } + item { + Spacer( + modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) + ) } } } @@ -350,13 +260,13 @@ private fun ConfirmRemovingMemberDialog( ) { val context = LocalContext.current val buttons = buildList { - this += DialogButtonModel( + this += DialogButtonData( text = GetString(R.string.remove), color = LocalColors.current.danger, onClick = { onConfirmed(member.accountId, false) } ) - this += DialogButtonModel( + this += DialogButtonData( text = GetString(R.string.cancel), onClick = onDismissRequest, ) @@ -373,6 +283,7 @@ private fun ConfirmRemovingMemberDialog( ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun MemberActionSheet( member: GroupMemberState, @@ -389,16 +300,16 @@ private fun MemberActionSheet( if (member.canRemove) { this += ActionSheetItemData( title = context.resources.getQuantityString(R.plurals.groupRemoveUserOnly, 1), - iconRes = R.drawable.ic_delete_24, + iconRes = R.drawable.ic_trash_2, onClick = onRemove, qaTag = R.string.AccessibilityId_removeContact ) } - if (BuildConfig.DEBUG && member.canPromote) { + if (BuildConfig.BUILD_TYPE != "release" && member.canPromote) { this += ActionSheetItemData( title = context.getString(R.string.adminPromoteToAdmin), - iconRes = R.drawable.ic_profile_default, + iconRes = R.drawable.ic_user_filled_custom, onClick = onPromote ) } @@ -412,7 +323,7 @@ private fun MemberActionSheet( ) } - if (BuildConfig.DEBUG && member.canResendPromotion) { + if (BuildConfig.BUILD_TYPE != "release" && member.canResendPromotion) { this += ActionSheetItemData( title = "Resend promotion", iconRes = R.drawable.ic_mail, @@ -432,11 +343,11 @@ private fun MemberActionSheet( @Composable fun EditMemberItem( member: GroupMemberState, - onClick: (accountId: AccountId) -> Unit, + onClick: (address: Address) -> Unit, modifier: Modifier = Modifier ) { MemberItem( - accountId = member.accountId, + address = Address.fromSerialized(member.accountId.hexString), title = member.name, subtitle = member.statusLabel, subtitleColor = if (member.highlightStatus) { @@ -445,12 +356,15 @@ fun EditMemberItem( LocalColors.current.textSecondary }, showAsAdmin = member.showAsAdmin, + showProBadge = member.showProBadge, + avatarUIData = member.avatarUIData, onClick = if(member.clickable) onClick else null, modifier = modifier ){ if (member.canEdit) { Icon( - painter = painterResource(R.drawable.ic_circle_dot_dot_dot), + painter = painterResource(R.drawable.ic_circle_dots_custom), + tint = LocalColors.current.text, contentDescription = stringResource(R.string.AccessibilityId_sessionSettings) ) } @@ -459,11 +373,19 @@ fun EditMemberItem( @Preview @Composable -private fun EditGroupPreview3() { +private fun EditGroupPreviewSheet() { PreviewTheme { val oneMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"), name = "Test User", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), status = GroupMember.Status.INVITE_SENT, highlightStatus = false, canPromote = true, @@ -471,12 +393,21 @@ private fun EditGroupPreview3() { canResendInvite = false, canResendPromotion = false, showAsAdmin = false, + showProBadge = true, clickable = true, statusLabel = "Invited" ) val twoMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), name = "Test User 2", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), status = GroupMember.Status.PROMOTION_FAILED, highlightStatus = true, canPromote = true, @@ -484,12 +415,21 @@ private fun EditGroupPreview3() { canResendInvite = false, canResendPromotion = false, showAsAdmin = true, + showProBadge = true, clickable = true, statusLabel = "Promotion failed" ) val threeMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), name = "Test User 3", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), status = null, highlightStatus = false, canPromote = true, @@ -497,6 +437,7 @@ private fun EditGroupPreview3() { canResendInvite = false, canResendPromotion = false, showAsAdmin = false, + showProBadge = false, clickable = true, statusLabel = "" ) @@ -509,19 +450,7 @@ private fun EditGroupPreview3() { onResendInviteClick = {}, onPromoteClick = {}, onRemoveClick = { _, _ -> }, - onEditNameCancelClicked = { - setEditingName(null) - }, - onEditNameConfirmed = { - setEditingName(null) - }, - onEditNameClicked = { - setEditingName("Test Group") - }, - editingName = editingName, - onEditingNameValueChanged = setEditingName, members = listOf(oneMember, twoMember, threeMember), - canEditName = true, groupName = "Test ", showAddMembers = true, onResendPromotionClick = {}, @@ -529,97 +458,31 @@ private fun EditGroupPreview3() { onErrorDismissed = {}, onMemberClicked = {}, hideActionSheet = {}, - clickedMember = null, - showLoading = true, + clickedMember = oneMember, + showLoading = false, ) } } -@Preview -@Composable -private fun EditGroupPreview() { - PreviewTheme { - val oneMember = GroupMemberState( - accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"), - name = "Test User", - status = GroupMember.Status.INVITE_SENT, - highlightStatus = false, - canPromote = true, - canRemove = true, - canResendInvite = false, - canResendPromotion = false, - showAsAdmin = false, - clickable = true, - statusLabel = "Invited" - ) - val twoMember = GroupMemberState( - accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), - name = "Test User 2", - status = GroupMember.Status.PROMOTION_FAILED, - highlightStatus = true, - canPromote = true, - canRemove = true, - canResendInvite = false, - canResendPromotion = false, - showAsAdmin = true, - clickable = true, - statusLabel = "Promotion failed" - ) - val threeMember = GroupMemberState( - accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), - name = "Test User 3", - status = null, - highlightStatus = false, - canPromote = true, - canRemove = true, - canResendInvite = false, - canResendPromotion = false, - showAsAdmin = false, - clickable = true, - statusLabel = "" - ) - - val (editingName, setEditingName) = remember { mutableStateOf(null) } - EditGroup( - onBack = {}, - onAddMemberClick = {}, - onResendInviteClick = {}, - onPromoteClick = {}, - onRemoveClick = { _, _ -> }, - onEditNameCancelClicked = { - setEditingName(null) - }, - onEditNameConfirmed = { - setEditingName(null) - }, - onEditNameClicked = { - setEditingName("Test Group") - }, - editingName = editingName, - onEditingNameValueChanged = setEditingName, - members = listOf(oneMember, twoMember, threeMember), - canEditName = true, - groupName = "Test name that is very very long indeed because many words in it", - showAddMembers = true, - onResendPromotionClick = {}, - showingError = "Error", - onErrorDismissed = {}, - onMemberClicked = {}, - hideActionSheet = {}, - clickedMember = null, - showLoading = false, - ) - } -} @Preview @Composable -private fun EditGroupEditNamePreview() { - PreviewTheme { +private fun EditGroupEditNamePreview( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { val oneMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"), name = "Test User", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), status = GroupMember.Status.INVITE_SENT, highlightStatus = false, canPromote = true, @@ -627,12 +490,21 @@ private fun EditGroupEditNamePreview() { canResendInvite = false, canResendPromotion = false, showAsAdmin = false, + showProBadge = true, clickable = true, statusLabel = "Invited" ) val twoMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), name = "Test User 2", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), status = GroupMember.Status.PROMOTION_FAILED, highlightStatus = true, canPromote = true, @@ -640,12 +512,21 @@ private fun EditGroupEditNamePreview() { canResendInvite = false, canResendPromotion = false, showAsAdmin = true, + showProBadge = true, clickable = true, statusLabel = "Promotion failed" ) val threeMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), name = "Test User 3", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), status = null, highlightStatus = false, canPromote = true, @@ -653,6 +534,7 @@ private fun EditGroupEditNamePreview() { canResendInvite = false, canResendPromotion = false, showAsAdmin = false, + showProBadge = false, clickable = true, statusLabel = "" ) @@ -663,13 +545,7 @@ private fun EditGroupEditNamePreview() { onResendInviteClick = {}, onPromoteClick = {}, onRemoveClick = { _, _ -> }, - onEditNameCancelClicked = {}, - onEditNameConfirmed = {}, - onEditNameClicked = {}, - editingName = "Test name that is very very long indeed because many words in it", - onEditingNameValueChanged = { }, members = listOf(oneMember, twoMember, threeMember), - canEditName = true, groupName = "Test name that is very very long indeed because many words in it", showAddMembers = true, onResendPromotionClick = {}, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt index 2f203d8673..7b69716593 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt @@ -1,6 +1,8 @@ package org.thoughtcrime.securesms.groups.compose +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -9,30 +11,44 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.hilt.navigation.compose.hiltViewModel import network.loki.messenger.R import network.loki.messenger.libsession_util.util.GroupMember +import org.session.libsession.utilities.Address import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.groups.GroupMemberState import org.thoughtcrime.securesms.groups.GroupMembersViewModel +import org.thoughtcrime.securesms.ui.ObserveAsEvents +import org.thoughtcrime.securesms.ui.SearchBar import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.primaryBlue +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUIElement @Composable fun GroupMembersScreen( - groupId: AccountId, + viewModel: GroupMembersViewModel, onBack: () -> Unit, ) { - val viewModel = hiltViewModel { factory -> - factory.create(groupId) + + val context = LocalContext.current + ObserveAsEvents(flow = viewModel.navigationActions) { intent -> + context.startActivity(intent) } GroupMembers( onBack = onBack, - members = viewModel.members.collectAsState().value + members = viewModel.members.collectAsState().value, + searchQuery = viewModel.searchQuery.collectAsState().value, + onSearchQueryChanged = viewModel::onSearchQueryChanged, + onSearchQueryClear = {viewModel.onSearchQueryChanged("") }, + onMemberClicked = viewModel::onMemberClicked ) } @@ -42,8 +58,11 @@ fun GroupMembersScreen( fun GroupMembers( onBack: () -> Unit, members: List, + searchQuery: String, + onSearchQueryChanged: (String) -> Unit, + onSearchQueryClear: () -> Unit, + onMemberClicked: (AccountId) -> Unit, ) { - Scaffold( topBar = { BackAppBar( @@ -52,13 +71,30 @@ fun GroupMembers( ) } ) { paddingValues -> - Column(modifier = Modifier.padding(paddingValues)) { + Column( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) + ) { + SearchBar( + query = searchQuery, + onValueChanged = onSearchQueryChanged, + onClear = onSearchQueryClear, + placeholder = stringResource(R.string.searchContacts), + modifier = Modifier + .padding(horizontal = LocalDimensions.current.smallSpacing) + .qaTag(R.string.AccessibilityId_groupNameSearch), + backgroundColor = LocalColors.current.backgroundSecondary, + ) + // List of members - LazyColumn(modifier = Modifier) { + LazyColumn() { items(members) { member -> // Each member's view MemberItem( - accountId = member.accountId, + address = Address.fromSerialized(member.accountId.hexString), + onClick = { onMemberClicked(member.accountId) }, title = member.name, subtitle = member.statusLabel, subtitleColor = if (member.highlightStatus) { @@ -66,7 +102,9 @@ fun GroupMembers( } else { LocalColors.current.textSecondary }, - showAsAdmin = member.showAsAdmin + showAsAdmin = member.showAsAdmin, + showProBadge = member.showProBadge, + avatarUIData = member.avatarUIData ) } } @@ -89,8 +127,17 @@ private fun EditGroupPreview() { canResendInvite = false, canResendPromotion = false, showAsAdmin = false, + showProBadge = true, clickable = true, - statusLabel = "Invited" + statusLabel = "Invited", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), ) val twoMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), @@ -102,8 +149,17 @@ private fun EditGroupPreview() { canResendInvite = false, canResendPromotion = false, showAsAdmin = true, + showProBadge = true, clickable = true, - statusLabel = "Promotion failed" + statusLabel = "Promotion failed", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), ) val threeMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), @@ -115,13 +171,26 @@ private fun EditGroupPreview() { canResendInvite = false, canResendPromotion = false, showAsAdmin = false, + showProBadge = false, clickable = true, - statusLabel = "" + statusLabel = "", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), ) GroupMembers( onBack = {}, members = listOf(oneMember, twoMember, threeMember), + searchQuery = "", + onSearchQueryChanged = {}, + onSearchQueryClear = {}, + onMemberClicked = {}, ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt new file mode 100644 index 0000000000..902c0bc4fe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt @@ -0,0 +1,218 @@ +package org.thoughtcrime.securesms.groups.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import network.loki.messenger.R +import org.session.libsession.utilities.Address +import org.thoughtcrime.securesms.groups.ContactItem +import org.thoughtcrime.securesms.groups.SelectContactsViewModel +import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox +import org.thoughtcrime.securesms.ui.SearchBar +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.AccentOutlineButton +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.primaryBlue +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUIElement + + +@Composable +fun InviteContactsScreen( + viewModel: SelectContactsViewModel, + onDoneClicked: () -> Unit, + onBack: () -> Unit, + banner: @Composable ()->Unit = {} +) { + InviteContacts( + contacts = viewModel.contacts.collectAsState().value, + onContactItemClicked = viewModel::onContactItemClicked, + searchQuery = viewModel.searchQuery.collectAsState().value, + onSearchQueryChanged = viewModel::onSearchQueryChanged, + onSearchQueryClear = {viewModel.onSearchQueryChanged("") }, + onDoneClicked = onDoneClicked, + onBack = onBack, + banner = banner + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InviteContacts( + contacts: List, + onContactItemClicked: (address: Address) -> Unit, + searchQuery: String, + onSearchQueryChanged: (String) -> Unit, + onSearchQueryClear: () -> Unit, + onDoneClicked: () -> Unit, + onBack: () -> Unit, + banner: @Composable ()->Unit = {} +) { + Scaffold( + topBar = { + BackAppBar( + title = stringResource(id = R.string.membersInvite), + onBack = onBack, + ) + }, + ) { paddings -> + Column( + modifier = Modifier + .padding(paddings) + .consumeWindowInsets(paddings), + ) { + banner() + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + SearchBar( + query = searchQuery, + onValueChanged = onSearchQueryChanged, + onClear = onSearchQueryClear, + placeholder = stringResource(R.string.searchContacts), + modifier = Modifier + .padding(horizontal = LocalDimensions.current.smallSpacing) + .qaTag(R.string.AccessibilityId_groupNameSearch), + backgroundColor = LocalColors.current.backgroundSecondary, + ) + + val scrollState = rememberLazyListState() + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + BottomFadingEdgeBox(modifier = Modifier.weight(1f)) { bottomContentPadding -> + if(contacts.isEmpty() && searchQuery.isEmpty()){ + Text( + text = stringResource(id = R.string.contactNone), + modifier = Modifier.padding(top = LocalDimensions.current.spacing) + .align(Alignment.TopCenter), + style = LocalType.current.base.copy(color = LocalColors.current.textSecondary) + ) + } else { + LazyColumn( + state = scrollState, + contentPadding = PaddingValues(bottom = bottomContentPadding), + ) { + multiSelectMemberList( + contacts = contacts, + onContactItemClicked = onContactItemClicked, + ) + } + } + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth() + ) { + AccentOutlineButton( + onClick = onDoneClicked, + enabled = contacts.any { it.selected }, + modifier = Modifier + .padding(vertical = LocalDimensions.current.spacing) + .qaTag(R.string.qa_invite_button), + ) { + Text( + stringResource(id = R.string.membersInviteTitle) + ) + } + } + } + + } +} + +@Preview +@Composable +private fun PreviewSelectContacts() { + val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + val contacts = List(20) { + ContactItem( + address = Address.fromSerialized(random), + name = "User $it", + selected = it % 3 == 0, + showProBadge = true, + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), + ) + } + + PreviewTheme { + InviteContacts( + contacts = contacts, + onContactItemClicked = {}, + searchQuery = "", + onSearchQueryChanged = {}, + onSearchQueryClear = {}, + onDoneClicked = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewSelectEmptyContacts() { + val contacts = emptyList() + + PreviewTheme { + InviteContacts( + contacts = contacts, + onContactItemClicked = {}, + searchQuery = "", + onSearchQueryChanged = {}, + onSearchQueryClear = {}, + onDoneClicked = {}, + onBack = {}, + banner = { GroupMinimumVersionBanner() } + ) + } +} + +@Preview +@Composable +private fun PreviewSelectEmptyContactsWithSearch() { + val contacts = emptyList() + + PreviewTheme { + InviteContacts( + contacts = contacts, + onContactItemClicked = {}, + searchQuery = "Test", + onSearchQueryChanged = {}, + onSearchQueryClear = {}, + onDoneClicked = {}, + onBack = {}, + banner = { GroupMinimumVersionBanner() } + ) + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContactsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContactsScreen.kt deleted file mode 100644 index 5416a9842d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContactsScreen.kt +++ /dev/null @@ -1,144 +0,0 @@ -package org.thoughtcrime.securesms.groups.compose - -import androidx.annotation.StringRes -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.hilt.navigation.compose.hiltViewModel -import kotlinx.serialization.Serializable -import network.loki.messenger.R -import org.session.libsignal.utilities.AccountId -import org.thoughtcrime.securesms.groups.ContactItem -import org.thoughtcrime.securesms.groups.SelectContactsViewModel -import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox -import org.thoughtcrime.securesms.ui.SearchBar -import org.thoughtcrime.securesms.ui.components.BackAppBar -import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton -import org.thoughtcrime.securesms.ui.qaTag -import org.thoughtcrime.securesms.ui.theme.LocalColors -import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.PreviewTheme - - -@Serializable -object RouteSelectContacts - -@Composable -fun InviteContactsScreen( - excludingAccountIDs: Set = emptySet(), - onDoneClicked: (selectedContacts: Set) -> Unit, - onBackClicked: () -> Unit, -) { - val viewModel = hiltViewModel { factory -> - factory.create(excludingAccountIDs) - } - - InviteContacts( - contacts = viewModel.contacts.collectAsState().value, - onContactItemClicked = viewModel::onContactItemClicked, - searchQuery = viewModel.searchQuery.collectAsState().value, - onSearchQueryChanged = viewModel::onSearchQueryChanged, - onDoneClicked = { onDoneClicked(viewModel.currentSelected) }, - onBack = onBackClicked, - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun InviteContacts( - contacts: List, - onContactItemClicked: (accountId: AccountId) -> Unit, - searchQuery: String, - onSearchQueryChanged: (String) -> Unit, - onDoneClicked: () -> Unit, - onBack: () -> Unit, - @StringRes okButtonResId: Int = R.string.ok -) { - Column(verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing)) { - BackAppBar( - title = stringResource(id = R.string.membersInvite), - onBack = onBack, - ) - - GroupMinimumVersionBanner() - SearchBar( - query = searchQuery, - onValueChanged = onSearchQueryChanged, - placeholder = stringResource(R.string.searchContacts), - modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing) - .qaTag(stringResource(R.string.AccessibilityId_groupNameSearch)), - backgroundColor = LocalColors.current.backgroundSecondary, - ) - - val scrollState = rememberLazyListState() - - BottomFadingEdgeBox(modifier = Modifier.weight(1f)) { bottomContentPadding -> - LazyColumn( - state = scrollState, - contentPadding = PaddingValues(bottom = bottomContentPadding), - ) { - multiSelectMemberList( - contacts = contacts, - onContactItemClicked = onContactItemClicked, - ) - } - } - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth() - ) { - PrimaryOutlineButton( - onClick = onDoneClicked, - modifier = Modifier - .padding(vertical = LocalDimensions.current.spacing) - .defaultMinSize(minWidth = LocalDimensions.current.minButtonWidth) - .qaTag(stringResource(R.string.AccessibilityId_selectContactConfirm)), - ) { - Text( - stringResource(id = okButtonResId) - ) - } - } - } - -} - -@Preview -@Composable -private fun PreviewSelectContacts() { - val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" - val contacts = List(20) { - ContactItem( - accountID = AccountId(random), - name = "User $it", - selected = it % 3 == 0, - ) - } - - PreviewTheme { - InviteContacts( - contacts = contacts, - onContactItemClicked = {}, - searchQuery = "", - onSearchQueryChanged = {}, - onDoneClicked = {}, - onBack = {}, - ) - } -} - diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/AdminStateSync.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/AdminStateSync.kt index 77b14ce680..cc80ea62bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/AdminStateSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/AdminStateSync.kt @@ -1,6 +1,6 @@ package org.thoughtcrime.securesms.groups.handler -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @@ -10,6 +10,8 @@ import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigUpdateNotification import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import java.util.EnumSet import javax.inject.Inject import javax.inject.Singleton @@ -25,13 +27,14 @@ import javax.inject.Singleton class AdminStateSync @Inject constructor( private val configFactory: ConfigFactoryProtocol, private val preferences: TextSecurePreferences, -) { + @param:ManagerScope private val scope: CoroutineScope +) : OnAppStartupComponent { private var job: Job? = null - fun start() { + override fun onPostAppStarted() { require(job == null) { "Already started" } - job = GlobalScope.launch { + job = scope.launch { configFactory.configUpdateNotifications .filter { it is ConfigUpdateNotification.UserConfigsMerged || it == ConfigUpdateNotification.UserConfigsModified } .collect { @@ -45,7 +48,7 @@ class AdminStateSync @Inject constructor( .asSequence() .mapNotNull { if ((it as? GroupInfo.ClosedGroupInfo)?.hasAdminKey() == true) { - it.groupAccountId + AccountId(it.groupAccountId) } else { null } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/CleanupInvitationHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/CleanupInvitationHandler.kt index b7f493bc3e..952421e504 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/CleanupInvitationHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/CleanupInvitationHandler.kt @@ -1,6 +1,6 @@ package org.thoughtcrime.securesms.groups.handler -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import network.loki.messenger.libsession_util.allWithStatus @@ -8,6 +8,9 @@ import network.loki.messenger.libsession_util.util.GroupMember import org.session.libsession.messaging.groups.GroupScope import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import javax.inject.Inject /** @@ -22,10 +25,11 @@ import javax.inject.Inject class CleanupInvitationHandler @Inject constructor( private val prefs: TextSecurePreferences, private val configFactory: ConfigFactoryProtocol, - private val groupScope: GroupScope -) { - fun start() { - GlobalScope.launch { + private val groupScope: GroupScope, + @param:ManagerScope private val scope: CoroutineScope +) : OnAppStartupComponent { + override fun onPostAppStarted() { + scope.launch { // Wait for the local number to be available prefs.watchLocalNumber().first { it != null } @@ -37,8 +41,9 @@ class CleanupInvitationHandler @Inject constructor( .asSequence() .filter { !it.kicked && !it.destroyed && it.hasAdminKey() } .forEach { group -> - groupScope.launch(group.groupAccountId, debugName = "CleanupInvitationHandler") { - configFactory.withMutableGroupConfigs(group.groupAccountId) { configs -> + val groupId = AccountId(group.groupAccountId) + groupScope.launch(groupId, debugName = "CleanupInvitationHandler") { + configFactory.withMutableGroupConfigs(groupId) { configs -> configs.groupMembers .allWithStatus() .filter { it.second == GroupMember.Status.INVITE_SENDING } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/DestroyedGroupSync.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/DestroyedGroupSync.kt index f750d48ef2..7c8c69ed9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/DestroyedGroupSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/DestroyedGroupSync.kt @@ -1,16 +1,19 @@ package org.thoughtcrime.securesms.groups.handler -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupScope import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigUpdateNotification import org.session.libsession.utilities.waitUntilGroupConfigsPushed import org.session.libsignal.utilities.Log -import org.session.libsession.messaging.groups.GroupScope +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import javax.inject.Inject import javax.inject.Singleton @@ -23,15 +26,17 @@ class DestroyedGroupSync @Inject constructor( private val configFactory: ConfigFactoryProtocol, private val groupScope: GroupScope, private val storage: StorageProtocol, -) { + @param:ManagerScope private val scope: CoroutineScope +) : OnAppStartupComponent { private var job: Job? = null - fun start() { + override fun onPostAppStarted() { require(job == null) { "Already started" } - job = GlobalScope.launch { + job = scope.launch { configFactory.configUpdateNotifications .filterIsInstance() + .filter { it.fromMerge } .collect { update -> val isDestroyed = configFactory.withGroupConfigs(update.groupId) { it.groupInfo.isDestroyed() diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt index 0c4ebe63d4..c118a0983a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt @@ -3,25 +3,27 @@ package org.thoughtcrime.securesms.groups.handler import android.content.Context import com.google.protobuf.ByteString import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import network.loki.messenger.R +import network.loki.messenger.libsession_util.ED25519 +import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.ReadableGroupKeysConfig import network.loki.messenger.libsession_util.allWithStatus import network.loki.messenger.libsession_util.util.GroupMember -import network.loki.messenger.libsession_util.util.Sodium +import network.loki.messenger.libsession_util.util.MultiEncrypt import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupScope import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.MessageAuthentication -import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeClock @@ -37,8 +39,8 @@ import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Namespace -import org.session.libsession.messaging.groups.GroupScope +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import javax.inject.Inject import javax.inject.Singleton @@ -49,23 +51,20 @@ private const val TAG = "RemoveGroupMemberHandler" * * It automatically does so by listening to the config updates changes and checking for any pending removals. */ +@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class) @Singleton class RemoveGroupMemberHandler @Inject constructor( - @ApplicationContext private val context: Context, + @param:ApplicationContext private val context: Context, private val configFactory: ConfigFactoryProtocol, private val textSecurePreferences: TextSecurePreferences, private val clock: SnodeClock, private val messageDataProvider: MessageDataProvider, private val storage: StorageProtocol, private val groupScope: GroupScope, -) { - private var job: Job? = null - - @OptIn(ExperimentalCoroutinesApi::class) - fun start() { - require(job == null) { "Already started" } - - job = GlobalScope.launch { + @ManagerScope scope: CoroutineScope, +) : OnAppStartupComponent { + init { + scope.launch { textSecurePreferences .watchLocalNumber() .flatMapLatest { localNumber -> @@ -77,17 +76,20 @@ class RemoveGroupMemberHandler @Inject constructor( } .filterIsInstance() .collect { update -> - val adminKey = configFactory.getGroup(update.groupId)?.adminKey + val adminKey = configFactory.getGroup(update.groupId)?.adminKey?.data if (adminKey != null) { groupScope.launch(update.groupId, "Handle possible group removals") { - processPendingRemovalsForGroup(update.groupId, adminKey) + try { + processPendingRemovalsForGroup(update.groupId, adminKey) + } catch (ec: Exception) { + Log.e("RemoveGroupMemberHandler", "Error processing pending removals", ec) + } } } } } } - private suspend fun processPendingRemovalsForGroup( groupAccountId: AccountId, adminKey: ByteArray @@ -118,7 +120,7 @@ class RemoveGroupMemberHandler @Inject constructor( SnodeAPI.buildAuthenticatedRevokeSubKeyBatchRequest( groupAdminAuth = groupAuth, subAccountTokens = pendingRemovals.map { (member, _) -> - configs.groupKeys.getSubAccountToken(member.accountId) + configs.groupKeys.getSubAccountToken(member.accountId()) } ) ) { "Fail to create a revoke request" } @@ -145,7 +147,7 @@ class RemoveGroupMemberHandler @Inject constructor( memberSessionIDs = pendingRemovals .asSequence() .filter { (member, status) -> member.shouldRemoveMessages(status) } - .map { (member, _) -> member.accountIdString() }, + .map { (member, _) -> member.accountId() }, ), auth = groupAuth, ) @@ -178,7 +180,7 @@ class RemoveGroupMemberHandler @Inject constructor( // now we can go ahead and update the configs configFactory.withMutableGroupConfigs(groupAccountId) { configs -> pendingRemovals.forEach { (member, _) -> - configs.groupMembers.erase(member.accountIdString()) + configs.groupMembers.erase(member.accountId()) } configs.rekey() } @@ -200,7 +202,7 @@ class RemoveGroupMemberHandler @Inject constructor( messageDataProvider.markUserMessagesAsDeleted( threadId = threadId, until = until, - sender = member.accountIdString(), + sender = member.accountId(), displayedMessage = context.getString(R.string.deleteMessageDeletedGlobally) ) } catch (e: Exception) { @@ -231,13 +233,14 @@ class RemoveGroupMemberHandler @Inject constructor( } .setAdminSignature( ByteString.copyFrom( - SodiumUtilities.sign( - MessageAuthentication.buildDeleteMemberContentSignature( + ED25519.sign( + message = MessageAuthentication.buildDeleteMemberContentSignature( memberIds = memberSessionIDs.map { AccountId(it) } .toList(), messageHashes = emptyList(), timestamp = timestamp, - ), adminKey + ), + ed25519PrivateKey = adminKey ) ) ) @@ -256,16 +259,16 @@ class RemoveGroupMemberHandler @Inject constructor( ) = SnodeMessage( recipient = groupAccountId, data = Base64.encodeBytes( - Sodium.encryptForMultipleSimple( + MultiEncrypt.encryptForMultipleSimple( messages = Array(pendingRemovals.size) { - pendingRemovals[it].accountId.pubKeyBytes + AccountId(pendingRemovals[it].accountId()).pubKeyBytes .plus(keys.currentGeneration().toString().toByteArray()) }, recipients = Array(pendingRemovals.size) { - pendingRemovals[it].accountId.pubKeyBytes + AccountId(pendingRemovals[it].accountId()).pubKeyBytes }, ed25519SecretKey = adminKey, - domain = Sodium.KICKED_DOMAIN + domain = MultiEncrypt.KICKED_DOMAIN ) ), ttl = SnodeMessage.DEFAULT_TTL, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/CreateLegacyGroupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/CreateLegacyGroupFragment.kt deleted file mode 100644 index 37a8890507..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/CreateLegacyGroupFragment.kt +++ /dev/null @@ -1,132 +0,0 @@ -package org.thoughtcrime.securesms.groups.legacy - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.RecyclerView -import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.R -import network.loki.messenger.databinding.FragmentCreateGroupBinding -import nl.komponents.kovenant.ui.failUi -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 -import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate -import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView -import com.bumptech.glide.Glide -import org.thoughtcrime.securesms.util.fadeIn -import org.thoughtcrime.securesms.util.fadeOut -import javax.inject.Inject - -@AndroidEntryPoint -class CreateLegacyGroupFragment : Fragment() { - - @Inject - lateinit var device: Device - - @Inject - lateinit var textSecurePreferences: TextSecurePreferences - - private lateinit var binding: FragmentCreateGroupBinding - private val viewModel: CreateLegacyGroupViewModel by viewModels() - - private val delegate: StartConversationDelegate - get() = (context as? StartConversationDelegate) - ?: (parentFragment as StartConversationDelegate) - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentCreateGroupBinding.inflate(inflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val adapter = SelectContactsAdapter(requireContext(), Glide.with(requireContext())) - binding.backButton.setOnClickListener { delegate.onDialogBackPressed() } - binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() } - binding.contactSearch.callbacks = object : KeyboardPageSearchView.Callbacks { - override fun onQueryChanged(query: String) { - adapter.members = viewModel.filter(query).map { it.address.serialize() } - } - } - binding.createNewPrivateChatButton.setOnClickListener { delegate.onNewMessageSelected() } - binding.recyclerView.adapter = adapter - val divider = ContextCompat.getDrawable(requireActivity(), R.drawable.conversation_menu_divider)!!.let { - DividerItemDecoration(requireActivity(), RecyclerView.VERTICAL).apply { - setDrawable(it) - } - } - binding.recyclerView.addItemDecoration(divider) - var isLoading = false - binding.createClosedGroupButton.setOnClickListener { - if (isLoading) return@setOnClickListener - val name = binding.nameEditText.text.trim() - if (name.isEmpty()) { - return@setOnClickListener Toast.makeText(context, R.string.groupNameEnterPlease, Toast.LENGTH_LONG).show() - } - - // Limit the group name length if it exceeds the limit - if (name.length > resources.getInteger(R.integer.max_group_and_community_name_length_chars)) { - return@setOnClickListener Toast.makeText(context, R.string.groupNameEnterShorter, Toast.LENGTH_LONG).show() - } - - val selectedMembers = adapter.selectedMembers - if (selectedMembers.isEmpty()) { - return@setOnClickListener Toast.makeText(context, R.string.groupCreateErrorNoMembers, Toast.LENGTH_LONG).show() - } - if (selectedMembers.count() >= groupSizeLimit) { // Minus one because we're going to include self later - return@setOnClickListener Toast.makeText(context, R.string.groupAddMemberMaximum, Toast.LENGTH_LONG).show() - } - val userPublicKey = textSecurePreferences.getLocalNumber()!! - isLoading = true - binding.loaderContainer.fadeIn() - 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)) - openConversationActivity( - requireContext(), - threadID, - Recipient.from(requireContext(), Address.fromSerialized(groupID), false) - ) - delegate.onDialogClosePressed() - }.failUi { - binding.loaderContainer.fadeOut() - isLoading = false - Toast.makeText(context, it.message, Toast.LENGTH_LONG).show() - } - } - binding.mainContentGroup.isVisible = !viewModel.recipients.value.isNullOrEmpty() - binding.emptyStateGroup.isVisible = viewModel.recipients.value.isNullOrEmpty() - viewModel.recipients.observe(viewLifecycleOwner) { recipients -> - adapter.members = recipients.map { it.address.serialize() } - } - } - - private fun openConversationActivity(context: Context, threadId: Long, recipient: Recipient) { - val intent = Intent(context, ConversationActivityV2::class.java) - intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) - intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) - context.startActivity(intent) - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/CreateLegacyGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/CreateLegacyGroupViewModel.kt deleted file mode 100644 index 64d639cda4..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/CreateLegacyGroupViewModel.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.thoughtcrime.securesms.groups.legacy - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.database.ThreadDatabase -import javax.inject.Inject - -@HiltViewModel -class CreateLegacyGroupViewModel @Inject constructor( - private val threadDb: ThreadDatabase, - private val textSecurePreferences: TextSecurePreferences -) : ViewModel() { - - private val _recipients = MutableLiveData>() - val recipients: LiveData> = _recipients - - init { - viewModelScope.launch { - threadDb.approvedConversationList.use { openCursor -> - val reader = threadDb.readerFor(openCursor) - val recipients = mutableListOf() - while (true) { - recipients += reader.next?.recipient ?: break - } - withContext(Dispatchers.Main) { - _recipients.value = recipients - .filter { !it.isGroupRecipient && it.hasApprovedMe() && it.address.serialize() != textSecurePreferences.getLocalNumber() } - } - } - } - } - - fun filter(query: String): List { - return _recipients.value?.filter { - it.address.serialize().contains(query, ignoreCase = true) || it.name.contains(query, ignoreCase = true) - } ?: emptyList() - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/EditLegacyClosedGroupLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/EditLegacyClosedGroupLoader.kt deleted file mode 100644 index abea121fc4..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/EditLegacyClosedGroupLoader.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.thoughtcrime.securesms.groups.legacy - -import android.content.Context -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.AsyncLoader - -class EditLegacyClosedGroupLoader(context: Context, val groupID: String) : AsyncLoader(context) { - - override fun loadInBackground(): EditLegacyGroupActivity.GroupMembers { - val groupDatabase = DatabaseComponent.get(context).groupDatabase() - val members = groupDatabase.getGroupMembers(groupID, true) - val zombieMembers = groupDatabase.getGroupZombieMembers(groupID) - return EditLegacyGroupActivity.GroupMembers( - members.map { - it.address.toString() - }, - zombieMembers.map { - it.address.toString() - } - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/EditLegacyGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/EditLegacyGroupActivity.kt deleted file mode 100644 index 29e7f1c0a3..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/EditLegacyGroupActivity.kt +++ /dev/null @@ -1,365 +0,0 @@ -package org.thoughtcrime.securesms.groups.legacy - -import android.content.Context -import android.content.Intent -import android.os.Bundle -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.EditText -import android.widget.LinearLayout -import android.widget.TextView -import android.widget.Toast -import androidx.lifecycle.lifecycleScope -import androidx.loader.app.LoaderManager -import androidx.loader.content.Loader -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch -import java.io.IOException -import javax.inject.Inject -import network.loki.messenger.R -import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.messaging.sending_receiving.groupSizeLimit -import org.session.libsession.messaging.sending_receiving.leave -import org.session.libsession.utilities.Address -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.ClosedGroupEditingOptionsBottomSheet -import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup -import org.thoughtcrime.securesms.util.fadeIn -import org.thoughtcrime.securesms.util.fadeOut - -@AndroidEntryPoint -class EditLegacyGroupActivity : PassphraseRequiredActionBarActivity() { - - @Inject - lateinit var groupConfigFactory: ConfigFactory - @Inject - lateinit var storage: Storage - - private val originalMembers = HashSet() - private val zombies = HashSet() - private val members = HashSet() - private val allMembers: Set - get() { - return members + zombies - } - private var hasNameChanged = false - private var isSelfAdmin = false - private var isLoading = false - set(newValue) { field = newValue; invalidateOptionsMenu() } - - private val groupInfo by lazy { DatabaseComponent.get(this).groupDatabase().getGroup(groupID).get() } - - private lateinit var groupID: String - private lateinit var originalName: String - private lateinit var name: String - - private var isEditingName = false - set(value) { - if (field == value) return - field = value - handleIsEditingNameChanged() - } - - private val memberListAdapter by lazy { - if (isSelfAdmin) - EditLegacyGroupMembersAdapter(this, Glide.with(this), isSelfAdmin, ::checkUserIsAdmin , this::onMemberClick) - else - EditLegacyGroupMembersAdapter(this, Glide.with(this), isSelfAdmin, ::checkUserIsAdmin) - } - - private lateinit var mainContentContainer: LinearLayout - private lateinit var cntGroupNameEdit: LinearLayout - private lateinit var cntGroupNameDisplay: LinearLayout - private lateinit var edtGroupName: EditText - private lateinit var emptyStateContainer: LinearLayout - private lateinit var lblGroupNameDisplay: TextView - private lateinit var loaderContainer: View - - companion object { - @JvmStatic val groupIDKey = "groupIDKey" - private val loaderID = 0 - val addUsersRequestCode = 124 - val legacyGroupSizeLimit = 10 - } - - // region Lifecycle - override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { - super.onCreate(savedInstanceState, isReady) - setContentView(R.layout.activity_edit_closed_group) - - supportActionBar!!.setHomeAsUpIndicator( - ThemeUtil.getThemedDrawableResId(this, R.attr.actionModeCloseDrawable)) - - groupID = intent.getStringExtra(groupIDKey)!! - - originalName = groupInfo.title - isSelfAdmin = checkUserIsAdmin(TextSecurePreferences.getLocalNumber(this)) - - name = originalName - - mainContentContainer = findViewById(R.id.mainContentContainer) - cntGroupNameEdit = findViewById(R.id.cntGroupNameEdit) - cntGroupNameDisplay = findViewById(R.id.cntGroupNameDisplay) - edtGroupName = findViewById(R.id.edtGroupName) - emptyStateContainer = findViewById(R.id.emptyStateContainer) - lblGroupNameDisplay = findViewById(R.id.lblGroupNameDisplay) - loaderContainer = findViewById(R.id.loaderContainer) - - findViewById(R.id.addMembersClosedGroupButton).setOnClickListener { - onAddMembersClick() - } - - findViewById(R.id.rvUserList).apply { - adapter = memberListAdapter - layoutManager = LinearLayoutManager(this@EditLegacyGroupActivity) - } - - lblGroupNameDisplay.text = originalName - - // Only allow admins to click on the name of closed groups to edit them.. - if (isSelfAdmin) { - cntGroupNameDisplay.setOnClickListener { isEditingName = true } - } - else // ..and also hide the edit `drawableEnd` for non-admins. - { - // Note: compoundDrawables returns 4 drawables (drawablesStart/Top/End/Bottom) - - // so the `drawableEnd` component is at index 2, which we replace with null. - val cd = lblGroupNameDisplay.compoundDrawables - lblGroupNameDisplay.setCompoundDrawables(cd[0], cd[1], null, cd[3]) - } - - findViewById(R.id.btnCancelGroupNameEdit).setOnClickListener { isEditingName = false } - findViewById(R.id.btnSaveGroupNameEdit).setOnClickListener { saveName() } - edtGroupName.setImeActionLabel(getString(R.string.save), EditorInfo.IME_ACTION_DONE) - edtGroupName.setOnEditorActionListener { _, actionId, _ -> - when (actionId) { - EditorInfo.IME_ACTION_DONE -> { - saveName() - return@setOnEditorActionListener true - } - else -> return@setOnEditorActionListener false - } - } - - LoaderManager.getInstance(this).initLoader(loaderID, null, object : LoaderManager.LoaderCallbacks { - - override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { - return EditLegacyClosedGroupLoader(this@EditLegacyGroupActivity, groupID) - } - - override fun onLoadFinished(loader: Loader, groupMembers: GroupMembers) { - // We no longer need any subsequent loading events - // (they will occur on every activity resume). - LoaderManager.getInstance(this@EditLegacyGroupActivity).destroyLoader(loaderID) - - members.clear() - members.addAll(groupMembers.members.toHashSet()) - zombies.clear() - zombies.addAll(groupMembers.zombieMembers.toHashSet()) - originalMembers.clear() - originalMembers.addAll(members + zombies) - updateMembers() - } - - override fun onLoaderReset(loader: Loader) { - updateMembers() - } - }) - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_edit_closed_group, menu) - return allMembers.isNotEmpty() && !isLoading - } - // endregion - - // region Updating - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - when (requestCode) { - addUsersRequestCode -> { - if (resultCode != RESULT_OK) return - if (data == null || data.extras == null || !data.hasExtra(SelectContactsActivity.selectedContactsKey)) return - - val selectedContacts = data.extras!!.getStringArray(SelectContactsActivity.selectedContactsKey)!!.toSet() - members.addAll(selectedContacts) - updateMembers() - } - } - } - - private fun checkUserIsAdmin(userId: String?): Boolean{ - return groupInfo.admins.any { it.serialize() == userId } - } - - private fun handleIsEditingNameChanged() { - cntGroupNameEdit.visibility = if (isEditingName) View.VISIBLE else View.INVISIBLE - cntGroupNameDisplay.visibility = if (isEditingName) View.INVISIBLE else View.VISIBLE - val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - if (isEditingName) { - edtGroupName.setText(name) - edtGroupName.selectAll() - edtGroupName.requestFocus() - inputMethodManager.showSoftInput(edtGroupName, 0) - } else { - inputMethodManager.hideSoftInputFromWindow(edtGroupName.windowToken, 0) - } - } - - private fun updateMembers() { - memberListAdapter.setMembers(allMembers) - memberListAdapter.setZombieMembers(zombies) - - mainContentContainer.visibility = if (allMembers.isEmpty()) View.GONE else View.VISIBLE - emptyStateContainer.visibility = if (allMembers.isEmpty()) View.VISIBLE else View.GONE - - invalidateOptionsMenu() - } - // endregion - - // region Interaction - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_apply -> if (!isLoading) { commitChanges() } - } - return super.onOptionsItemSelected(item) - } - - private fun onMemberClick(member: String) { - val bottomSheet = ClosedGroupEditingOptionsBottomSheet() - bottomSheet.onRemoveTapped = { - if (zombies.contains(member)) zombies.remove(member) - else members.remove(member) - updateMembers() - bottomSheet.dismiss() - } - bottomSheet.show(supportFragmentManager, "GroupEditingOptionsBottomSheet") - } - - private fun onAddMembersClick() { - val intent = Intent(this@EditLegacyGroupActivity, SelectContactsActivity::class.java) - intent.putExtra(SelectContactsActivity.usersToExcludeKey, allMembers.toTypedArray()) - intent.putExtra(SelectContactsActivity.emptyStateTextKey, "No contacts to add") - startActivityForResult(intent, addUsersRequestCode) - } - - private fun saveName() { - val name = edtGroupName.text.toString().trim() - if (name.isEmpty()) { - return Toast.makeText(this, R.string.groupNameEnterPlease, Toast.LENGTH_SHORT).show() - } - if (name.length >= 64) { - return Toast.makeText(this, R.string.groupNameEnterShorter, Toast.LENGTH_SHORT).show() - } - this.name = name - lblGroupNameDisplay.text = name - hasNameChanged = true - isEditingName = false - } - - private fun commitChanges() { - val hasMemberListChanges = (allMembers != originalMembers) - - if (!hasNameChanged && !hasMemberListChanges) { - return finish() - } - - val name = if (hasNameChanged) this.name else originalName - - val members = this.allMembers.map { - Recipient.from(this, Address.fromSerialized(it), false) - }.toSet() - val originalMembers = this.originalMembers.map { - Recipient.from(this, Address.fromSerialized(it), false) - }.toSet() - - var isClosedGroup: Boolean - var groupPublicKey: String? - try { - groupPublicKey = GroupUtil.doubleDecodeGroupID(groupID).toHexString() - isClosedGroup = DatabaseComponent.get(this).lokiAPIDatabase().isClosedGroup(groupPublicKey) - } catch (e: IOException) { - groupPublicKey = null - isClosedGroup = false - } - - if (members.isEmpty()) { - return Toast.makeText(this, R.string.groupCreateErrorNoMembers, Toast.LENGTH_LONG).show() - } - - val maxGroupMembers = if (isClosedGroup) groupSizeLimit else legacyGroupSizeLimit - if (members.size >= maxGroupMembers) { - return Toast.makeText(this, R.string.groupAddMemberMaximum, Toast.LENGTH_LONG).show() - } - - val userPublicKey = TextSecurePreferences.getLocalNumber(this)!! - val userAsRecipient = Recipient.from(this, Address.fromSerialized(userPublicKey), false) - - // There's presently no way in the UI to get into the state whereby you could remove yourself from the group when removing any other members - // (you can't unselect yourself - the only way to leave is to "Leave Group" from the menu) - but it's possible that this was not always - // the case - so we can leave this in as defensive code in-case something goes screwy. - if (!members.contains(userAsRecipient) && !members.map { it.address.toString() }.containsAll(originalMembers.minus(userPublicKey))) { - return Log.w("EditClosedGroup", "Can't leave group while adding or removing other members.") - } - - if (isClosedGroup) { - isLoading = true - loaderContainer.fadeIn() - try { - if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) { - lifecycleScope.launch { - try { - MessageSender.leave(groupPublicKey!!, false) - } catch (e: Exception) { - Log.e("EditClosedGroup", "Failed to leave group", e) - } - } - - } else { - if (hasNameChanged) { - MessageSender.explicitNameChange(groupPublicKey!!, name) - } - members.filterNot { it in originalMembers }.let { adds -> - if (adds.isNotEmpty()) MessageSender.explicitAddMembers(groupPublicKey!!, adds.map { it.address.serialize() }) - } - originalMembers.filterNot { it in members }.let { removes -> - if (removes.isNotEmpty()) MessageSender.explicitRemoveMembers(groupPublicKey!!, removes.map { it.address.serialize() }) - } - } - loaderContainer.fadeOut() - isLoading = false - updateGroupConfig() - finish() - } catch (exception: Exception) { - val message = if (exception is MessageSender.Error) exception.description else "An error occurred" - Toast.makeText(this@EditLegacyGroupActivity, message, Toast.LENGTH_LONG).show() - loaderContainer.fadeOut() - isLoading = false - } - } - } - - private fun updateGroupConfig() { - 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, val zombieMembers: List) -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/EditLegacyGroupMembersAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/EditLegacyGroupMembersAdapter.kt deleted file mode 100644 index f039ecdaa0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/legacy/EditLegacyGroupMembersAdapter.kt +++ /dev/null @@ -1,68 +0,0 @@ -package org.thoughtcrime.securesms.groups.legacy - -import android.content.Context -import androidx.recyclerview.widget.RecyclerView -import android.view.ViewGroup -import org.session.libsession.utilities.Address -import org.thoughtcrime.securesms.contacts.UserView -import com.bumptech.glide.RequestManager -import org.session.libsession.utilities.recipients.Recipient -import org.session.libsession.utilities.TextSecurePreferences - -class EditLegacyGroupMembersAdapter( - private val context: Context, - private val glide: RequestManager, - private val admin: Boolean, - private val checkIsAdmin: (String) -> Boolean, - private val memberClickListener: ((String) -> Unit)? = null -) : RecyclerView.Adapter() { - - private val members = ArrayList() - private val zombieMembers = ArrayList() - - fun setMembers(members: Collection) { - this.members.clear() - this.members.addAll(members) - notifyDataSetChanged() - } - - fun setZombieMembers(members: Collection) { - this.zombieMembers.clear() - this.zombieMembers.addAll(members) - notifyDataSetChanged() - } - - override fun getItemCount(): Int = members.size - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val view = UserView(context) - return ViewHolder(view) - } - - override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { - val member = members[position] - - val unlocked = admin && member != TextSecurePreferences.getLocalNumber(context) - - viewHolder.view.bind(Recipient.from( - context, - Address.fromSerialized(member), false), - glide, - if (unlocked) UserView.ActionIndicator.Menu else UserView.ActionIndicator.None) - - if (zombieMembers.contains(member)) - viewHolder.view.alpha = 0.5F - else - viewHolder.view.alpha = 1F - - if (unlocked) { - viewHolder.view.setOnClickListener { this.memberClickListener?.invoke(member) } - } else { - viewHolder.view.setOnClickListener(null) - } - - viewHolder.view.handleAdminStatus(checkIsAdmin(member)) - } - - class ViewHolder(val view: UserView) : RecyclerView.ViewHolder(view) -} \ 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 3ba2b25c88..85bb25bd89 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -13,9 +13,10 @@ import network.loki.messenger.R import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.utilities.GroupRecord -import org.session.libsession.utilities.getGroup -import org.session.libsession.utilities.wasKickedFromGroupV2 +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.getConversationUnread @@ -35,6 +36,9 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto @Inject lateinit var configFactory: ConfigFactory @Inject lateinit var deprecationManager: LegacyGroupDeprecationManager + @Inject lateinit var groupDatabase: GroupDatabase + @Inject lateinit var textSecurePreferences: TextSecurePreferences + var onViewDetailsTapped: (() -> Unit?)? = null var onCopyConversationId: (() -> Unit?)? = null var onPinTapped: (() -> Unit)? = null @@ -43,8 +47,9 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto var onUnblockTapped: (() -> Unit)? = null var onDeleteTapped: (() -> Unit)? = null var onMarkAllAsReadTapped: (() -> Unit)? = null + var onMarkAsUnreadTapped : (() -> Unit)? = null var onNotificationTapped: (() -> Unit)? = null - var onSetMuteTapped: ((Boolean) -> Unit)? = null + var onDeleteContactTapped: (() -> Unit)? = null override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FragmentConversationBottomSheetBinding.inflate(LayoutInflater.from(parentContext), container, false) @@ -62,9 +67,9 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto binding.unblockTextView -> onUnblockTapped?.invoke() binding.deleteTextView -> onDeleteTapped?.invoke() binding.markAllAsReadTextView -> onMarkAllAsReadTapped?.invoke() + binding.markAsUnreadTextView -> onMarkAsUnreadTapped?.invoke() binding.notificationsTextView -> onNotificationTapped?.invoke() - binding.unMuteNotificationsTextView -> onSetMuteTapped?.invoke(false) - binding.muteNotificationsTextView -> onSetMuteTapped?.invoke(true) + binding.deleteContactTextView -> onDeleteContactTapped?.invoke() } } @@ -72,6 +77,9 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto super.onViewCreated(view, savedInstanceState) if (!this::thread.isInitialized) { return dismiss() } val recipient = thread.recipient + + binding.deleteContactTextView.isVisible = false + if (!recipient.isGroupOrCommunityRecipient && !recipient.isLocalNumber) { binding.detailsTextView.visibility = View.VISIBLE binding.unblockTextView.visibility = if (recipient.isBlocked) View.VISIBLE else View.GONE @@ -94,16 +102,14 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto binding.copyCommunityUrl.isVisible = recipient.isCommunityRecipient binding.copyCommunityUrl.setOnClickListener(this) - binding.unMuteNotificationsTextView.isVisible = recipient.isMuted && !recipient.isLocalNumber - && !isDeprecatedLegacyGroup - binding.muteNotificationsTextView.isVisible = !recipient.isMuted && !recipient.isLocalNumber - && !isDeprecatedLegacyGroup - - binding.unMuteNotificationsTextView.setOnClickListener(this) - binding.muteNotificationsTextView.setOnClickListener(this) - binding.notificationsTextView.isVisible = recipient.isGroupOrCommunityRecipient && !recipient.isMuted - && !isDeprecatedLegacyGroup - + val notificationIconRes = when{ + recipient.isMuted -> R.drawable.ic_volume_off + recipient.notifyType == RecipientDatabase.NOTIFY_TYPE_MENTIONS -> + R.drawable.ic_at_sign + else -> R.drawable.ic_volume_2 + } + binding.notificationsTextView.setCompoundDrawablesWithIntrinsicBounds(notificationIconRes, 0, 0, 0) + binding.notificationsTextView.isVisible = !recipient.isLocalNumber && !isDeprecatedLegacyGroup binding.notificationsTextView.setOnClickListener(this) // delete @@ -114,14 +120,34 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto // the text, content description and icon will change depending on the type when { + recipient.isLegacyGroupRecipient -> { + val group = groupDatabase.getGroup(recipient.address.toString()).orNull() + + val isGroupAdmin = group.admins.map { it.toString() } + .contains(textSecurePreferences.getLocalNumber()) + + if (isGroupAdmin) { + text = context.getString(R.string.delete) + contentDescription = context.getString(R.string.AccessibilityId_delete) + drawableStartRes = R.drawable.ic_trash_2 + } else { + text = context.getString(R.string.leave) + contentDescription = context.getString(R.string.AccessibilityId_leave) + drawableStartRes = R.drawable.ic_log_out + } + } + // groups and communities - recipient.isGroupOrCommunityRecipient -> { - // if you are in a group V2 and have been kicked of that group, + recipient.isGroupV2Recipient -> { + val accountId = AccountId(recipient.address.toString()) + val group = configFactory.withUserConfigs { it.userGroups.getClosedGroup(accountId.hexString) } ?: return + // if you are in a group V2 and have been kicked of that group, or the group was destroyed, + // or if the user is an admin // the button should read 'Delete' instead of 'Leave' - if (configFactory.wasKickedFromGroupV2(recipient)) { + if (!group.shouldPoll || group.hasAdminKey()) { text = context.getString(R.string.delete) contentDescription = context.getString(R.string.AccessibilityId_delete) - drawableStartRes = R.drawable.ic_delete_24 + drawableStartRes = R.drawable.ic_trash_2 } else { text = context.getString(R.string.leave) contentDescription = context.getString(R.string.AccessibilityId_leave) @@ -129,28 +155,52 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto } } + recipient.isCommunityRecipient -> { + text = context.getString(R.string.leave) + contentDescription = context.getString(R.string.AccessibilityId_leave) + drawableStartRes = R.drawable.ic_log_out + } + // note to self recipient.isLocalNumber -> { text = context.getString(R.string.hide) contentDescription = context.getString(R.string.AccessibilityId_clear) - drawableStartRes = R.drawable.ic_delete_24 + drawableStartRes = R.drawable.ic_eye_off } // 1on1 else -> { - text = context.getString(R.string.delete) + text = context.getString(R.string.conversationsDelete) contentDescription = context.getString(R.string.AccessibilityId_delete) - drawableStartRes = R.drawable.ic_delete_24 + drawableStartRes = R.drawable.ic_trash_2 + + // also show delete contact for 1on1 + binding.deleteContactTextView.isVisible = true + binding.deleteContactTextView.setOnClickListener(this@ConversationOptionsBottomSheet) } } TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(this, drawableStartRes, 0, 0, 0) } - binding.markAllAsReadTextView.isVisible = (thread.unreadCount > 0 || - configFactory.withUserConfigs { it.convoInfoVolatile.getConversationUnread(thread) }) - && !isDeprecatedLegacyGroup + // We have three states for a conversation: + // 1. The conversation has unread messages + // 2. The conversation is marked as unread from the config (which is different from having unread messages) + // 3. The conversation is up to date + // Case 1 and 2 should show the 'mark as read' button while case 3 should show 'mark as unread' + + // case 1 + val hasUnreadMessages = thread.unreadCount > 0 + + // case 2 + val isMarkedAsUnread = configFactory.withUserConfigs { it.convoInfoVolatile.getConversationUnread(thread)} + + val showMarkAsReadButton = hasUnreadMessages || isMarkedAsUnread + + binding.markAllAsReadTextView.isVisible = showMarkAsReadButton && !isDeprecatedLegacyGroup binding.markAllAsReadTextView.setOnClickListener(this) + binding.markAsUnreadTextView.isVisible = !showMarkAsReadButton && !isDeprecatedLegacyGroup + binding.markAsUnreadTextView.setOnClickListener(this) binding.pinTextView.isVisible = !thread.isPinned && !isDeprecatedLegacyGroup binding.unpinTextView.isVisible = thread.isPinned binding.pinTextView.setOnClickListener(this) 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 875ea31092..c8321b22b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -1,13 +1,12 @@ package org.thoughtcrime.securesms.home import android.content.Context +import android.content.res.ColorStateList import android.content.res.Resources -import android.graphics.Typeface -import android.graphics.drawable.ColorDrawable import android.util.AttributeSet -import android.util.TypedValue import android.view.View import android.widget.LinearLayout +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView @@ -21,16 +20,23 @@ 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.pro.ProStatusManager +import org.thoughtcrime.securesms.ui.ProBadgeText +import org.thoughtcrime.securesms.ui.setThemedContent +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.bold import org.thoughtcrime.securesms.util.DateUtils -import org.thoughtcrime.securesms.util.getAccentColor +import org.thoughtcrime.securesms.util.UnreadStylingHelper import org.thoughtcrime.securesms.util.getConversationUnread -import java.util.Locale import javax.inject.Inject @AndroidEntryPoint class ConversationView : LinearLayout { @Inject lateinit var configFactory: ConfigFactory + @Inject lateinit var dateUtils: DateUtils + @Inject lateinit var proStatusManager: ProStatusManager private val binding: ViewConversationBinding by lazy { ViewConversationBinding.bind(this) } private val screenWidth = Resources.getSystem().displayMetrics.widthPixels @@ -50,95 +56,100 @@ class ConversationView : LinearLayout { // region Updating fun bind(thread: ThreadRecord, isTyping: Boolean) { this.thread = thread - if (thread.isPinned) { - binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds( - 0, - 0, - R.drawable.ic_pin, - 0 - ) - } else { - binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 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.iconPinned.isVisible = thread.isPinned + + val isConversationUnread = (configFactory.withUserConfigs { it.convoInfoVolatile.getConversationUnread(thread) }) val unreadCount = thread.unreadCount + val hasUnreadCount = unreadCount > 0 + val isMarkedUnread = !hasUnreadCount && isConversationUnread + + binding.root.background = UnreadStylingHelper.getUnreadBackground(context, + hasUnreadCount || isMarkedUnread) + if (thread.recipient.isBlocked) { binding.accentView.setBackgroundColor(ThemeUtil.getThemedColor(context, R.attr.danger)) binding.accentView.visibility = View.VISIBLE } else { - val accentColor = context.getAccentColor() - val background = ColorDrawable(accentColor) - binding.accentView.background = background + binding.accentView.background = UnreadStylingHelper.getAccentBackground(context) // Using thread.isRead we can determine if the last message was our own, and display it as 'read' even though previous messages may not be // 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 + binding.accentView.visibility = if(hasUnreadCount) View.VISIBLE else View.INVISIBLE } - val formattedUnreadCount = if (unreadCount == 0) { - null - } else { - if (unreadCount < 10000) unreadCount.toString() else "9999+" + + binding.unreadCountTextView.apply{ + text = UnreadStylingHelper.formatUnreadCount(unreadCount) + isVisible = hasUnreadCount } - binding.unreadCountTextView.text = formattedUnreadCount - val textSize = if (unreadCount < 1000) 12.0f else 10.0f - binding.unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) - binding.unreadCountIndicator.isVisible = (unreadCount != 0 && !thread.isRead) - || (configFactory.withUserConfigs { it.convoInfoVolatile.getConversationUnread(thread) }) - binding.unreadMentionTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) - binding.unreadMentionIndicator.isVisible = (thread.unreadMentionCount != 0 && thread.recipient.address.isGroupOrCommunity) + + binding.unreadMentionBadge.isVisible = thread.unreadMentionCount != 0 + binding.markedUnreadIndicator.isVisible = isMarkedUnread + val senderDisplayName = getTitle(thread.recipient) - ?: thread.recipient.address.toString() - binding.conversationViewDisplayNameTextView.text = senderDisplayName - binding.timestampTextView.text = thread.date.takeIf { it != 0L }?.let { DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), it) } + + // Thread name and pro badge + binding.conversationViewDisplayName.text = senderDisplayName + binding.iconPro.isVisible = proStatusManager.shouldShowProBadge(thread.recipient.address) + && !thread.recipient.isLocalNumber + + binding.timestampTextView.text = thread.date.takeIf { it != 0L }?.let { dateUtils.getDisplayFormattedTimeSpanString( + 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) { - R.drawable.ic_outline_notifications_off_24 + R.drawable.ic_volume_off } else { - R.drawable.ic_notifications_mentions + R.drawable.ic_at_sign } + binding.muteIndicatorImageView.setImageResource(drawableRes) - binding.snippetTextView.text = highlightMentions( + val snippet = highlightMentions( text = thread.getDisplayBody(context), formatOnly = true, // no styling here, only text formatting threadID = thread.threadId, context = 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 + binding.snippetTextView.apply { + text = snippet + typeface = UnreadStylingHelper.getUnreadTypeface(hasUnreadCount) + visibility = if (isTyping) View.GONE else View.VISIBLE + } + if (isTyping) { binding.typingIndicatorView.root.startAnimation() } else { binding.typingIndicatorView.root.stopAnimation() } + binding.typingIndicatorView.root.visibility = if (isTyping) View.VISIBLE else View.GONE binding.statusIndicatorImageView.visibility = View.VISIBLE + binding.statusIndicatorImageView.imageTintList = ColorStateList.valueOf(ThemeUtil.getThemedColor(context, android.R.attr.textColorTertiary)) // tertiary in the current xml styling is actually what figma uses as secondary text color... + when { - !thread.isOutgoing -> binding.statusIndicatorImageView.visibility = View.GONE + !thread.isOutgoing || thread.lastMessage == null -> binding.statusIndicatorImageView.visibility = View.GONE + thread.isFailed -> { - val drawable = ContextCompat.getDrawable(context, R.drawable.ic_error)?.mutate() - drawable?.setTint(ThemeUtil.getThemedColor(context, R.attr.danger)) + val drawable = ContextCompat.getDrawable(context, R.drawable.ic_triangle_alert)?.mutate() binding.statusIndicatorImageView.setImageDrawable(drawable) + binding.statusIndicatorImageView.imageTintList = ColorStateList.valueOf(ThemeUtil.getThemedColor(context, R.attr.danger)) } - thread.isPending -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_dot_dot_dot) - thread.isRead -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_filled_circle_check) + thread.isPending -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_dots_custom) + thread.isRead -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_eye) else -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check) } + binding.profilePictureView.update(thread.recipient) } - fun recycle() { - binding.profilePictureView.recycle() - } + fun recycle() { binding.profilePictureView.recycle() } - private fun getTitle(recipient: Recipient): String? = when { + private fun getTitle(recipient: Recipient): String = when { recipient.isLocalNumber -> context.getString(R.string.noteToSelf) - else -> recipient.toShortString() // Internally uses the Contact API + else -> recipient.name // Internally uses the Contact API } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/EmptyView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/EmptyView.kt index 2ab5cad673..54a6743cce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/EmptyView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/EmptyView.kt @@ -56,7 +56,7 @@ internal fun EmptyView(newAccount: Boolean) { .format().toString() }, style = LocalType.current.base, - color = LocalColors.current.primary, + color = LocalColors.current.accent, textAlign = TextAlign.Center ) } 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 99e7518c1f..d4829a714f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -8,32 +8,39 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle +import android.view.ViewGroup.MarginLayoutParams import android.widget.Toast import androidx.activity.viewModels -import androidx.core.os.bundleOf -import androidx.core.view.isInvisible +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.Navigator import androidx.recyclerview.widget.LinearLayoutManager import com.bumptech.glide.Glide import com.bumptech.glide.RequestManager import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.databinding.ActivityHomeBinding import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.jobs.JobQueue @@ -45,13 +52,12 @@ import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_K import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.conversation.start.StartConversationFragment +import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 -import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper -import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils +import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsActivity import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase @@ -67,17 +73,29 @@ import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter import org.thoughtcrime.securesms.home.search.GlobalSearchInputLayout import org.thoughtcrime.securesms.home.search.GlobalSearchResult import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel +import org.thoughtcrime.securesms.home.search.SearchContactActionBottomSheet +import org.thoughtcrime.securesms.home.startconversation.StartConversationDestination import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.preferences.SettingsActivity +import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity -import org.thoughtcrime.securesms.showMuteDialog +import org.thoughtcrime.securesms.reviews.StoreReviewManager +import org.thoughtcrime.securesms.reviews.ui.InAppReview +import org.thoughtcrime.securesms.reviews.ui.InAppReviewViewModel import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.tokenpage.TokenPageNotificationManager +import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.setThemedContent +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.applySafeInsetsPaddings import org.thoughtcrime.securesms.util.disableClipping +import org.thoughtcrime.securesms.util.fadeIn +import org.thoughtcrime.securesms.util.fadeOut import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.show import org.thoughtcrime.securesms.util.start +import org.thoughtcrime.securesms.webrtc.WebRtcCallActivity import javax.inject.Inject // Intent extra keys so we know where we came from @@ -85,9 +103,10 @@ private const val NEW_ACCOUNT = "HomeActivity_NEW_ACCOUNT" private const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING" @AndroidEntryPoint -class HomeActivity : PassphraseRequiredActionBarActivity(), +class HomeActivity : ScreenLockActionBarActivity(), ConversationClickListener, - GlobalSearchInputLayout.GlobalSearchInputLayoutListener { + GlobalSearchInputLayout.GlobalSearchInputLayoutListener, + SearchContactActionBottomSheet.Callbacks{ private val TAG = "HomeActivity" @@ -101,15 +120,22 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), @Inject lateinit var groupDatabase: GroupDatabase @Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var configFactory: ConfigFactory + @Inject lateinit var tokenPageNotificationManager: TokenPageNotificationManager @Inject lateinit var groupManagerV2: GroupManagerV2 @Inject lateinit var deprecationManager: LegacyGroupDeprecationManager @Inject lateinit var lokiThreadDatabase: LokiThreadDatabase @Inject lateinit var sessionJobDatabase: SessionJobDatabase @Inject lateinit var clock: SnodeClock @Inject lateinit var messageNotifier: MessageNotifier + @Inject lateinit var dateUtils: DateUtils + @Inject lateinit var openGroupManager: OpenGroupManager + @Inject lateinit var storeReviewManager: StoreReviewManager + @Inject lateinit var proStatusManager: ProStatusManager + @Inject lateinit var startConversationNavigator: UINavigator private val globalSearchViewModel by viewModels() private val homeViewModel by viewModels() + private val inAppReviewViewModel by viewModels() private val publicKey: String by lazy { textSecurePreferences.getLocalNumber()!! } @@ -117,42 +143,72 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), HomeAdapter(context = this, configFactory = configFactory, listener = this, ::showMessageRequests, ::hideMessageRequests) } - private val globalSearchAdapter = GlobalSearchAdapter { model -> - when (model) { - is GlobalSearchAdapter.Model.Message -> push { - model.messageResult.run { - putExtra(ConversationActivityV2.THREAD_ID, threadId) - putExtra(ConversationActivityV2.SCROLL_MESSAGE_ID, sentTimestampMs) - putExtra(ConversationActivityV2.SCROLL_MESSAGE_AUTHOR, messageRecipient.address) + private val globalSearchAdapter by lazy { + GlobalSearchAdapter( + dateUtils = dateUtils, + onContactClicked = { model -> + when (model) { + is GlobalSearchAdapter.Model.Message -> push { + model.messageResult.run { + putExtra(ConversationActivityV2.THREAD_ID, threadId) + putExtra(ConversationActivityV2.SCROLL_MESSAGE_ID, sentTimestampMs) + putExtra( + ConversationActivityV2.SCROLL_MESSAGE_AUTHOR, + messageRecipient.address + ) + } + } + + is GlobalSearchAdapter.Model.SavedMessages -> push { + putExtra( + ConversationActivityV2.ADDRESS, + Address.fromSerialized(model.currentUserPublicKey) + ) + } + + is GlobalSearchAdapter.Model.Contact -> push { + putExtra( + ConversationActivityV2.ADDRESS, + model.contact.hexString.let(Address::fromSerialized) + ) + } + + is GlobalSearchAdapter.Model.GroupConversation -> model.groupId + .let { Recipient.from(this, Address.fromSerialized(it), false) } + .let(threadDb::getThreadIdIfExistsFor) + .takeIf { it >= 0 } + ?.let { + push { + putExtra( + ConversationActivityV2.THREAD_ID, + it + ) + } + } + + else -> Log.d("Loki", "callback with model: $model") } + }, + onContactLongPressed = { model -> + onSearchContactLongPress(model.contact.hexString, model.name) } - is GlobalSearchAdapter.Model.SavedMessages -> push { - putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey)) - } - is GlobalSearchAdapter.Model.Contact -> push { - putExtra( - ConversationActivityV2.ADDRESS, - model.contact.accountID.let(Address::fromSerialized) - ) - } + ) + } - is GlobalSearchAdapter.Model.GroupConversation -> model.groupRecord.encodedId - .let { Recipient.from(this, Address.fromSerialized(it), false) } - .let(threadDb::getThreadIdIfExistsFor) - .takeIf { it >= 0 } - ?.let { - push { putExtra(ConversationActivityV2.THREAD_ID, it) } - } - else -> Log.d("Loki", "callback with model: $model") - } + private fun onSearchContactLongPress(accountId: String, contactName: String) { + val bottomSheet = SearchContactActionBottomSheet.newInstance(accountId, contactName) + bottomSheet.show(supportFragmentManager, bottomSheet.tag) } private val isFromOnboarding: Boolean get() = intent.getBooleanExtra(FROM_ONBOARDING, false) private val isNewAccount: Boolean get() = intent.getBooleanExtra(NEW_ACCOUNT, false) + override val applyDefaultWindowInsets: Boolean + get() = false + // region Lifecycle - override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { - super.onCreate(savedInstanceState, isReady) + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + super.onCreate(savedInstanceState, ready) // Set content view binding = ActivityHomeBinding.inflate(layoutInflater) @@ -164,21 +220,22 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // Set up toolbar buttons binding.profileButton.setOnClickListener { openSettings() } binding.searchViewContainer.setOnClickListener { - globalSearchViewModel.refresh() - binding.globalSearchInputLayout.requestFocus() + homeViewModel.onSearchClicked() } binding.sessionToolbar.disableClipping() + binding.sessionHeaderProBadge.isVisible = homeViewModel.shouldShowCurrentUserProBadge() // Set up seed reminder view lifecycleScope.launchWhenStarted { binding.seedReminderView.setThemedContent { if (!textSecurePreferences.getHasViewedSeed()) SeedReminder { start() } } } + // Set up recycler view binding.globalSearchInputLayout.listener = this homeAdapter.setHasStableIds(true) homeAdapter.glide = glide - binding.recyclerView.adapter = homeAdapter + binding.conversationsRecyclerView.adapter = homeAdapter binding.globalSearchRecycler.adapter = globalSearchAdapter binding.configOutdatedView.setOnClickListener { @@ -186,11 +243,29 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), updateLegacyConfigView() } + // in case a phone call is in progress, this banner is visible and should bring the user back to the call + binding.callInProgress.setOnClickListener { + startActivity(WebRtcCallActivity.getCallActivityIntent(this)) + } + // Set up empty state view binding.emptyStateContainer.setThemedContent { EmptyView(isNewAccount) } + // set the compose dialog content + binding.dialogs.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setThemedContent { + val dialogsState by homeViewModel.dialogsState.collectAsState() + HomeDialogs( + dialogsState = dialogsState, + startConversationNavigator = startConversationNavigator, + sendCommand = homeViewModel::onCommand + ) + } + } + // Set up new conversation button binding.newConversationButton.setOnClickListener { showStartConversation() } // Observe blocked contacts changed events @@ -210,7 +285,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), 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 manager = binding.conversationsRecyclerView.layoutManager as LinearLayoutManager val firstPos = manager.findFirstCompletelyVisibleItemPosition() val offsetTop = if(firstPos >= 0) { manager.findViewByPosition(firstPos)?.let { view -> @@ -219,19 +294,16 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } else 0 homeAdapter.data = data if(firstPos >= 0) { manager.scrollToPositionWithOffset(firstPos, offsetTop) } - updateEmptyState() + binding.emptyStateContainer.isVisible = homeAdapter.itemCount == 0 } } } lifecycleScope.launchWhenStarted { launch(Dispatchers.Default) { - // Double check that the long poller is up - (applicationContext as ApplicationContext).startPollingIfNeeded() // update things based on TextSecurePrefs (profile info etc) // Set up remaining components if needed if (textSecurePreferences.getLocalNumber() != null) { - OpenGroupManager.startPolling() JobQueue.shared.resumePendingJobs() } @@ -243,11 +315,12 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } - // monitor the global search VM query + // sync view -> viewModel launch { - binding.globalSearchInputLayout.query + binding.globalSearchInputLayout.query() .collect(globalSearchViewModel::setQuery) } + // Get group results and display them launch { globalSearchViewModel.result.map { result -> @@ -258,7 +331,12 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), addAll(result.groupedContacts) } else -> buildList { - result.contactAndGroupList.takeUnless { it.isEmpty() }?.let { + val conversations = result.contactAndGroupList.toMutableList() + if(result.showNoteToSelf){ + conversations.add(GlobalSearchAdapter.Model.SavedMessages(publicKey)) + } + + conversations.takeUnless { it.isEmpty() }?.let { add(GlobalSearchAdapter.Model.Header(R.string.sessionConversations)) addAll(it) } @@ -286,13 +364,70 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } } + + // Schedule a notification about the new Token Page for 1 hour after running the updated app for the first time. + // Note: We do NOT schedule a debug notification on startup - but one may be triggered from the Debug Menu. + if (BuildConfig.BUILD_TYPE == "release") { + tokenPageNotificationManager.scheduleTokenPageNotification(constructDebugNotification = false) + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + homeViewModel.callBanner.collect { callBanner -> + when (callBanner) { + null -> binding.callInProgress.fadeOut() + else -> { + binding.callInProgress.text = callBanner + binding.callInProgress.fadeIn() + } + } + } + } + } + + // Set up search layout + lifecycleScope.launch { + homeViewModel.isSearchOpen.collect { open -> + setSearchShown(open) + } + } + + binding.root.applySafeInsetsPaddings( + applyBottom = false, + alsoApply = { insets -> + binding.globalSearchRecycler.updatePadding(bottom = insets.bottom) + binding.newConversationButton.updateLayoutParams { + bottomMargin = insets.bottom + resources.getDimensionPixelSize(R.dimen.new_conversation_button_bottom_offset) + } + } + ) + + // Set up in-app review + binding.inAppReviewView.setThemedContent { + InAppReview( + uiStateFlow = inAppReviewViewModel.uiState, + storeReviewManager = storeReviewManager, + sendCommands = inAppReviewViewModel::sendUiCommand, + ) + } + } + + override fun onCancelClicked() { + homeViewModel.onCancelSearchClicked() + } + + override fun onBlockContact(accountId: String) { + homeViewModel.blockContact(accountId) + } + + override fun onDeleteContact(accountId: String) { + homeViewModel.deleteContact(accountId) } private val GlobalSearchResult.groupedContacts: List get() { class NamedValue(val name: String?, val value: T) - // Unknown is temporarily to be grouped together with numbers title. - // https://optf.atlassian.net/browse/SES-2287 + // Unknown is temporarily to be grouped together with numbers title - see: SES-2287 val numbersTitle = "#" val unknownTitle = numbersTitle @@ -318,13 +453,31 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), .flatMap { (key, contacts) -> listOf( GlobalSearchAdapter.Model.SubHeader(key) - ) + contacts.sortedBy { it.name ?: it.value.accountID }.map { it.value }.map { GlobalSearchAdapter.Model.Contact(it, it.nickname ?: it.name, it.accountID == publicKey) } + ) + contacts.sortedBy { it.name ?: it.value.accountID }.map { it.value }.map { + GlobalSearchAdapter.Model.Contact( + contact = it, + isSelf = it.accountID == publicKey, + showProBadge = proStatusManager.shouldShowProBadge(Address.fromSerialized(it.accountID)) + ) + } } } private val GlobalSearchResult.contactAndGroupList: List get() = - contacts.map { GlobalSearchAdapter.Model.Contact(it, it.nickname ?: it.name, it.accountID == publicKey) } + - threads.map(GlobalSearchAdapter.Model::GroupConversation) + contacts.map { + GlobalSearchAdapter.Model.Contact( + contact = it, + isSelf = it.accountID == publicKey, + showProBadge = proStatusManager.shouldShowProBadge(Address.fromSerialized(it.accountID)) + ) + } + + threads.map { + GlobalSearchAdapter.Model.GroupConversation( + context = this@HomeActivity, + groupRecord = it, + showProBadge = proStatusManager.shouldShowProBadge(Address.fromSerialized(it.encodedId)) + ) + } private val GlobalSearchResult.messageResults: List get() { val unreadThreadMap = messages @@ -332,22 +485,37 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), .associateWith { mmsSmsDatabase.getUnreadCount(it) } return messages.map { - GlobalSearchAdapter.Model.Message(it, unreadThreadMap[it.threadId] ?: 0, it.conversationRecipient.isLocalNumber) + GlobalSearchAdapter.Model.Message( + messageResult = it, + unread = unreadThreadMap[it.threadId] ?: 0, + isSelf = it.conversationRecipient.isLocalNumber, + showProBadge = proStatusManager.shouldShowProBadge(it.conversationRecipient.address) + ) } } - override fun onInputFocusChanged(hasFocus: Boolean) { - setSearchShown(hasFocus || binding.globalSearchInputLayout.query.value.isNotEmpty()) - } + private fun setSearchShown(isSearchShown: Boolean) { + // Request focus immediately so the user can start typing + if (isSearchShown) { + binding.globalSearchInputLayout.requestFocus() + } + + binding.searchToolbar.isVisible = isSearchShown + binding.sessionToolbar.isVisible = !isSearchShown + binding.seedReminderView.isVisible = !TextSecurePreferences.getHasViewedSeed(this) && !isSearchShown + binding.globalSearchRecycler.isVisible = isSearchShown + + + // Show a fade in animation for the conversation list upon re-appearing + val shouldShowHomeAnimation = !isSearchShown && !binding.conversationListContainer.isVisible + + binding.conversationListContainer.isVisible = !isSearchShown + if (shouldShowHomeAnimation) { + binding.conversationListContainer.animate().cancel() + binding.conversationListContainer.alpha = 0f + binding.conversationListContainer.animate().alpha(1f).start() + } - private fun setSearchShown(isShown: Boolean) { - binding.searchToolbar.isVisible = isShown - binding.sessionToolbar.isVisible = !isShown - binding.recyclerView.isVisible = !isShown - binding.emptyStateContainer.isVisible = (binding.recyclerView.adapter as HomeAdapter).itemCount == 0 && binding.recyclerView.isVisible - binding.seedReminderView.isVisible = !TextSecurePreferences.getHasViewedSeed(this) && !isShown - binding.globalSearchRecycler.isInvisible = !isShown - binding.newConversationButton.isVisible = !isShown } private fun updateLegacyConfigView() { @@ -365,11 +533,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), binding.seedReminderView.isVisible = false } - // refresh search on resume, in case we a conversation was deleted - if (binding.globalSearchRecycler.isVisible){ - globalSearchViewModel.refresh() - } - updateLegacyConfigView() } @@ -390,11 +553,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // endregion // region Updating - private fun updateEmptyState() { - val threadCount = (binding.recyclerView.adapter)!!.itemCount - binding.emptyStateContainer.isVisible = threadCount == 0 && binding.recyclerView.isVisible - } - @Subscribe(threadMode = ThreadMode.MAIN) fun onUpdateProfileEvent(event: ProfilePictureModifiedEvent) { if (event.recipient.isLocalNumber) { @@ -406,7 +564,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private fun updateProfileButton() { binding.profileButton.publicKey = publicKey - binding.profileButton.displayName = textSecurePreferences.getProfileName() + binding.profileButton.displayName = homeViewModel.getCurrentUsername() binding.profileButton.recycle() binding.profileButton.update() } @@ -415,8 +573,13 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // region Interaction @Deprecated("Deprecated in Java") override fun onBackPressed() { - if (binding.globalSearchRecycler.isVisible) binding.globalSearchInputLayout.clearSearch(true) - else super.onBackPressed() + if (homeViewModel.isSearchOpen.value && binding.globalSearchInputLayout.handleBackPressed()) { + return + } + + if (!homeViewModel.onBackPressed()) { + super.onBackPressed() + } } override fun onConversationClick(thread: ThreadRecord) { @@ -432,13 +595,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), bottomSheet.group = groupDatabase.getGroup(thread.recipient.address.toString()).orNull() bottomSheet.onViewDetailsTapped = { bottomSheet.dismiss() - val userDetailsBottomSheet = UserDetailsBottomSheet() - val bundle = bundleOf( - UserDetailsBottomSheet.ARGUMENT_PUBLIC_KEY to thread.recipient.address.toString(), - UserDetailsBottomSheet.ARGUMENT_THREAD_ID to thread.threadId - ) - userDetailsBottomSheet.arguments = bundle - userDetailsBottomSheet.show(supportFragmentManager, userDetailsBottomSheet.tag) + homeViewModel.showUserProfileModal(thread) } bottomSheet.onCopyConversationId = onCopyConversationId@{ bottomSheet.dismiss() @@ -474,15 +631,13 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), bottomSheet.dismiss() deleteConversation(thread) } - bottomSheet.onSetMuteTapped = { muted -> - bottomSheet.dismiss() - setConversationMuted(thread, muted) - } bottomSheet.onNotificationTapped = { bottomSheet.dismiss() - NotificationUtils.showNotifyDialog(this, thread.recipient) { notifyType -> - setNotifyType(thread, notifyType) + // go to the notification settings + val intent = Intent(this, NotificationSettingsActivity::class.java).apply { + putExtra(NotificationSettingsActivity.THREAD_ID, thread.threadId) } + startActivity(intent) } bottomSheet.onPinTapped = { bottomSheet.dismiss() @@ -496,6 +651,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), bottomSheet.dismiss() markAllAsRead(thread) } + bottomSheet.onMarkAsUnreadTapped = { + bottomSheet.dismiss() + markAsUnread(thread) + } + bottomSheet.onDeleteContactTapped = { + bottomSheet.dismiss() + confirmDeleteContact(thread) + } bottomSheet.show(supportFragmentManager, bottomSheet.tag) } @@ -503,18 +666,18 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), showSessionDialog { title(R.string.block) text(Phrase.from(context, R.string.blockDescription) - .put(NAME_KEY, thread.recipient.toShortString()) + .put(NAME_KEY, thread.recipient.name) .format()) dangerButton(R.string.block, R.string.AccessibilityId_blockConfirm) { lifecycleScope.launch(Dispatchers.Default) { storage.setBlocked(listOf(thread.recipient), true) withContext(Dispatchers.Main) { - binding.recyclerView.adapter!!.notifyDataSetChanged() + binding.conversationsRecyclerView.adapter!!.notifyDataSetChanged() } } // Block confirmation toast added as per SS-64 - val txt = Phrase.from(context, R.string.blockBlockedUser).put(NAME_KEY, thread.recipient.toShortString()).format().toString() + val txt = Phrase.from(context, R.string.blockBlockedUser).put(NAME_KEY, thread.recipient.name).format().toString() Toast.makeText(context, txt, Toast.LENGTH_LONG).show() } cancelButton() @@ -524,12 +687,12 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private fun unblockConversation(thread: ThreadRecord) { showSessionDialog { title(R.string.blockUnblock) - text(Phrase.from(context, R.string.blockUnblockName).put(NAME_KEY, thread.recipient.toShortString()).format()) + text(Phrase.from(context, R.string.blockUnblockName).put(NAME_KEY, thread.recipient.name).format()) dangerButton(R.string.blockUnblock, R.string.AccessibilityId_unblockConfirm) { lifecycleScope.launch(Dispatchers.Default) { storage.setBlocked(listOf(thread.recipient), false) withContext(Dispatchers.Main) { - binding.recyclerView.adapter!!.notifyDataSetChanged() + binding.conversationsRecyclerView.adapter!!.notifyDataSetChanged() } } } @@ -537,45 +700,35 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } - private fun setConversationMuted(thread: ThreadRecord, isMuted: Boolean) { - if (!isMuted) { - lifecycleScope.launch(Dispatchers.Default) { - recipientDatabase.setMuted(thread.recipient, 0) - withContext(Dispatchers.Main) { - binding.recyclerView.adapter!!.notifyDataSetChanged() - } - } - } else { - showMuteDialog(this) { until -> - lifecycleScope.launch(Dispatchers.Default) { - recipientDatabase.setMuted(thread.recipient, until) - withContext(Dispatchers.Main) { - binding.recyclerView.adapter!!.notifyDataSetChanged() - } - } + private fun confirmDeleteContact(thread: ThreadRecord) { + showSessionDialog { + title(R.string.contactDelete) + text( + Phrase.from(context, R.string.deleteContactDescription) + .put(NAME_KEY, thread.recipient?.name ?: "") + .put(NAME_KEY, thread.recipient?.name ?: "") + .format() + ) + dangerButton(R.string.delete, R.string.qa_conversation_settings_dialog_delete_contact_confirm) { + homeViewModel.deleteContact(thread.recipient.address.toString()) } + cancelButton() } } - private fun setNotifyType(thread: ThreadRecord, newNotifyType: Int) { - lifecycleScope.launch(Dispatchers.Default) { - recipientDatabase.setNotifyType(thread.recipient, newNotifyType) - withContext(Dispatchers.Main) { - binding.recyclerView.adapter!!.notifyDataSetChanged() - } - } + private fun setConversationPinned(threadId: Long, pinned: Boolean) { + homeViewModel.setPinned(threadId, pinned) } - private fun setConversationPinned(threadId: Long, pinned: Boolean) { + private fun markAllAsRead(thread: ThreadRecord) { lifecycleScope.launch(Dispatchers.Default) { - storage.setPinned(threadId, pinned) - homeViewModel.tryReload() + storage.markConversationAsRead(thread.threadId, clock.currentTimeMills()) } } - private fun markAllAsRead(thread: ThreadRecord) { + private fun markAsUnread(thread : ThreadRecord){ lifecycleScope.launch(Dispatchers.Default) { - storage.markConversationAsRead(thread.threadId, clock.currentTimeMills()) + storage.markConversationAsUnread(thread.threadId) } } @@ -584,14 +737,19 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), val recipient = thread.recipient if (recipient.isGroupV2Recipient) { - ConversationMenuHelper.leaveGroup( - context = this, - thread = recipient, + val accountId = AccountId(recipient.address.toString()) + val group = configFactory.withUserConfigs { it.userGroups.getClosedGroup(accountId.hexString) } ?: return + val name = configFactory.withGroupConfigs(accountId) { + it.groupInfo.getName() + } ?: group.name + + confirmAndLeaveGroup( + dialogData = groupManagerV2.getLeaveGroupConfirmationDialogData(accountId, name), threadID = threadID, - configFactory = configFactory, storage = storage, - groupManager = groupManagerV2, - deprecationManager = deprecationManager + doLeave = { + homeViewModel.leaveGroup(accountId) + } ) return @@ -607,21 +765,15 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), lifecycleScope.launch(Dispatchers.Main) { val context = this@HomeActivity // Cancel any outstanding jobs - sessionJobDatabase - .cancelPendingMessageSendJobs(threadID) + sessionJobDatabase.cancelPendingMessageSendJobs(threadID) // Delete the conversation - val community = lokiThreadDatabase - .getOpenGroupChat(threadID) + val community = lokiThreadDatabase.getOpenGroupChat(threadID) if (community != null) { - OpenGroupManager.delete( - community.server, - community.room, - context - ) + openGroupManager.delete(community.server, community.room, context) } else { lifecycleScope.launch(Dispatchers.Default) { - threadDb.deleteConversation(threadID) + storage.deleteConversation(threadID) } } @@ -662,7 +814,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // Note to self if (recipient.isLocalNumber) { title = getString(R.string.noteToSelfHide) - message = getString(R.string.noteToSelfHideDescription) + message = getText(R.string.hideNoteToSelfDescription) positiveButtonId = R.string.hide // change the action for Note To Self, as they should only be hidden and the messages should remain undeleted @@ -672,8 +824,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } else { // If this is a 1-on-1 conversation title = getString(R.string.conversationsDelete) - message = Phrase.from(this, R.string.conversationsDeleteDescription) - .put(NAME_KEY, recipient.toShortString()) + message = Phrase.from(this, R.string.deleteConversationDescription) + .put(NAME_KEY, recipient.name) .format() } } @@ -688,6 +840,36 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } + private fun confirmAndLeaveGroup( + dialogData: GroupManagerV2.ConfirmDialogData?, + threadID: Long, + storage: StorageProtocol, + doLeave: suspend () -> Unit, + ) { + if (dialogData == null) return + + showSessionDialog { + title(dialogData.title) + text(dialogData.message) + dangerButton( + dialogData.positiveText, + contentDescriptionRes = dialogData.positiveQaTag ?: dialogData.positiveText + ) { + GlobalScope.launch(Dispatchers.Default) { + // Cancel any outstanding jobs + storage.cancelPendingMessageSendJobs(threadID) + + doLeave() + } + + } + button( + dialogData.negativeText, + contentDescriptionRes = dialogData.negativeQaTag ?: dialogData.negativeText + ) + } + } + private fun openSettings() { val intent = Intent(this, SettingsActivity::class.java) show(intent, isForResult = true) @@ -710,7 +892,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } private fun showStartConversation() { - StartConversationFragment().show(supportFragmentManager, "StartConversationFragment") + homeViewModel.onCommand(HomeViewModel.Commands.ShowStartConversationSheet) } } 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 1e64fc3f4f..554ffa8804 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt @@ -36,9 +36,9 @@ class HomeAdapter( } override fun getItemId(position: Int): Long { - when (val item = data.items[position]) { - is HomeViewModel.Item.MessageRequests -> return NO_ID - is HomeViewModel.Item.Thread -> return item.thread.threadId + return when (val item = data.items[position]) { + is HomeViewModel.Item.MessageRequests -> NO_ID + is HomeViewModel.Item.Thread -> item.thread.threadId } } @@ -56,7 +56,9 @@ class HomeAdapter( ITEM_TYPE_CONVO -> { 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.setOnClickListener { viewHolder.view.thread?.let { threadRecord -> + listener.onConversationClick(threadRecord) } + } viewHolder.view.setOnLongClickListener { viewHolder.view.thread?.let { listener.onLongConversationClick(it) } true diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt new file mode 100644 index 0000000000..27c8f4655d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.home + +import androidx.compose.runtime.Composable +import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HandleUserProfileCommand +import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HidePinCTADialog +import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HideUserProfileModal +import org.thoughtcrime.securesms.home.startconversation.StartConversationDestination +import org.thoughtcrime.securesms.home.startconversation.StartConversationSheet +import org.thoughtcrime.securesms.ui.PinProCTA +import org.thoughtcrime.securesms.ui.UINavigator +import org.thoughtcrime.securesms.ui.UserProfileModal +import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme + +@Composable +fun HomeDialogs( + dialogsState: HomeViewModel.DialogsState, + startConversationNavigator: UINavigator, + sendCommand: (HomeViewModel.Commands) -> Unit +) { + SessionMaterialTheme { + // pin CTA + if(dialogsState.pinCTA != null){ + PinProCTA( + overTheLimit = dialogsState.pinCTA.overTheLimit, + onDismissRequest = { + sendCommand(HidePinCTADialog) + } + ) + } + + if(dialogsState.userProfileModal != null){ + UserProfileModal( + data = dialogsState.userProfileModal, + onDismissRequest = { + sendCommand(HideUserProfileModal) + }, + sendCommand = { + sendCommand(HandleUserProfileCommand(it)) + }, + ) + } + + if(dialogsState.showStartConversationSheet != null){ + StartConversationSheet( + accountId = dialogsState.showStartConversationSheet.accountId, + navigator = startConversationNavigator, + onDismissRequest = { + sendCommand(HomeViewModel.Commands.HideStartConversationSheet) + } + ) + } + } +} 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 d77eb5b19e..52cc772424 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt @@ -55,6 +55,7 @@ class HomeDiffUtil( if (isSameItem) { isSameItem = (oldItem.count == newItem.count) } if (isSameItem) { isSameItem = (oldItem.unreadCount == newItem.unreadCount) } if (isSameItem) { isSameItem = (oldItem.isPinned == newItem.isPinned) } + if (isSameItem) { isSameItem = (oldItem.isRead == newItem.isRead) } // 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeLoader.kt deleted file mode 100644 index 6935fb24a1..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeLoader.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.thoughtcrime.securesms.home - -import android.content.Context -import android.database.Cursor -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.AbstractCursorLoader - -class HomeLoader(context: Context, val onNewCursor: (Cursor?) -> Unit) : AbstractCursorLoader(context) { - - override fun getCursor(): Cursor { - return DatabaseComponent.get(context).threadDatabase().approvedConversationList - } - - override fun deliverResult(newCursor: Cursor?) { - super.deliverResult(newCursor) - onNewCursor(newCursor) - } -} \ 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 67d80da4f3..17ce2e14c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -7,13 +7,17 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -25,31 +29,70 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.R import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigUpdateNotification import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.UsernameUtils +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository +import org.thoughtcrime.securesms.util.UserProfileModalCommands +import org.thoughtcrime.securesms.util.UserProfileModalData +import org.thoughtcrime.securesms.util.UserProfileUtils import org.thoughtcrime.securesms.util.observeChanges +import org.thoughtcrime.securesms.webrtc.CallManager +import org.thoughtcrime.securesms.webrtc.data.State import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( + @param:ApplicationContext + private val context: Context, private val threadDb: ThreadDatabase, private val contentResolver: ContentResolver, private val prefs: TextSecurePreferences, private val typingStatusRepository: TypingStatusRepository, - private val configFactory: ConfigFactory + private val configFactory: ConfigFactory, + private val callManager: CallManager, + private val usernameUtils: UsernameUtils, + private val storage: StorageProtocol, + private val groupManager: GroupManagerV2, + private val proStatusManager: ProStatusManager, + private val upmFactory: UserProfileUtils.UserProfileUtilsFactory, ) : ViewModel() { // SharedFlow that emits whenever the user asks us to reload the conversation private val manualReloadTrigger = MutableSharedFlow( - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST ) + private val mutableIsSearchOpen = MutableStateFlow(false) + val isSearchOpen: StateFlow get() = mutableIsSearchOpen + + val callBanner: StateFlow = callManager.currentConnectionStateFlow.map { + // a call is in progress if it isn't idle nor disconnected + if (it !is State.Idle && it !is State.Disconnected) { + // call is started, we need to differentiate between in progress vs incoming + if (it is State.Connected) context.getString(R.string.callsInProgress) + else context.getString(R.string.callsIncomingUnknown) + } else null // null when the call isn't in progress / incoming + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), initialValue = null) + + private val _dialogsState = MutableStateFlow(DialogsState()) + val dialogsState: StateFlow = _dialogsState + /** * A [StateFlow] that emits the list of threads and the typing status of each thread. * @@ -67,8 +110,12 @@ class HomeViewModel @Inject constructor( messageRequests?.let { add(it) } threads.mapNotNullTo(this) { thread -> - // if the note to self is marked as hidden, do not add it - if (thread.recipient.isLocalNumber && hideNoteToSelf) { + // if the note to self is marked as hidden, + // or if the contact is blocked, do not add it + if ( + thread.recipient.isLocalNumber && hideNoteToSelf || + thread.recipient.isBlocked + ) { return@mapNotNullTo null } @@ -78,9 +125,14 @@ class HomeViewModel @Inject constructor( ) } } - ) - } - .stateIn(viewModelScope, SharingStarted.Eagerly, null) + ) as? Data? + }.catch { err -> + Log.e("HomeViewModel", "Error loading conversation list", err) + emit(null) + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private var userProfileModalJob: Job? = null + private var userProfileModalUtils: UserProfileUtils? = null private fun hasHiddenMessageRequests() = TextSecurePreferences.events .filter { it == TextSecurePreferences.HAS_HIDDEN_MESSAGE_REQUESTS } @@ -93,10 +145,10 @@ class HomeViewModel @Inject constructor( .onStart { emit(prefs.hasHiddenNoteToSelf()) } private fun observeTypingStatus(): Flow> = typingStatusRepository - .typingThreads - .asFlow() - .onStart { emit(emptySet()) } - .distinctUntilChanged() + .typingThreads + .asFlow() + .onStart { emit(emptySet()) } + .distinctUntilChanged() private fun messageRequests() = combine( unapprovedConversationCount(), @@ -105,16 +157,19 @@ class HomeViewModel @Inject constructor( ).flowOn(Dispatchers.Default) private fun unapprovedConversationCount() = reloadTriggersAndContentChanges() - .map { threadDb.unapprovedConversationList.use { cursor -> cursor.count } } + .map { + threadDb.getUnapprovedUnreadConversationCount().toInt() + } @Suppress("OPT_IN_USAGE") - private fun observeConversationList(): Flow> = reloadTriggersAndContentChanges() - .mapLatest { _ -> - threadDb.approvedConversationList.use { openCursor -> - threadDb.readerFor(openCursor).run { generateSequence { next }.toList() } + private fun observeConversationList(): Flow> = + reloadTriggersAndContentChanges() + .mapLatest { _ -> + threadDb.approvedConversationList.use { openCursor -> + threadDb.readerFor(openCursor).run { generateSequence { next }.toList() } + } } - } - .flowOn(Dispatchers.IO) + .flowOn(Dispatchers.IO) @OptIn(FlowPreview::class) private fun reloadTriggersAndContentChanges(): Flow<*> = merge( @@ -127,15 +182,27 @@ class HomeViewModel @Inject constructor( fun tryReload() = manualReloadTrigger.tryEmit(Unit) + fun onSearchClicked() { + mutableIsSearchOpen.value = true + } + + fun onCancelSearchClicked() { + mutableIsSearchOpen.value = false + } + + fun onBackPressed(): Boolean { + if (mutableIsSearchOpen.value) { + mutableIsSearchOpen.value = false + return true + } + + return false + } + data class Data( val items: List, ) - data class MessageSnippetOverride( - val text: CharSequence, - @AttrRes val colorAttr: Int, - ) - sealed interface Item { data class Thread( val thread: ThreadRecord, @@ -147,7 +214,7 @@ class HomeViewModel @Inject constructor( private fun createMessageRequests( count: Int, - hidden: Boolean, + hidden: Boolean ) = if (count > 0 && !hidden) Item.MessageRequests(count) else null @@ -158,6 +225,119 @@ class HomeViewModel @Inject constructor( } } + fun getCurrentUsername() = usernameUtils.getCurrentUsernameWithAccountIdFallback() + + fun blockContact(accountId: String) { + viewModelScope.launch(Dispatchers.Default) { + val recipient = Recipient.from(context, Address.fromSerialized(accountId), false) + storage.setBlocked(listOf(recipient), isBlocked = true) + } + } + + fun deleteContact(accountId: String) { + viewModelScope.launch(Dispatchers.Default) { + storage.deleteContactAndSyncConfig(accountId) + } + } + + fun leaveGroup(accountId: AccountId) { + viewModelScope.launch(Dispatchers.Default) { + groupManager.leaveGroup(accountId) + } + } + + fun setPinned(threadId: Long, pinned: Boolean) { + // check the pin limit before continuing + val totalPins = storage.getTotalPinned() + val maxPins = proStatusManager.getPinnedConversationLimit() + if (pinned && totalPins >= maxPins) { + // the user has reached the pin limit, show the CTA + _dialogsState.update { + it.copy( + pinCTA = PinProCTA(overTheLimit = totalPins > maxPins) + ) + } + } else { + viewModelScope.launch(Dispatchers.Default) { + storage.setPinned(threadId, pinned) + } + } + } + + fun onCommand(command: Commands) { + when (command) { + is Commands.HidePinCTADialog -> { + _dialogsState.update { it.copy(pinCTA = null) } + } + + is Commands.HideUserProfileModal -> { + _dialogsState.update { it.copy(userProfileModal = null) } + } + + is Commands.HandleUserProfileCommand -> { + userProfileModalUtils?.onCommand(command.upmCommand) + } + + is Commands.ShowStartConversationSheet -> { + _dialogsState.update { it.copy(showStartConversationSheet = + StartConversationSheetData( + accountId = prefs.getLocalNumber()!! + ) + ) } + } + + is Commands.HideStartConversationSheet -> { + _dialogsState.update { it.copy(showStartConversationSheet = null) } + } + } + } + + fun showUserProfileModal(thread: ThreadRecord) { + // get the helper class for the selected user + userProfileModalUtils = upmFactory.create( + recipient = thread.recipient, + threadId = thread.threadId, + scope = viewModelScope + ) + + // cancel previous job if any then listen in on the changes + userProfileModalJob?.cancel() + userProfileModalJob = viewModelScope.launch { + userProfileModalUtils?.userProfileModalData?.collect { upmData -> + _dialogsState.update { it.copy(userProfileModal = upmData) } + } + } + } + + fun shouldShowCurrentUserProBadge() : Boolean { + return proStatusManager.shouldShowProBadge(Address.fromSerialized(prefs.getLocalNumber()!!)) + } + + data class DialogsState( + val pinCTA: PinProCTA? = null, + val userProfileModal: UserProfileModalData? = null, + val showStartConversationSheet: StartConversationSheetData? = null + ) + + data class PinProCTA( + val overTheLimit: Boolean + ) + + data class StartConversationSheetData( + val accountId: String + ) + + sealed interface Commands { + data object HidePinCTADialog : Commands + data object HideUserProfileModal : Commands + data class HandleUserProfileCommand( + val upmCommand: UserProfileModalCommands + ) : Commands + + data object ShowStartConversationSheet : Commands + data object HideStartConversationSheet : Commands + } + 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 fd9e362e9f..2f1bd26dec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt @@ -10,16 +10,19 @@ import android.util.AttributeSet import android.util.TypedValue import android.view.Gravity import android.view.View +import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.RelativeLayout import android.widget.TextView import android.widget.Toast import androidx.annotation.ColorRes import androidx.core.content.ContextCompat +import androidx.core.view.doOnLayout import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.localbroadcastmanager.content.LocalBroadcastManager +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job @@ -37,7 +40,8 @@ import org.session.libsession.utilities.NonTranslatableStringConstants.APP_NAME import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.getColorFromAttr import org.session.libsignal.utilities.Snode -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.ScreenLockActionBarActivity +import org.thoughtcrime.securesms.reviews.InAppReviewManager import org.thoughtcrime.securesms.ui.getSubbedString import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.IP2Country @@ -48,11 +52,17 @@ import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.fadeIn import org.thoughtcrime.securesms.util.fadeOut import org.thoughtcrime.securesms.util.getAccentColor +import javax.inject.Inject -class PathActivity : PassphraseRequiredActionBarActivity() { + +@AndroidEntryPoint +class PathActivity : ScreenLockActionBarActivity() { private lateinit var binding: ActivityPathBinding private val broadcastReceivers = mutableListOf() + @Inject + lateinit var inAppReviewManager: InAppReviewManager + // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) @@ -82,6 +92,24 @@ class PathActivity : PassphraseRequiredActionBarActivity() { } } } + + binding.pathScroll.doOnLayout { + val child: View = binding.pathScroll.getChildAt(0) + val isScrollable: Boolean = child.height > binding.pathScroll.height + val params = binding.pathRowsContainer.layoutParams as FrameLayout.LayoutParams + + if(isScrollable){ + params.gravity = Gravity.CENTER_HORIZONTAL + } else { + params.gravity = Gravity.CENTER + } + + binding.pathRowsContainer.layoutParams = params + } + + lifecycleScope.launch { + inAppReviewManager.onEvent(InAppReviewManager.Event.PathScreenVisited) + } } private fun registerObservers() { 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 a422ddf9e5..5a03844f38 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.snode.OnionRequestAPI import org.thoughtcrime.securesms.util.toPx @@ -55,19 +56,22 @@ class PathStatusView : View { override fun onAttachedToWindow() { super.onAttachedToWindow() - updateJob = GlobalScope.launch(Dispatchers.Main) { + updateJob = GlobalScope.launch { OnionRequestAPI.hasPath .collectLatest { pathsBuilt -> - if (pathsBuilt) { - 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 = ContextCompat.getColor(context, R.color.paths_building) - mainColor = pathsBuildingColor - sessionShadowColor = pathsBuildingColor + withContext(Dispatchers.Main) { + if (pathsBuilt) { + 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 = + ContextCompat.getColor(context, R.color.paths_building) + mainColor = pathsBuildingColor + sessionShadowColor = pathsBuildingColor + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt b/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt index a413d09d86..5be7239cae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/SeedReminder.kt @@ -17,11 +17,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import network.loki.messenger.R import org.thoughtcrime.securesms.ui.SessionShieldIcon -import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton -import org.thoughtcrime.securesms.ui.components.SlimPrimaryOutlineButton -import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.components.AccentOutlineButton +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -37,7 +37,7 @@ internal fun SeedReminder(startRecoveryPasswordActivity: () -> Unit) { Modifier .fillMaxWidth() .height(LocalDimensions.current.indicatorHeight) - .background(LocalColors.current.primary) + .background(LocalColors.current.accent) ) Row( Modifier @@ -53,8 +53,10 @@ internal fun SeedReminder(startRecoveryPasswordActivity: () -> Unit) { stringResource(R.string.recoveryPasswordBannerTitle), style = LocalType.current.h8 ) - Spacer(Modifier.requiredWidth(LocalDimensions.current.xxsSpacing)) - SessionShieldIcon() + Spacer(Modifier.requiredWidth(LocalDimensions.current.xsSpacing)) + SessionShieldIcon( + modifier = Modifier.align(Alignment.CenterVertically) + ) } Text( stringResource(R.string.recoveryPasswordBannerDescription), @@ -62,11 +64,12 @@ internal fun SeedReminder(startRecoveryPasswordActivity: () -> Unit) { ) } Spacer(Modifier.width(LocalDimensions.current.smallSpacing)) - PrimaryOutlineButton( + AccentOutlineButton( text = stringResource(R.string.theContinue), modifier = Modifier .align(Alignment.CenterVertically) - .contentDescription(R.string.AccessibilityId_recoveryPasswordBanner), + .qaTag(R.string.AccessibilityId_recoveryPasswordBanner), + minWidth = 0.dp, onClick = startRecoveryPasswordActivity ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt deleted file mode 100644 index c4092de2de..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt +++ /dev/null @@ -1,167 +0,0 @@ -package org.thoughtcrime.securesms.home - -import android.annotation.SuppressLint -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.ContextThemeWrapper -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager -import android.widget.Toast -import androidx.core.view.isVisible -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.R -import network.loki.messenger.databinding.FragmentUserDetailsBottomSheetBinding -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.contacts.Contact -import org.session.libsession.utilities.Address -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.DatabaseContentProviders -import org.thoughtcrime.securesms.database.ThreadDatabase -import javax.inject.Inject - -@AndroidEntryPoint -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" - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val wrappedContext = ContextThemeWrapper(requireActivity(), requireActivity().theme) - val themedInflater = inflater.cloneInContext(wrappedContext) - binding = FragmentUserDetailsBottomSheetBinding.inflate(themedInflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val publicKey = arguments?.getString(ARGUMENT_PUBLIC_KEY) ?: return dismiss() - val threadID = arguments?.getLong(ARGUMENT_THREAD_ID) ?: return dismiss() - val recipient = Recipient.from(requireContext(), Address.fromSerialized(publicKey), false) - val threadRecipient = threadDb.getRecipientForThreadId(threadID) ?: return dismiss() - with(binding) { - profilePictureView.publicKey = publicKey - profilePictureView.update(recipient) - nameTextViewContainer.visibility = View.VISIBLE - nameTextViewContainer.setOnClickListener { - if (recipient.isCommunityInboxRecipient || recipient.isCommunityOutboxRecipient) return@setOnClickListener - nameTextViewContainer.visibility = View.INVISIBLE - nameEditTextContainer.visibility = View.VISIBLE - nicknameEditText.text = null - nicknameEditText.requestFocus() - showSoftKeyboard() - } - cancelNicknameEditingButton.setOnClickListener { - nicknameEditText.clearFocus() - hideSoftKeyboard() - nameTextViewContainer.visibility = View.VISIBLE - nameEditTextContainer.visibility = View.INVISIBLE - } - saveNicknameButton.setOnClickListener { - saveNickName(recipient) - } - nicknameEditText.setOnEditorActionListener { _, actionId, _ -> - when (actionId) { - EditorInfo.IME_ACTION_DONE -> { - saveNickName(recipient) - return@setOnEditorActionListener true - } - else -> return@setOnEditorActionListener false - } - } - nameTextView.text = recipient.name - - nameEditIcon.isVisible = recipient.isContactRecipient - && !threadRecipient.isCommunityInboxRecipient - && !threadRecipient.isCommunityOutboxRecipient - - publicKeyTextView.isVisible = !threadRecipient.isCommunityRecipient - && !threadRecipient.isCommunityInboxRecipient - && !threadRecipient.isCommunityOutboxRecipient - messageButton.isVisible = !threadRecipient.isCommunityRecipient || IdPrefix.fromValue(publicKey)?.isBlinded() == true - publicKeyTextView.text = publicKey - publicKeyTextView.setOnLongClickListener { - val clipboard = - requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Account ID", publicKey) - clipboard.setPrimaryClip(clip) - Toast.makeText(requireContext(), R.string.copied, Toast.LENGTH_SHORT) - .show() - true - } - messageButton.setOnClickListener { - val threadId = MessagingModuleConfiguration.shared.storage.getThreadId(recipient) - val intent = Intent( - context, - ConversationActivityV2::class.java - ) - intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) - intent.putExtra(ConversationActivityV2.THREAD_ID, threadId ?: -1) - intent.putExtra(ConversationActivityV2.FROM_GROUP_THREAD_ID, threadID) - startActivity(intent) - dismiss() - } - } - } - - override fun onStart() { - super.onStart() - val window = dialog?.window ?: return - window.setDimAmount(0.6f) - } - - fun saveNickName(recipient: Recipient) = with(binding) { - nicknameEditText.clearFocus() - hideSoftKeyboard() - nameTextViewContainer.visibility = View.VISIBLE - nameEditTextContainer.visibility = View.INVISIBLE - var newNickName: String? = null - if (nicknameEditText.text.isNotEmpty() && nicknameEditText.text.trim().length != 0) { - newNickName = nicknameEditText.text.toString() - } - else { newNickName = previousContactNickname } - val publicKey = recipient.address.serialize() - val storage = MessagingModuleConfiguration.shared.storage - val contact = storage.getContactWithAccountID(publicKey) ?: Contact(publicKey) - contact.nickname = newNickName - storage.setContact(contact) - nameTextView.text = recipient.name - - (parentFragment as? UserDetailsBottomSheetCallback) - ?: (requireActivity() as? UserDetailsBottomSheetCallback)?.onNicknameSaved() - } - - @SuppressLint("ServiceCast") - 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() { - val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - imm?.hideSoftInputFromWindow(binding.nicknameEditText.windowToken, 0) - } - - interface UserDetailsBottomSheetCallback { - fun onNicknameSaved() - } -} \ No newline at end of file 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 bc9d597641..afe13322d1 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 @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.home.search +import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -11,12 +12,19 @@ import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding import network.loki.messenger.databinding.ViewGlobalSearchResultBinding import network.loki.messenger.databinding.ViewGlobalSearchSubheaderBinding import org.session.libsession.utilities.GroupRecord +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.search.model.MessageResult import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.util.DateUtils import java.security.InvalidParameterException -import org.session.libsession.messaging.contacts.Contact as ContactModel -class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerView.Adapter() { + +class GlobalSearchAdapter( + private val dateUtils: DateUtils, + private val onContactClicked: (Model) -> Unit, + private val onContactLongPressed: (Model.Contact) -> Unit, +): RecyclerView.Adapter() { companion object { const val HEADER_VIEW_TYPE = 0 @@ -30,17 +38,25 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie fun setNewData(data: Pair>) = setNewData(data.first, data.second) fun setNewData(query: String, newData: List) { - val diffResult = DiffUtil.calculateDiff(GlobalSearchDiff(this.query, query, data, newData)) this.query = query - data = newData - diffResult.dispatchUpdatesTo(this) + + if (this.data.size > 500 || newData.size > 500) { + // For big data sets, we won't use DiffUtil to calculate the difference as it could be slow + this.data = newData + notifyDataSetChanged() + } else { + val diffResult = + DiffUtil.calculateDiff(GlobalSearchDiff(this.query, query, data, newData), false) + data = newData + diffResult.dispatchUpdatesTo(this) + } } override fun getItemViewType(position: Int): Int = - when(data[position]) { - is Model.Header -> HEADER_VIEW_TYPE - is Model.SubHeader -> SUB_HEADER_VIEW_TYPE - else -> CONTENT_VIEW_TYPE + when (data[position]) { + is Model.Header -> HEADER_VIEW_TYPE + is Model.SubHeader -> SUB_HEADER_VIEW_TYPE + else -> CONTENT_VIEW_TYPE } override fun getItemCount(): Int = data.size @@ -55,7 +71,9 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie ) else -> ContentView( LayoutInflater.from(parent.context).inflate(R.layout.view_global_search_result, parent, false), - modelCallback + dateUtils = dateUtils, + onContactClicked = onContactClicked, + onContactLongPressed = onContactLongPressed ) } @@ -70,9 +88,9 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie return } when (holder) { - is HeaderView -> holder.bind(data[position] as Model.Header) + is HeaderView -> holder.bind(data[position] as Model.Header) is SubHeaderView -> holder.bind(data[position] as Model.SubHeader) - is ContentView -> holder.bind(query.orEmpty(), data[position]) + is ContentView -> holder.bind(query.orEmpty(), data[position]) } } @@ -81,18 +99,14 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie } class HeaderView(view: View) : RecyclerView.ViewHolder(view) { - val binding = ViewGlobalSearchHeaderBinding.bind(view) - fun bind(header: Model.Header) { - binding.searchHeader.setText(header.title.string(binding.root.context)) + binding.searchHeader.text = header.title.string(binding.root.context) } } class SubHeaderView(view: View) : RecyclerView.ViewHolder(view) { - val binding = ViewGlobalSearchSubheaderBinding.bind(view) - fun bind(header: Model.SubHeader) { binding.searchHeader.text = header.title.string(binding.root.context) } @@ -104,7 +118,12 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie } } - class ContentView(view: View, private val modelCallback: (Model) -> Unit) : RecyclerView.ViewHolder(view) { + class ContentView( + view: View, + private val dateUtils: DateUtils, + private val onContactClicked: (Model) -> Unit, + private val onContactLongPressed: (Model.Contact) -> Unit, + ) : RecyclerView.ViewHolder(view) { val binding = ViewGlobalSearchResultBinding.bind(view) @@ -117,26 +136,59 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie when (model) { is Model.GroupConversation -> bindModel(query, model) is Model.Contact -> bindModel(query, model) - is Model.Message -> bindModel(query, model) + is Model.Message -> bindModel(query, model, dateUtils) is Model.SavedMessages -> bindModel(model) + else -> throw InvalidParameterException("Can't display as ContentView") } - binding.root.setOnClickListener { modelCallback(model) } + binding.root.setOnClickListener { onContactClicked(model) } + + // Display the block / delete popup on long-press of a contact which isn't us + if (model is Model.Contact && !model.isSelf) { + binding.root.setOnLongClickListener { + onContactLongPressed(model) + true + } + } } } - sealed class Model { - data class Header(val title: GetString): Model() { + sealed interface Model { + data class Header(val title: GetString): Model { constructor(@StringRes title: Int): this(GetString(title)) constructor(title: String): this(GetString(title)) } - data class SubHeader(val title: GetString): Model() { + data class SubHeader(val title: GetString): Model { constructor(@StringRes title: Int): this(GetString(title)) constructor(title: String): this(GetString(title)) } - data class SavedMessages(val currentUserPublicKey: String): Model() - data class Contact(val contact: ContactModel, val name: String?, val isSelf: Boolean) : Model() - data class GroupConversation(val groupRecord: GroupRecord) : Model() - data class Message(val messageResult: MessageResult, val unread: Int, val isSelf: Boolean) : Model() + + data class SavedMessages(val currentUserPublicKey: String): Model // Note: "Note to Self" counts as SavedMessages rather than a Contact where `isSelf` is true. + data class Contact(val contact: AccountId, val name: String, val isSelf: Boolean, val showProBadge: Boolean) : Model { + constructor(contact: org.session.libsession.messaging.contacts.Contact, isSelf: Boolean, showProBadge: Boolean): + this(AccountId(contact.accountID), contact.getSearchName(), isSelf, showProBadge) + } + data class GroupConversation( + val isLegacy: Boolean, + val groupId: String, + val title: String, + val legacyMembersString: String?, + val showProBadge: Boolean + ) : Model { + constructor(context: Context, groupRecord: GroupRecord, showProBadge: Boolean): + this( + isLegacy = groupRecord.isLegacyGroup, + groupId = groupRecord.encodedId, + title = groupRecord.title, + legacyMembersString = if (groupRecord.isLegacyGroup) { + val recipients = groupRecord.members.map { Recipient.from(context, it, false) } + recipients.joinToString(transform = Recipient::getSearchName) + } else { + null + }, + showProBadge = showProBadge + ) + } + data class Message(val messageResult: MessageResult, val unread: Int, val isSelf: Boolean, val showProBadge: Boolean) : 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 4e74a5d220..25473c9bb7 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 @@ -4,23 +4,30 @@ import android.graphics.Typeface import android.text.Spannable import android.text.SpannableStringBuilder import android.text.style.StyleSpan +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil -import java.util.Locale import network.loki.messenger.R import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.truncateIdForDisplay import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView -import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Contact as ContactModel 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.home.search.GlobalSearchAdapter.Model.SubHeader +import org.thoughtcrime.securesms.ui.ProBadgeText +import org.thoughtcrime.securesms.ui.setThemedContent +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.bold import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.SearchUtil +import java.util.Locale +import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Contact as ContactModel class GlobalSearchDiff( private val oldQuery: String?, @@ -28,6 +35,7 @@ class GlobalSearchDiff( private val oldData: List, private val newData: List ) : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldData.size override fun getNewListSize(): Int = newData.size override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = @@ -46,9 +54,9 @@ private val BoldStyleFactory = { StyleSpan(Typeface.BOLD) } fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) { when (model) { is ContactModel -> { - binding.searchResultTitle.text = getHighlight( - query, - model.contact.getSearchName() + binding.resultTitle.setupTitleWithBadge( + title = model.name, + showProBadge = model.showProBadge ) } is Message -> { @@ -64,18 +72,18 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) { )) binding.searchResultSubtitle.text = textSpannable binding.searchResultSubtitle.isVisible = true - binding.searchResultTitle.text = model.messageResult.conversationRecipient.getSearchName() + binding.resultTitle.setupTitleWithBadge( + title = model.messageResult.conversationRecipient.getSearchName(), + showProBadge = model.showProBadge + ) } is GroupConversation -> { - binding.searchResultTitle.text = getHighlight( - query, - model.groupRecord.title + binding.resultTitle.setupTitleWithBadge( + title = model.title, + showProBadge = model.showProBadge ) - val membersString = model.groupRecord.members.joinToString { address -> - Recipient.from(binding.root.context, address, false).getSearchName() - } - binding.searchResultSubtitle.text = getHighlight(query, membersString) + binding.searchResultSubtitle.text = getHighlight(query, model.legacyMembersString.orEmpty()) } is Header, // do nothing for header is SubHeader, // do nothing for subheader @@ -87,20 +95,30 @@ private fun getHighlight(query: String?, toSearch: String): Spannable? { return SearchUtil.getHighlightedSpan(Locale.getDefault(), BoldStyleFactory, toSearch, query) } +private fun ComposeView.setupTitleWithBadge(title: String, showProBadge: Boolean){ + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setThemedContent { + ProBadgeText( + text = title, + textStyle = LocalType.current.h8.bold().copy(color = LocalColors.current.text), + showBadge = showProBadge, + ) + } +} + fun ContentView.bindModel(query: String?, model: GroupConversation) { binding.searchResultProfilePicture.isVisible = true - binding.searchResultSubtitle.isVisible = model.groupRecord.isLegacyGroup + binding.searchResultSubtitle.isVisible = model.isLegacy binding.searchResultTimestamp.isVisible = false - val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false) + val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupId), false) binding.searchResultProfilePicture.update(threadRecipient) - val nameString = model.groupRecord.title - binding.searchResultTitle.text = getHighlight(query, nameString) + binding.resultTitle.setupTitleWithBadge( + title = model.title, + showProBadge = model.showProBadge + ) - val groupRecipients = model.groupRecord.members.map { Recipient.from(binding.root.context, it, false) } - - val membersString = groupRecipients.joinToString(transform = Recipient::getSearchName) - if (model.groupRecord.isLegacyGroup) { - binding.searchResultSubtitle.text = getHighlight(query, membersString) + if (model.legacyMembersString != null) { + binding.searchResultSubtitle.text = getHighlight(query, model.legacyMembersString) } } @@ -109,30 +127,41 @@ fun ContentView.bindModel(query: String?, model: ContactModel) = binding.run { searchResultSubtitle.isVisible = false searchResultTimestamp.isVisible = false searchResultSubtitle.text = null - val recipient = Recipient.from(root.context, Address.fromSerialized(model.contact.accountID), false) + val recipient = Recipient.from(root.context, Address.fromSerialized(model.contact.hexString), false) searchResultProfilePicture.update(recipient) val nameString = if (model.isSelf) root.context.getString(R.string.noteToSelf) - else model.contact.getSearchName() - searchResultTitle.text = getHighlight(query, nameString) + else model.name + + binding.resultTitle.setupTitleWithBadge( + title = nameString, + showProBadge = model.showProBadge + ) } fun ContentView.bindModel(model: SavedMessages) { binding.searchResultSubtitle.isVisible = false binding.searchResultTimestamp.isVisible = false - binding.searchResultTitle.setText(R.string.noteToSelf) + binding.resultTitle.setupTitleWithBadge( + title = binding.root.context.getString(R.string.noteToSelf), + showProBadge = false + ) binding.searchResultProfilePicture.update(Address.fromSerialized(model.currentUserPublicKey)) binding.searchResultProfilePicture.isVisible = true } -fun ContentView.bindModel(query: String?, model: Message) = binding.apply { +fun ContentView.bindModel(query: String?, model: Message, dateUtils: DateUtils) = binding.apply { searchResultProfilePicture.isVisible = true searchResultTimestamp.isVisible = true - searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(root.context, Locale.getDefault(), model.messageResult.sentTimestampMs) + + searchResultTimestamp.text = dateUtils.getDisplayFormattedTimeSpanString( + model.messageResult.sentTimestampMs + ) + searchResultProfilePicture.update(model.messageResult.conversationRecipient) val textSpannable = SpannableStringBuilder() if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) { // group chat, bind - val text = "${model.messageResult.messageRecipient.toShortString()}: " + val text = "${model.messageResult.messageRecipient.name}: " textSpannable.append(text) } textSpannable.append(getHighlight( @@ -140,14 +169,19 @@ fun ContentView.bindModel(query: String?, model: Message) = binding.apply { model.messageResult.bodySnippet )) searchResultSubtitle.text = textSpannable - searchResultTitle.text = if (model.isSelf) root.context.getString(R.string.noteToSelf) + val title = if (model.isSelf) root.context.getString(R.string.noteToSelf) else model.messageResult.conversationRecipient.getSearchName() + + binding.resultTitle.setupTitleWithBadge( + title = title, + showProBadge = model.showProBadge + ) searchResultSubtitle.isVisible = true } fun Recipient.getSearchName(): String = name.takeIf { it.isNotEmpty() && !it.looksLikeAccountId } - ?: address.serialize().let(::truncateIdForDisplay) + ?: address.toString().let(::truncateIdForDisplay) fun Contact.getSearchName(): String = nickname?.takeIf { it.isNotEmpty() && !it.looksLikeAccountId } 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 442e6159fd..3730553bf9 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 @@ -1,10 +1,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 import android.view.LayoutInflater @@ -13,11 +11,10 @@ import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.LinearLayout import android.widget.TextView -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.channelFlow import network.loki.messenger.databinding.ViewGlobalSearchInputBinding import org.thoughtcrime.securesms.util.SimpleTextWatcher -import org.thoughtcrime.securesms.util.addTextChangedListener class GlobalSearchInputLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null @@ -29,17 +26,31 @@ class GlobalSearchInputLayout @JvmOverloads constructor( var listener: GlobalSearchInputLayoutListener? = null - private val _query = MutableStateFlow("") - val query: StateFlow = _query + fun query() = channelFlow { + val watcher = object : SimpleTextWatcher() { + override fun onTextChanged(text: String?) { + trySend(text.orEmpty()) + } + } + + send(binding.searchInput.text.toString()) + binding.searchInput.addTextChangedListener(watcher) + + awaitClose { + binding.searchInput.removeTextChangedListener(watcher) + } + } override fun onAttachedToWindow() { super.onAttachedToWindow() binding.searchInput.onFocusChangeListener = this - binding.searchInput.addTextChangedListener(::setQuery) binding.searchInput.setOnEditorActionListener(this) binding.searchInput.filters = arrayOf(LengthFilter(100)) // 100 char search limit - binding.searchCancel.setOnClickListener { clearSearch(true) } - binding.searchClear.setOnClickListener { clearSearch(false) } + binding.searchCancel.setOnClickListener { + clearSearch() + listener?.onCancelClicked() + } + binding.searchClear.setOnClickListener { clearSearch() } } override fun onFocusChange(v: View?, hasFocus: Boolean) { @@ -48,7 +59,6 @@ class GlobalSearchInputLayout @JvmOverloads constructor( if (hasFocus) showSoftInput(v, 0) else hideSoftInputFromWindow(windowToken, 0) } - listener?.onInputFocusChanged(hasFocus) } } @@ -60,20 +70,21 @@ class GlobalSearchInputLayout @JvmOverloads constructor( return false } - fun clearSearch(clearFocus: Boolean) { + fun clearSearch() { binding.searchInput.text = null - setQuery("") - if (clearFocus) { - binding.searchInput.clearFocus() - } } - private fun setQuery(query: String) { - _query.value = query + fun handleBackPressed(): Boolean { + if (binding.searchInput.length() > 0) { + clearSearch() + return true + } + + return false } interface GlobalSearchInputLayoutListener { - fun onInputFocusChanged(hasFocus: Boolean) + fun onCancelClicked() } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt index 29e11067a0..c2c5f01a20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt @@ -9,7 +9,8 @@ data class GlobalSearchResult( val query: String, val contacts: List = emptyList(), val threads: List = emptyList(), - val messages: List = emptyList() + val messages: List = emptyList(), + val showNoteToSelf: Boolean = false ) { val isEmpty: Boolean get() = contacts.isEmpty() && threads.isEmpty() && messages.isEmpty() 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 3e99134506..d986996501 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 @@ -1,28 +1,28 @@ package org.thoughtcrime.securesms.home.search +import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.buffer -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.launch -import kotlinx.coroutines.plus +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.withContext +import network.loki.messenger.R +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.DatabaseContentProviders +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.search.SearchRepository import org.thoughtcrime.securesms.search.model.SearchResult +import org.thoughtcrime.securesms.util.observeChanges import javax.inject.Inject import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @@ -30,45 +30,58 @@ import kotlin.coroutines.suspendCoroutine @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class GlobalSearchViewModel @Inject constructor( + private val application: Application, private val searchRepository: SearchRepository, + private val configFactory: ConfigFactory, ) : ViewModel() { - private val scope = viewModelScope + SupervisorJob() - private val refreshes = MutableSharedFlow() - private val _queryText = MutableStateFlow("") - val result = _queryText - .reEmit(refreshes) - .buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST) + // The query text here is not the source of truth due to the limitation of Android view system + // Currently it's only set by the user input: if you try to set it programmatically, it won't + // be reflected in the UI and could be overwritten by the user input. + private val _queryText = MutableStateFlow("") + + private fun observeChangesAffectingSearch(): Flow<*> = merge( + application.contentResolver.observeChanges(DatabaseContentProviders.ConversationList.CONTENT_URI), + configFactory.configUpdateNotifications + ) + + val noteToSelfString by lazy { application.getString(R.string.noteToSelf).lowercase() } + + val result = combine( + _queryText, + observeChangesAffectingSearch().onStart { emit(Unit) } + ) { query, _ -> query } + .debounce(300L) .mapLatest { query -> - if (query.trim().isEmpty()) { - withContext(Dispatchers.Default) { - // searching for 05 as contactDb#getAllContacts was not returning contacts - // without a nickname/name who haven't approved us. - GlobalSearchResult( - query.toString(), - searchRepository.queryContacts("05").first.toList() - ) - } - } else { - // 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) - try { - searchRepository.suspendQuery(query.toString()).toGlobalSearchResult() - } catch (e: Exception) { - GlobalSearchResult(query.toString()) + try { + if (query.isBlank()) { + withContext(Dispatchers.Default) { + // searching for 05 as contactDb#getAllContacts was not returning contacts + // without a nickname/name who haven't approved us. + GlobalSearchResult( + query, + searchRepository.queryContacts("05").toList() + ) + } + } else { + val results = searchRepository.suspendQuery(query).toGlobalSearchResult() + + // show "Note to Self" is the user searches for parts of"Note to Self" + if(noteToSelfString.contains(query.lowercase())){ + results.copy(showNoteToSelf = true) + } else { + results + } } + } catch (e: Exception) { + Log.e("GlobalSearchViewModel", "Error searching len = ${query.length}", e) + GlobalSearchResult(query) } } + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 0) fun setQuery(charSequence: CharSequence) { - _queryText.value = charSequence - } - - fun refresh() { - viewModelScope.launch { - refreshes.emit(Unit) - } + _queryText.value = charSequence.toString() } } @@ -77,9 +90,3 @@ private suspend fun SearchRepository.suspendQuery(query: String): SearchResult { query(query, cont::resume) } } - -/** - * Re-emit whenever refreshes emits. - * */ -@OptIn(ExperimentalCoroutinesApi::class) -private fun Flow.reEmit(refreshes: Flow) = flatMapLatest { query -> merge(flowOf(query), refreshes.map { query }) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/SearchContactActionBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/SearchContactActionBottomSheet.kt new file mode 100644 index 0000000000..b912f9a14b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/SearchContactActionBottomSheet.kt @@ -0,0 +1,127 @@ +package org.thoughtcrime.securesms.home.search + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.squareup.phrase.Phrase +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.ui.components.ActionSheetItem +import org.thoughtcrime.securesms.ui.createThemedComposeView + +@AndroidEntryPoint +class SearchContactActionBottomSheet : BottomSheetDialogFragment() { + + private var accountId: String? = null + private var contactName: String? = null + + interface Callbacks { + fun onBlockContact(accountId: String) + fun onDeleteContact(accountId: String) + } + + private var callbacks: Callbacks? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + accountId = arguments?.getString(ARG_ACCOUNT_ID) + contactName = arguments?.getString(ARG_CONTACT_NAME) + } + + override fun onAttach(context: Context) { + super.onAttach(context) + callbacks = context as? Callbacks + ?: parentFragment as? Callbacks + ?: throw IllegalStateException("Parent activity or fragment must implement Callbacks") + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = createThemedComposeView { + Column( + modifier = Modifier.fillMaxWidth() + ) { + ActionSheetItem( + text = stringResource(R.string.block), + leadingIcon = R.drawable.ic_user_round_x, + qaTag = stringResource(R.string.AccessibilityId_block), + onClick = { + showBlockConfirmation() + dismiss() + } + ) + + ActionSheetItem( + text = stringResource(R.string.contactDelete), + leadingIcon = R.drawable.ic_trash_2, + qaTag = stringResource(R.string.AccessibilityId_delete), + onClick = { + showDeleteConfirmation() + dismiss() + } + ) + } + } + + private fun showBlockConfirmation() { + val accountId = accountId ?: return + val contactName = contactName ?: return + + showSessionDialog { + title(R.string.block) + text( + Phrase.from(context, R.string.blockDescription) + .put(NAME_KEY, contactName) + .format()) + dangerButton(R.string.block, R.string.AccessibilityId_blockConfirm) { + callbacks?.onBlockContact(accountId) + callbacks = null + } + cancelButton() + } + } + + private fun showDeleteConfirmation() { + val accountId = accountId ?: return + val contactName = contactName ?: return + + showSessionDialog { + title(R.string.contactDelete) + text( + Phrase.from(context, R.string.deleteContactDescription) + .put(NAME_KEY, contactName) + .put(NAME_KEY, contactName) + .format()) + dangerButton(R.string.delete, R.string.AccessibilityId_delete) { + callbacks?.onDeleteContact(accountId) + callbacks = null + } + cancelButton() + } + } + + companion object { + private const val ARG_ACCOUNT_ID = "arg_account_id" + private const val ARG_CONTACT_NAME = "arg_contact_name" + + fun newInstance(accountId: String, contactName: String): SearchContactActionBottomSheet { + return SearchContactActionBottomSheet().apply { + arguments = Bundle().apply { + putString(ARG_ACCOUNT_ID, accountId) + putString(ARG_CONTACT_NAME, contactName) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt new file mode 100644 index 0000000000..a51ac3e674 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt @@ -0,0 +1,258 @@ +package org.thoughtcrime.securesms.home.startconversation + +import android.annotation.SuppressLint +import android.content.Intent +import androidx.activity.compose.LocalActivity +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.fragment.app.viewModels +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.home.startconversation.community.JoinCommunityScreen +import org.thoughtcrime.securesms.home.startconversation.community.JoinCommunityViewModel +import org.thoughtcrime.securesms.home.startconversation.group.CreateGroupScreen +import org.thoughtcrime.securesms.home.startconversation.home.StartConversationScreen +import org.thoughtcrime.securesms.home.startconversation.invitefriend.InviteFriend +import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessage +import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessageViewModel +import org.thoughtcrime.securesms.home.startconversation.newmessage.State +import org.thoughtcrime.securesms.openUrl +import org.thoughtcrime.securesms.ui.NavigationAction +import org.thoughtcrime.securesms.ui.ObserveAsEvents +import org.thoughtcrime.securesms.ui.UINavigator +import org.thoughtcrime.securesms.ui.components.BaseBottomSheet +import org.thoughtcrime.securesms.ui.horizontalSlideComposable +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.util.push +import kotlin.getValue + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StartConversationSheet( + modifier: Modifier = Modifier, + accountId: String, + navigator: UINavigator, + onDismissRequest: () -> Unit, +){ + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + val scope = rememberCoroutineScope() + + BaseBottomSheet( + modifier = modifier, + sheetState = sheetState, + dragHandle = null, + onDismissRequest = onDismissRequest + ){ + BoxWithConstraints(modifier = modifier) { + val topInset = WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() + val targetHeight = (this.maxHeight - topInset) * 0.94f // sheet should take up 94% of the height, without the staatus bar + Box( + modifier = Modifier.height(targetHeight), + contentAlignment = Alignment.TopCenter + ) { + StartConversationNavHost( + accountId = accountId, + navigator = navigator, + onClose = { + scope.launch { + sheetState.hide() + onDismissRequest() + } + } + ) + } + } + } +} + +// Destinations +sealed interface StartConversationDestination { + @Serializable + data object Home: StartConversationDestination + + @Serializable + data object NewMessage: StartConversationDestination + + @Serializable + data object CreateGroup: StartConversationDestination + + @Serializable + data object JoinCommunity: StartConversationDestination + + @Serializable + data object InviteFriend: StartConversationDestination +} + +@SuppressLint("RestrictedApi") +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3Api::class) +@Composable +fun StartConversationNavHost( + accountId: String, + navigator: UINavigator, + onClose: () -> Unit +){ + SharedTransitionLayout { + val navController = rememberNavController() + + ObserveAsEvents(flow = navigator.navigationActions) { action -> + when (action) { + is NavigationAction.Navigate -> navController.navigate( + action.destination + ) { + action.navOptions(this) + } + + NavigationAction.NavigateUp -> navController.navigateUp() + + is NavigationAction.NavigateToIntent -> { + navController.context.startActivity(action.intent) + } + + is NavigationAction.ReturnResult -> {} + } + } + + val scope = rememberCoroutineScope() + val activity = LocalActivity.current + val context = LocalContext.current + + NavHost(navController = navController, startDestination = StartConversationDestination.Home) { + // Home + horizontalSlideComposable { + StartConversationScreen ( + accountId = accountId, + onClose = onClose, + navigateTo = { + scope.launch { navigator.navigate(it) } + } + ) + } + + // New Message + horizontalSlideComposable { + val viewModel = hiltViewModel() + val uiState by viewModel.state.collectAsState(State()) + + LaunchedEffect(Unit) { + scope.launch { + viewModel.success.collect { + val recipient = Recipient.from(context, Address.fromSerialized(it.publicKey), false) + Intent(context, ConversationActivityV2::class.java).apply { + putExtra(ConversationActivityV2.ADDRESS, recipient.address) + setDataAndType(activity?.intent?.data, activity?.intent?.type) + putExtra(ConversationActivityV2.THREAD_ID, DatabaseComponent.get(context).threadDatabase().getThreadIdIfExistsFor(recipient)) + }.let(context::startActivity) + + onClose() + } + } + } + + NewMessage( + uiState, + viewModel.qrErrors, + viewModel, + onBack = { scope.launch { navigator.navigateUp() }}, + onClose = onClose, + onHelp = { activity?.openUrl("https://sessionapp.zendesk.com/hc/en-us/articles/4439132747033-How-do-Account-ID-usernames-work") } + ) + } + + // Create Group + horizontalSlideComposable { + CreateGroupScreen( + onNavigateToConversationScreen = { threadID -> + activity?.startActivity( + Intent(activity, ConversationActivityV2::class.java) + .putExtra(ConversationActivityV2.THREAD_ID, threadID) + ) + }, + onBack = { scope.launch { navigator.navigateUp() }}, + onClose = onClose, + fromLegacyGroupId = null, + ) + } + + // Join Community + horizontalSlideComposable { + val viewModel = hiltViewModel() + val state by viewModel.state.collectAsState() + + LaunchedEffect(Unit){ + scope.launch { + viewModel.uiEvents.collect { + when(it){ + is JoinCommunityViewModel.UiEvent.NavigateToConversation -> { + val intent = Intent(context, ConversationActivityV2::class.java) + intent.putExtra(ConversationActivityV2.THREAD_ID, it.threadId) + activity?.startActivity(intent) + onClose() + } + } + } + } + } + + JoinCommunityScreen( + state = state, + sendCommand = { viewModel.onCommand(it) }, + onBack = { scope.launch { navigator.navigateUp() }}, + onClose = onClose + ) + } + + // Invite Friend + horizontalSlideComposable { + InviteFriend( + accountId = accountId, + onBack = { scope.launch { navigator.navigateUp() }}, + onClose = onClose + ) + } + + } + } +} + +@Preview +@Composable +fun PreviewStartConversationSheet(){ + PreviewTheme { + StartConversationSheet( + accountId = "", + onDismissRequest = {}, + navigator = UINavigator() + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityScreen.kt new file mode 100644 index 0000000000..adbdfe7570 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityScreen.kt @@ -0,0 +1,275 @@ +package org.thoughtcrime.securesms.home.startconversation.community + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +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.widthIn +import androidx.compose.foundation.pager.HorizontalPager +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.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import kotlinx.coroutines.flow.emptyFlow +import network.loki.messenger.R +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.thoughtcrime.securesms.home.startconversation.community.JoinCommunityViewModel.Commands.OnQRScanned +import org.thoughtcrime.securesms.ui.LoadingArcOr +import org.thoughtcrime.securesms.ui.components.AccentOutlineButton +import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.QRScannerScreen +import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField +import org.thoughtcrime.securesms.ui.components.SessionTabRow +import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.bold +import org.thoughtcrime.securesms.util.State + +private val TITLES = listOf(R.string.communityUrl, R.string.qrScan) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun JoinCommunityScreen( + state: JoinCommunityViewModel.JoinCommunityState, + sendCommand: (JoinCommunityViewModel.Commands) -> Unit, + onClose: () -> Unit = {}, + onBack: () -> Unit = {}, +) { + val pagerState = rememberPagerState { TITLES.size } + + Column(modifier = Modifier.background( + LocalColors.current.backgroundSecondary, + shape = MaterialTheme.shapes.small + )) { + BackAppBar( + title = stringResource(R.string.communityJoin), + backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container + onBack = onBack, + actions = { AppBarCloseIcon(onClose = onClose) }, + windowInsets = WindowInsets(0, 0, 0, 0), // Insets handled by the dialog + ) + SessionTabRow(pagerState, TITLES) + HorizontalPager(pagerState) { + when (TITLES[it]) { + R.string.communityUrl -> CommunityScreen( + state = state, + sendCommand = sendCommand + ) + R.string.qrScan -> QRScannerScreen(errors = emptyFlow(), onScan = { sendCommand(OnQRScanned(it)) }) + } + } + } +} + + +@Composable +private fun CommunityScreen( + state: JoinCommunityViewModel.JoinCommunityState, + sendCommand: (JoinCommunityViewModel.Commands) -> Unit +) { + Surface(color = LocalColors.current.backgroundSecondary) { + Column( + modifier = Modifier + .fillMaxSize() + .nestedScroll(rememberNestedScrollInteropConnection()) + .verticalScroll(rememberScrollState()) + ) { + Column( + modifier = Modifier.padding(vertical = LocalDimensions.current.spacing), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SessionOutlinedTextField( + text = state.communityUrl, + modifier = Modifier + .padding(horizontal = LocalDimensions.current.spacing) + .qaTag(R.string.AccessibilityId_communityEnterUrl), + placeholder = stringResource(R.string.communityEnterUrl), + onChange = { + sendCommand(JoinCommunityViewModel.Commands.OnUrlChanged(it)) + }, + onContinue = { + sendCommand(JoinCommunityViewModel.Commands.JoinCommunity(state.communityUrl)) + }, + error = null, + isTextErrorColor = false + ) + + } + + Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) + + when(state.defaultCommunities){ + is State.Loading -> { + SmallCircularProgressIndicator( + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } + + is State.Success -> { + Text( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = LocalDimensions.current.spacing), + text = stringResource(R.string.communityJoinOfficial), + style = LocalType.current.h7.bold() + ) + + Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + + FlowRow( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = LocalDimensions.current.spacing), + horizontalArrangement = Arrangement.SpaceAround + ) { + state.defaultCommunities.value.forEach { + CommunityChip( + group = it, + onClick = { + sendCommand(JoinCommunityViewModel.Commands.JoinCommunity(it.joinURL)) + } + ) + } + } + } + + else -> {} + } + + Spacer(Modifier + .weight(1f) + .heightIn(min = LocalDimensions.current.smallSpacing)) + + AccentOutlineButton( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(horizontal = LocalDimensions.current.xlargeSpacing) + .padding(bottom = LocalDimensions.current.smallSpacing) + .fillMaxWidth() + .qaTag(R.string.AccessibilityId_communityJoin), + enabled = state.isJoinButtonEnabled, + onClick = { + sendCommand(JoinCommunityViewModel.Commands.JoinCommunity( + state.communityUrl + )) + } + ) { + LoadingArcOr(state.loading) { + Text(stringResource(R.string.join)) + } + } + } + } + +} + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +private fun CommunityChip( + modifier: Modifier = Modifier, + group: OpenGroupApi.DefaultGroup, + onClick: () -> Unit +){ + AssistChip( + modifier = modifier.widthIn(max = 200.dp), + onClick = onClick, + label = { + Text( + text = group.name, + style = LocalType.current.base, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + leadingIcon = { + if (group.image != null) { + GlideImage( + modifier = Modifier.size(AssistChipDefaults.IconSize) + .clip(CircleShape), + model = group.image.copyToBytes(), + contentDescription = null + ) + } + }, + shape = MaterialTheme.shapes.extraLarge, + colors = AssistChipDefaults.assistChipColors().copy( + containerColor = LocalColors.current.backgroundSecondary, + labelColor = LocalColors.current.text + ), + border = BorderStroke( + width = 1.dp, + color = LocalColors.current.borders + ) + ) +} + +@Preview +@Composable +private fun PreviewNewMessage( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + JoinCommunityScreen( + state = JoinCommunityViewModel.JoinCommunityState(defaultCommunities = State.Loading), + sendCommand = {} + ) + } +} + +@Preview +@Composable +private fun PreviewCommunityChip( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + Box( + modifier = Modifier + .background(LocalColors.current.background) + .padding(12.dp) + ) { + CommunityChip( + group = OpenGroupApi.DefaultGroup( + id = "id", + name = "Session community", + image = null + ), + onClick = {} + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt new file mode 100644 index 0000000000..3b59e3b177 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt @@ -0,0 +1,164 @@ +package org.thoughtcrime.securesms.home.startconversation.community + +import android.content.Context +import android.webkit.URLUtil +import android.widget.Toast +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import network.loki.messenger.R +import nl.komponents.kovenant.functional.map +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.utilities.OpenGroupUrlParser +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.groups.GroupManager +import org.thoughtcrime.securesms.groups.OpenGroupManager +import org.thoughtcrime.securesms.ui.getSubbedString +import org.thoughtcrime.securesms.util.State +import javax.inject.Inject + +@HiltViewModel +class JoinCommunityViewModel @Inject constructor( + @ApplicationContext private val appContext: Context, + private val openGroupManager: OpenGroupManager, + private val storage: StorageProtocol +): ViewModel() { + + private val _state = MutableStateFlow(JoinCommunityState(defaultCommunities = State.Loading)) + val state: StateFlow = _state + + private val _uiEvents = MutableSharedFlow(extraBufferCapacity = 1) + val uiEvents: SharedFlow get() = _uiEvents + + private var lasQrScan: Long = 0L + private val qrDebounceTime = 3000L + + init { + OpenGroupApi.getDefaultServerCapabilities().map { + OpenGroupApi.getDefaultRoomsIfNeeded() + } + + viewModelScope.launch(Dispatchers.Default) { + OpenGroupApi.defaultRooms.collect { defaultCommunities -> + _state.update { it.copy(defaultCommunities = State.Success(defaultCommunities)) } + } + } + } + + private fun joinCommunityIfPossible(url: String) { + viewModelScope.launch(Dispatchers.Default) { + _state.update { it.copy(loading = true) } + + val openGroup = try { + OpenGroupUrlParser.parseUrl(url) + } catch (e: OpenGroupUrlParser.Error) { + _state.update { it.copy(loading = false) } + when (e) { + is OpenGroupUrlParser.Error.MalformedURL, OpenGroupUrlParser.Error.NoRoom -> { + withContext(Dispatchers.Main) { + Toast.makeText( + appContext, + appContext.getString(R.string.communityJoinError), + Toast.LENGTH_SHORT + ).show() + } + return@launch + } + + is OpenGroupUrlParser.Error.InvalidPublicKey, OpenGroupUrlParser.Error.NoPublicKey -> { + withContext(Dispatchers.Main) { + Toast.makeText( + appContext, + appContext.getString(R.string.communityEnterUrlErrorInvalidDescription), + Toast.LENGTH_SHORT + ).show() + } + return@launch + } + } + } + + try { + val sanitizedServer = openGroup.server.removeSuffix("/") + val openGroupID = "$sanitizedServer.${openGroup.room}" + openGroupManager.add( + sanitizedServer, + openGroup.room, + openGroup.serverPublicKey, + appContext + ) + + storage.onOpenGroupAdded(sanitizedServer, openGroup.room) + val threadID = GroupManager.getOpenGroupThreadID(openGroupID, appContext) + + withContext(Dispatchers.Main) { + _uiEvents.emit(UiEvent.NavigateToConversation( + threadId = threadID + )) + } + } catch (e: Exception) { + Log.e("Loki", "Couldn't join community.", e) + withContext(Dispatchers.Main) { + _state.update { it.copy(loading = false) } + + val txt = appContext.getSubbedString(R.string.groupErrorJoin, + GROUP_NAME_KEY to url) + Toast.makeText(appContext, txt, Toast.LENGTH_SHORT).show() + } + } + } + } + + fun onCommand(command: Commands) { + when (command) { + is Commands.OnQRScanned -> { + val currentTime = System.currentTimeMillis() + if (currentTime - lasQrScan > qrDebounceTime) { + lasQrScan = currentTime + joinCommunityIfPossible(command.qr) + } + } + + is Commands.JoinCommunity -> { + joinCommunityIfPossible(command.url) + } + + is Commands.OnUrlChanged -> { + _state.update { + it.copy( + communityUrl = command.url, + isJoinButtonEnabled = URLUtil.isValidUrl(command.url.trim()) + ) + } + } + } + } + + data class JoinCommunityState( + val loading: Boolean = false, + val isJoinButtonEnabled: Boolean = false, + val communityUrl: String = "", + val defaultCommunities: State> + ) + + sealed interface Commands { + data class OnQRScanned(val qr: String) : Commands + data class JoinCommunity(val url: String): Commands + data class OnUrlChanged(val url: String): Commands + } + + sealed interface UiEvent { + data class NavigateToConversation(val threadId: Long) : UiEvent + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt new file mode 100644 index 0000000000..b38076e5ca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt @@ -0,0 +1,300 @@ +package org.thoughtcrime.securesms.home.startconversation.group + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import network.loki.messenger.R +import org.session.libsession.utilities.Address +import org.thoughtcrime.securesms.groups.ContactItem +import org.thoughtcrime.securesms.groups.compose.GroupMinimumVersionBanner +import org.thoughtcrime.securesms.groups.compose.multiSelectMemberList +import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox +import org.thoughtcrime.securesms.ui.LoadingArcOr +import org.thoughtcrime.securesms.ui.SearchBar +import org.thoughtcrime.securesms.ui.components.AccentOutlineButton +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.primaryBlue +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUIElement + + +@Composable +fun CreateGroupScreen( + fromLegacyGroupId: String?, + onNavigateToConversationScreen: (threadID: Long) -> Unit, + onBack: () -> Unit, + onClose: () -> Unit, +) { + val viewModel = hiltViewModel { factory -> + factory.create(fromLegacyGroupId) + } + + val context = LocalContext.current + + LaunchedEffect(viewModel) { + viewModel.events.collect { event -> + when (event) { + is CreateGroupEvent.NavigateToConversation -> { + onClose() + onNavigateToConversationScreen(event.threadID) + } + + is CreateGroupEvent.Error -> { + Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() + } + } + } + } + + CreateGroup( + groupName = viewModel.groupName.collectAsState().value, + onGroupNameChanged = viewModel::onGroupNameChanged, + groupNameError = viewModel.groupNameError.collectAsState().value, + contactSearchQuery = viewModel.searchQuery.collectAsState().value, + onContactSearchQueryChanged = viewModel::onSearchQueryChanged, + onContactSearchQueryClear = { viewModel.onSearchQueryChanged("") }, + onContactItemClicked = viewModel::onContactItemClicked, + showLoading = viewModel.isLoading.collectAsState().value, + items = viewModel.contacts.collectAsState().value, + onCreateClicked = viewModel::onCreateClicked, + onBack = onBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateGroup( + groupName: String, + onGroupNameChanged: (String) -> Unit, + groupNameError: String, + contactSearchQuery: String, + onContactSearchQueryChanged: (String) -> Unit, + onContactSearchQueryClear: () -> Unit, + onContactItemClicked: (address: Address) -> Unit, + showLoading: Boolean, + items: List, + onCreateClicked: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val focusManager = LocalFocusManager.current + + Scaffold( + containerColor = LocalColors.current.backgroundSecondary, + topBar = { + BackAppBar( + title = stringResource(id = R.string.groupCreate), + backgroundColor = LocalColors.current.backgroundSecondary, + onBack = onBack, + windowInsets = WindowInsets(0, 0, 0, 0), // Insets handled by the dialog + ) + }, + ) { paddings -> + Column( + modifier = modifier.padding(paddings).consumeWindowInsets(paddings), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + GroupMinimumVersionBanner() + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + SessionOutlinedTextField( + text = groupName, + onChange = onGroupNameChanged, + placeholder = stringResource(R.string.groupNameEnter), + textStyle = LocalType.current.base, + modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing) + .qaTag(R.string.AccessibilityId_groupNameEnter), + error = groupNameError.takeIf { it.isNotBlank() }, + enabled = !showLoading, + innerPadding = PaddingValues(LocalDimensions.current.smallSpacing), + onContinue = focusManager::clearFocus + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + SearchBar( + query = contactSearchQuery, + onValueChanged = onContactSearchQueryChanged, + onClear = onContactSearchQueryClear, + placeholder = stringResource(R.string.searchContacts), + modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing) + .qaTag(R.string.AccessibilityId_groupNameSearch), + enabled = !showLoading + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + BottomFadingEdgeBox( + modifier = Modifier.weight(1f) + .nestedScroll(rememberNestedScrollInteropConnection()), + fadingColor = LocalColors.current.backgroundSecondary + ) { bottomContentPadding -> + if(items.isEmpty() && contactSearchQuery.isEmpty()){ + Text( + modifier = Modifier.fillMaxWidth() + .padding(top = LocalDimensions.current.xsSpacing), + text = stringResource(R.string.contactNone), + textAlign = TextAlign.Center, + style = LocalType.current.base.copy(color = LocalColors.current.textSecondary) + ) + } else { + LazyColumn( + contentPadding = PaddingValues(bottom = bottomContentPadding) + ) { + multiSelectMemberList( + contacts = items, + onContactItemClicked = onContactItemClicked, + enabled = !showLoading + ) + } + } + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + AccentOutlineButton( + onClick = onCreateClicked, + modifier = Modifier + .padding(horizontal = LocalDimensions.current.spacing) + .qaTag(R.string.AccessibilityId_groupCreate) + ) { + LoadingArcOr(loading = showLoading) { + Text(stringResource(R.string.create)) + } + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + } + + } + +} + +@Preview +@Composable +private fun CreateGroupPreview( +) { + val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + val previewMembers = listOf( + ContactItem(address = Address.fromSerialized(random), name = "Alice", selected = false, + showProBadge = true, + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), + ), + ContactItem(address = Address.fromSerialized(random), name = "Bob", selected = true, + showProBadge = false, + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), + ), + ) + + PreviewTheme { + CreateGroup( + groupName = "", + onGroupNameChanged = {}, + groupNameError = "", + contactSearchQuery = "", + onContactSearchQueryChanged = {}, + onContactSearchQueryClear = {}, + onContactItemClicked = {}, + showLoading = false, + items = previewMembers, + onCreateClicked = {}, + onBack = {}, + modifier = Modifier.background(LocalColors.current.backgroundSecondary), + ) + } + +} + +@Preview +@Composable +private fun CreateEmptyGroupPreview( +) { + val previewMembers = emptyList() + + PreviewTheme { + CreateGroup( + groupName = "", + onGroupNameChanged = {}, + groupNameError = "", + contactSearchQuery = "", + onContactSearchQueryChanged = {}, + onContactSearchQueryClear = {}, + onContactItemClicked = {}, + showLoading = false, + items = previewMembers, + onCreateClicked = {}, + onBack = {}, + modifier = Modifier.background(LocalColors.current.backgroundSecondary), + ) + } +} + +@Preview +@Composable +private fun CreateEmptyGroupPreviewWithSearch( +) { + val previewMembers = emptyList() + + PreviewTheme { + CreateGroup( + groupName = "", + onGroupNameChanged = {}, + groupNameError = "", + contactSearchQuery = "Test", + onContactSearchQueryChanged = {}, + onContactSearchQueryClear = {}, + onContactItemClicked = {}, + showLoading = false, + items = previewMembers, + onCreateClicked = {}, + onBack = {}, + modifier = Modifier.background(LocalColors.current.backgroundSecondary), + ) + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt similarity index 81% rename from app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt rename to app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt index 3607119002..5e0d16ac2f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt @@ -1,7 +1,6 @@ -package org.thoughtcrime.securesms.groups +package org.thoughtcrime.securesms.home.startconversation.group import android.content.Context -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -18,10 +17,14 @@ import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.utilities.Address import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.textSizeInBytes import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.groups.SelectContactsViewModel +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.util.AvatarUtils @HiltViewModel(assistedFactory = CreateGroupViewModel.Factory::class) @@ -30,16 +33,19 @@ class CreateGroupViewModel @AssistedInject constructor( @ApplicationContext private val appContext: Context, private val storage: StorageProtocol, private val groupManagerV2: GroupManagerV2, + avatarUtils: AvatarUtils, + proStatusManager: ProStatusManager, groupDatabase: GroupDatabase, @Assisted createFromLegacyGroupId: String?, -): ViewModel() { +): SelectContactsViewModel( + configFactory = configFactory, + excludingAccountIDs = emptySet(), + contactFiltering = SelectContactsViewModel.Factory.defaultFiltering, + appContext = appContext, + avatarUtils = avatarUtils, + proStatusManager = proStatusManager +) { // Child view model to handle contact selection logic - val selectContactsViewModel = SelectContactsViewModel( - configFactory = configFactory, - excludingAccountIDs = emptySet(), - scope = viewModelScope, - appContext = appContext, - ) // Input: group name private val mutableGroupName = MutableStateFlow("") @@ -69,11 +75,11 @@ class CreateGroupViewModel @AssistedInject constructor( val accountIDs = group.members .asSequence() - .filter { it.serialize() != myPublicKey } - .mapTo(mutableSetOf()) { AccountId(it.serialize()) } + .filter { it.toString() != myPublicKey } + .mapTo(mutableSetOf()) { Address.fromSerialized(it.toString()) } - selectContactsViewModel.selectAccountIDs(accountIDs) - selectContactsViewModel.setManuallyAddedContacts(accountIDs) + selectAccountIDs(accountIDs) + setManuallyAddedContacts(accountIDs) } } finally { mutableIsLoading.value = false @@ -91,13 +97,13 @@ class CreateGroupViewModel @AssistedInject constructor( } // validate name length (needs to be less than 100 bytes) - if(groupName.textSizeInBytes() > MAX_GROUP_NAME_BYTES){ + if(groupName.textSizeInBytes() > ConfigFactory.MAX_NAME_BYTES){ mutableGroupNameError.value = appContext.getString(R.string.groupNameEnterShorter) return@launch } - val selected = selectContactsViewModel.currentSelected + val selected = currentSelected if (selected.isEmpty()) { mutableEvents.emit(CreateGroupEvent.Error(appContext.getString(R.string.groupCreateErrorNoMembers))) return@launch @@ -110,7 +116,7 @@ class CreateGroupViewModel @AssistedInject constructor( groupManagerV2.createGroup( groupName = groupName, groupDescription = "", - members = selected + members = selected.map { AccountId(it.toString()) }.toSet() ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/home/StartConversation.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/home/StartConversation.kt new file mode 100644 index 0000000000..e06d21bf23 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/home/StartConversation.kt @@ -0,0 +1,140 @@ +package org.thoughtcrime.securesms.home.startconversation.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.thoughtcrime.securesms.home.startconversation.StartConversationDestination +import org.thoughtcrime.securesms.ui.Divider +import org.thoughtcrime.securesms.ui.ItemButton +import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon +import org.thoughtcrime.securesms.ui.components.BasicAppBar +import org.thoughtcrime.securesms.ui.components.QrImage +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun StartConversationScreen( + accountId: String, + navigateTo: (StartConversationDestination) -> Unit, + onClose: () -> Unit, +) { + val context = LocalContext.current + + Column(modifier = Modifier.background( + LocalColors.current.backgroundSecondary, + shape = MaterialTheme.shapes.small.copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp)) + )) { + BasicAppBar( + title = stringResource(R.string.conversationsStart), + backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container + actions = { AppBarCloseIcon(onClose = onClose) }, + windowInsets = WindowInsets(0, 0, 0, 0), // Insets handled by the dialog + ) + Surface( + modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()), + color = LocalColors.current.backgroundSecondary + ) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + val newMessageTitleTxt:String = context.resources.getQuantityString(R.plurals.messageNew, 1, 1) + ItemButton( + text = newMessageTitleTxt, + icon = R.drawable.ic_message_square, + modifier = Modifier.qaTag(R.string.AccessibilityId_messageNew), + onClick = { + navigateTo(StartConversationDestination.NewMessage) + } + ) + Divider(startIndent = LocalDimensions.current.minItemButtonHeight) + ItemButton( + textId = R.string.groupCreate, + icon = R.drawable.ic_users_group_custom, + modifier = Modifier.qaTag(R.string.AccessibilityId_groupCreate), + onClick = { + navigateTo(StartConversationDestination.CreateGroup) + } + ) + Divider(startIndent = LocalDimensions.current.minItemButtonHeight) + ItemButton( + textId = R.string.communityJoin, + icon = R.drawable.ic_globe, + modifier = Modifier.qaTag(R.string.AccessibilityId_communityJoin), + onClick = { + navigateTo(StartConversationDestination.JoinCommunity) + } + ) + Divider(startIndent = LocalDimensions.current.minItemButtonHeight) + ItemButton( + textId = R.string.sessionInviteAFriend, + icon = R.drawable.ic_user_round_plus, + Modifier.qaTag(R.string.AccessibilityId_sessionInviteAFriendButton), + onClick = { + navigateTo(StartConversationDestination.InviteFriend) + } + ) + Column( + modifier = Modifier + .padding(horizontal = LocalDimensions.current.spacing) + .padding(top = LocalDimensions.current.spacing) + .padding(bottom = LocalDimensions.current.spacing) + ) { + Text(stringResource(R.string.accountIdYours), style = LocalType.current.xl) + Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) + Text( + text = stringResource(R.string.qrYoursDescription), + color = LocalColors.current.textSecondary, + style = LocalType.current.small + ) + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + QrImage( + string = accountId, + Modifier.qaTag(R.string.AccessibilityId_qrCode), + icon = R.drawable.session + ) + } + } + } + } +} + +@Preview +@Composable +private fun PreviewStartConversationScreen( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + StartConversationScreen( + accountId = "059287129387123", + onClose = {}, + navigateTo = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/invitefriend/InviteFriend.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/invitefriend/InviteFriend.kt new file mode 100644 index 0000000000..d02925055e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/invitefriend/InviteFriend.kt @@ -0,0 +1,119 @@ +package org.thoughtcrime.securesms.home.startconversation.invitefriend + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.thoughtcrime.securesms.preferences.copyPublicKey +import org.thoughtcrime.securesms.preferences.sendInvitationToUseSession +import org.thoughtcrime.securesms.ui.border +import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.OutlineButton +import org.thoughtcrime.securesms.ui.components.OutlineCopyButton +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun InviteFriend( + accountId: String, + onBack: () -> Unit, + onClose: () -> Unit, +) { + val context = LocalContext.current + + Column(modifier = Modifier.background( + LocalColors.current.backgroundSecondary, + shape = MaterialTheme.shapes.small + )) { + BackAppBar( + title = stringResource(R.string.sessionInviteAFriend), + backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container + onBack = onBack, + actions = { AppBarCloseIcon(onClose = onClose) }, + windowInsets = WindowInsets(0, 0, 0, 0), // Insets handled by the dialog + ) + Column( + modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing) + .padding(top = LocalDimensions.current.spacing), + ) { + Text( + accountId, + modifier = Modifier + .qaTag(R.string.AccessibilityId_shareAccountId) + .fillMaxWidth() + .border() + .padding(LocalDimensions.current.spacing), + textAlign = TextAlign.Center, + style = LocalType.current.base + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + Text( + stringResource(R.string.shareAccountIdDescription).let { txt -> + Phrase.from(txt).put(APP_NAME_KEY, context.getString(R.string.app_name)).format().toString() + }, + textAlign = TextAlign.Center, + style = LocalType.current.small, + color = LocalColors.current.textSecondary, + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing) + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + Row(horizontalArrangement = spacedBy(LocalDimensions.current.smallSpacing)) { + OutlineButton( + stringResource(R.string.share), + modifier = Modifier + .weight(1f) + .qaTag("Share button"), + onClick = { + context.sendInvitationToUseSession() + } + ) + + OutlineCopyButton( + modifier = Modifier.weight(1f), + onClick = { + context.copyPublicKey() + } + ) + } + } + } +} + +@Preview +@Composable +private fun PreviewInviteFriend() { + PreviewTheme { + InviteFriend( + accountId = "050000000", + onBack = {}, + onClose = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt new file mode 100644 index 0000000000..a70ebfd709 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.home.startconversation.newmessage + +internal interface Callbacks { + fun onChange(value: String) {} + fun onContinue() {} + fun onScanQrCode(value: String) {} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt new file mode 100644 index 0000000000..d4f7d604c3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt @@ -0,0 +1,161 @@ +package org.thoughtcrime.securesms.home.startconversation.newmessage + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +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.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.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.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.LoadingArcOr +import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.BorderlessButtonWithIcon +import org.thoughtcrime.securesms.ui.components.AccentOutlineButton +import org.thoughtcrime.securesms.ui.components.QRScannerScreen +import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField +import org.thoughtcrime.securesms.ui.components.SessionTabRow +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors + +private val TITLES = listOf(R.string.accountIdEnter, R.string.qrScan) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun NewMessage( + state: State, + qrErrors: Flow = emptyFlow(), + callbacks: Callbacks = object: Callbacks {}, + onClose: () -> Unit = {}, + onBack: () -> Unit = {}, + onHelp: () -> Unit = {}, +) { + val pagerState = rememberPagerState { TITLES.size } + + Column(modifier = Modifier.background( + LocalColors.current.backgroundSecondary, + shape = MaterialTheme.shapes.small + )) { + // `messageNew` is now a plurals string so get the singular version + val context = LocalContext.current + val newMessageTitleTxt:String = context.resources.getQuantityString(R.plurals.messageNew, 1, 1) + + BackAppBar( + title = newMessageTitleTxt, + backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container + onBack = onBack, + actions = { AppBarCloseIcon(onClose = onClose) }, + windowInsets = WindowInsets(0, 0, 0, 0), // Insets handled by the dialog + ) + SessionTabRow(pagerState, TITLES) + HorizontalPager(pagerState) { + when (TITLES[it]) { + R.string.accountIdEnter -> EnterAccountId(state, callbacks, onHelp) + R.string.qrScan -> QRScannerScreen(qrErrors, onScan = callbacks::onScanQrCode) + } + } + } +} + +@Composable +private fun EnterAccountId( + state: State, + callbacks: Callbacks, + onHelp: () -> Unit = {} +) { + Surface(color = LocalColors.current.backgroundSecondary) { + Column( + modifier = Modifier + .fillMaxSize() + .nestedScroll(rememberNestedScrollInteropConnection()) + .verticalScroll(rememberScrollState()) + ) { + Column( + modifier = Modifier.padding(vertical = LocalDimensions.current.spacing), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SessionOutlinedTextField( + text = state.newMessageIdOrOns, + modifier = Modifier + .padding(horizontal = LocalDimensions.current.spacing) + .qaTag(R.string.AccessibilityId_sessionIdInput), + placeholder = stringResource(R.string.accountIdOrOnsEnter), + onChange = callbacks::onChange, + onContinue = callbacks::onContinue, + error = state.error?.string(), + isTextErrorColor = state.isTextErrorColor + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + + BorderlessButtonWithIcon( + text = stringResource(R.string.messageNewDescriptionMobile), + modifier = Modifier + .qaTag(R.string.AccessibilityId_messageNewDescriptionMobile) + .padding(horizontal = LocalDimensions.current.mediumSpacing) + .fillMaxWidth(), + style = LocalType.current.small, + color = LocalColors.current.textSecondary, + iconRes = R.drawable.ic_circle_help, + onClick = onHelp + ) + } + + Spacer(Modifier.weight(1f).heightIn(min = LocalDimensions.current.smallSpacing)) + + AccentOutlineButton( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(horizontal = LocalDimensions.current.xlargeSpacing) + .padding(bottom = LocalDimensions.current.smallSpacing) + .fillMaxWidth() + .qaTag(R.string.next), + enabled = state.isNextButtonEnabled, + onClick = callbacks::onContinue + ) { + LoadingArcOr(state.loading) { + Text(stringResource(R.string.next)) + } + } + } + } + +} + +@Preview +@Composable +private fun PreviewNewMessage( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + NewMessage(State("z")) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt new file mode 100644 index 0000000000..33dabce78a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt @@ -0,0 +1,140 @@ +package org.thoughtcrime.securesms.home.startconversation.newmessage + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import network.loki.messenger.R +import org.session.libsession.snode.SnodeAPI +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.PublicKeyValidation +import org.thoughtcrime.securesms.ui.GetString +import java.net.IDN + +@HiltViewModel +internal class NewMessageViewModel @Inject constructor( + private val application: Application +): AndroidViewModel(application), Callbacks { + + private val _state = MutableStateFlow(State()) + val state = _state.asStateFlow() + + private val _success = MutableSharedFlow() + val success get() = _success.asSharedFlow() + + private val _qrErrors = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val qrErrors = _qrErrors.asSharedFlow() + + private var loadOnsJob: Job? = null + + private var lasQrScan: Long = 0L + private val qrDebounceTime = 3000L + + override fun onChange(value: String) { + loadOnsJob?.cancel() + loadOnsJob = null + _state.update { it.copy(newMessageIdOrOns = value, isTextErrorColor = false, loading = false) } + } + + override fun onContinue() { + val trimmed = state.value.newMessageIdOrOns.trim() + // Check if all characters are ASCII (code <= 127). + val idOrONS = if (trimmed.all { it.code <= 127 }) { + // Already ASCII (or punycode‐ready); no conversion needed. + trimmed + } else { + try { + // For non-ASCII input (e.g. with emojis), attempt to puny-encode + IDN.toASCII(trimmed, IDN.ALLOW_UNASSIGNED) + } catch (e: IllegalArgumentException) { + // if the above failed, resort to the original trimmed string + Log.w("", "IDN.toASCII failed. Returning: $trimmed") + trimmed + } + } + + if (PublicKeyValidation.isValid(idOrONS, isPrefixRequired = false)) { + onUnvalidatedPublicKey(publicKey = idOrONS) + } else { + resolveONS(ons = idOrONS) + } + } + + override fun onScanQrCode(value: String) { + val currentTime = System.currentTimeMillis() + if (currentTime - lasQrScan > qrDebounceTime) { + lasQrScan = currentTime + if (PublicKeyValidation.isValid( + value, + isPrefixRequired = false + ) && PublicKeyValidation.hasValidPrefix(value) + ) { + onPublicKey(value) + } else { + _qrErrors.tryEmit(application.getString(R.string.qrNotAccountId)) + } + } + } + + private fun resolveONS(ons: String) { + if (loadOnsJob?.isActive == true) return + + // This could be an ONS name + _state.update { it.copy(isTextErrorColor = false, error = null, loading = true) } + + loadOnsJob = viewModelScope.launch { + try { + val publicKey = withTimeout(30_000L, { + SnodeAPI.getAccountID(ons) + }) + onPublicKey(publicKey) + } catch (e: Exception) { + Log.w("", "Error resolving ONS:", e) + onError(e) + } + } + } + + private fun onError(e: Exception) { + _state.update { it.copy(loading = false, isTextErrorColor = true, error = GetString(e) { it.toMessage() }) } + } + + private fun onPublicKey(publicKey: String) { + _state.update { it.copy(loading = false) } + viewModelScope.launch { _success.emit(Success(publicKey)) } + } + + private fun onUnvalidatedPublicKey(publicKey: String) { + if (PublicKeyValidation.hasValidPrefix(publicKey)) { + onPublicKey(publicKey) + } else { + _state.update { it.copy(isTextErrorColor = true, error = GetString(R.string.accountIdErrorInvalid), loading = false) } + } + } + + private fun Exception.toMessage() = when (this) { + is SnodeAPI.Error.Generic -> application.getString(R.string.onsErrorNotRecognized) + else -> application.getString(R.string.onsErrorUnableToSearch) + } +} + +internal data class State( + val newMessageIdOrOns: String = "", + val isTextErrorColor: Boolean = false, + val error: GetString? = null, + val loading: Boolean = false +) { + val isNextButtonEnabled: Boolean get() = newMessageIdOrOns.isNotBlank() +} + +internal data class Success(val publicKey: String) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java index 3ab56a89a5..6f26462da1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java @@ -2,6 +2,7 @@ import android.content.Context; import android.graphics.Canvas; +import android.graphics.Color; import android.graphics.Matrix; import android.graphics.Paint; import android.graphics.Point; @@ -10,6 +11,7 @@ import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import androidx.core.view.GestureDetectorCompat; import android.util.AttributeSet; import android.view.GestureDetector; @@ -21,6 +23,9 @@ import org.thoughtcrime.securesms.imageeditor.model.ThumbRenderer; import org.thoughtcrime.securesms.imageeditor.renderers.BezierDrawingRenderer; import org.thoughtcrime.securesms.imageeditor.renderers.TextRenderer; +import org.thoughtcrime.securesms.util.ResUtil; + +import network.loki.messenger.R; /** * ImageEditorView @@ -71,6 +76,9 @@ public final class ImageEditorView extends FrameLayout { private TapListener tapListener; private RendererContext rendererContext; + private int bgColor; + private final RectF bgRect = new RectF(); + @Nullable private EditSession editSession; private boolean moreThanOnePointerUsedInSession; @@ -98,6 +106,8 @@ private void init() { doubleTap = new GestureDetectorCompat(getContext(), new DoubleTapGestureListener()); + bgColor = ResUtil.getColor(getContext(), android.R.attr.colorPrimary); + setOnTouchListener((v, event) -> doubleTap.onTouchEvent(event)); } @@ -120,8 +130,6 @@ public void startTextEditing(@NonNull EditorElement editorElement, boolean incog editText.selectAll(); } editText.requestFocus(); - - getModel().zoomTo(editorElement, Bounds.TOP / 2, true); } } @@ -152,6 +160,14 @@ protected void onDraw(Canvas canvas) { } finally { rendererContext.restore(); } + + // Make sure the canvas doesn't apply any extra colors + bgRect.set(Bounds.FULL_BOUNDS); + viewMatrix.mapRect(bgRect); + int save = canvas.save(); + canvas.clipOutRect(bgRect); + canvas.drawColor(bgColor); + canvas.restoreToCount(save); } private final RendererContext.Ready rendererReady = new RendererContext.Ready() { 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 f34974667e..bff3de970a 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 @@ -108,12 +108,12 @@ class KeyboardPageSearchView @JvmOverloads constructor( fun showRequested(): Boolean = state == State.SHOW_REQUESTED fun enableBackNavigation(enable: Boolean = true) { - navButton.setImageResource(if (enable) R.drawable.ic_arrow_left else R.drawable.ic_search_24) + navButton.setImageResource(if (enable) R.drawable.ic_chevron_left else R.drawable.ic_search) if (enable) { - navButton.setImageResource(R.drawable.ic_arrow_left) + navButton.setImageResource(R.drawable.ic_chevron_left) navButton.setOnClickListener { callbacks?.onNavigationClicked() } } else { - navButton.setImageResource(R.drawable.ic_search_24) + navButton.setImageResource(R.drawable.ic_search) navButton.setOnClickListener(null) } } 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 6dcc928c99..76de9d7fb6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java @@ -11,7 +11,7 @@ import androidx.annotation.Nullable; 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.AttachmentState; import org.session.libsession.messaging.sending_receiving.attachments.UriAttachment; import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; import org.session.libsession.utilities.MediaTypes; @@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.net.CompositeRequestController; import org.thoughtcrime.securesms.net.ContentProxySafetyInterceptor; import org.thoughtcrime.securesms.net.RequestController; -import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.providers.BlobUtils; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -178,12 +178,12 @@ private static Optional bitmapToAttachment(@Nullable Bitmap bitmap, bitmap.compress(format, 80, baos); byte[] bytes = baos.toByteArray(); - Uri uri = BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(); + Uri uri = BlobUtils.getInstance().forData(bytes).createForSingleSessionInMemory(); return Optional.of(new UriAttachment(uri, uri, contentType, - AttachmentTransferProgress.TRANSFER_PROGRESS_STARTED, + AttachmentState.DOWNLOADING.getValue(), bytes.length, bitmap.getWidth(), bitmap.getHeight(), 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 2072e783b2..6bfee03f16 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logging/LogFile.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logging/LogFile.java @@ -7,9 +7,11 @@ import org.session.libsession.utilities.Conversions; import org.session.libsession.utilities.Util; +import org.thoughtcrime.securesms.util.LimitedInputStream; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; +import java.io.Closeable; import java.io.EOFException; import java.io.File; import java.io.FileInputStream; @@ -47,14 +49,14 @@ public static class Writer { private final GrowingBuffer ciphertextBuffer = new GrowingBuffer(); private final byte[] secret; - private final File file; + final File file; private final Cipher cipher; private final BufferedOutputStream outputStream; - Writer(@NonNull byte[] secret, @NonNull File file) throws IOException { + Writer(@NonNull byte[] secret, @NonNull File file, boolean append) throws IOException { this.secret = secret; this.file = file; - this.outputStream = new BufferedOutputStream(new FileOutputStream(file, true)); + this.outputStream = new BufferedOutputStream(new FileOutputStream(file, append)); try { this.cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); @@ -63,7 +65,7 @@ public static class Writer { } } - void writeEntry(@NonNull String entry) throws IOException { + void writeEntry(@NonNull String entry, boolean flush) throws IOException { SECURE_RANDOM.nextBytes(ivBuffer); byte[] plaintext = entry.getBytes(); @@ -80,12 +82,18 @@ void writeEntry(@NonNull String entry) throws IOException { outputStream.write(ciphertext, 0, cipherLength); } - outputStream.flush(); + if (flush) { + outputStream.flush(); + } } catch (ShortBufferException | InvalidAlgorithmParameterException | InvalidKeyException | BadPaddingException | IllegalBlockSizeException e) { throw new AssertionError(e); } } + void flush() throws IOException { + outputStream.flush(); + } + long getLogSize() { return file.length(); } @@ -95,7 +103,7 @@ void close() { } } - static class Reader { + static class Reader implements Closeable { private final byte[] ivBuffer = new byte[16]; private final byte[] intBuffer = new byte[4]; @@ -107,7 +115,8 @@ static class Reader { Reader(@NonNull byte[] secret, @NonNull File file) throws IOException { this.secret = secret; - this.inputStream = new BufferedInputStream(new FileInputStream(file)); + // Limit the input stream to the file size to prevent endless reading in the case of a streaming file. + this.inputStream = new BufferedInputStream(new LimitedInputStream(new FileInputStream(file), file.length())); try { this.cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); @@ -116,18 +125,12 @@ static class Reader { } } - String readAll() throws IOException { - StringBuilder builder = new StringBuilder(); - - String entry; - while ((entry = readEntry()) != null) { - builder.append(entry).append('\n'); - } - - return builder.toString(); + @Override + public void close() throws IOException { + Util.close(inputStream); } - String readEntry() throws IOException { + byte[] readEntryBytes() throws IOException { try { // Read the IV and length Util.readFully(inputStream, ivBuffer); @@ -151,7 +154,7 @@ String readEntry() throws IOException { 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); + return plaintext; } } catch (BadPaddingException e) { // Bad padding likely indicates a corrupted or incomplete entry. diff --git a/app/src/main/java/org/thoughtcrime/securesms/logging/PersistentLogger.java b/app/src/main/java/org/thoughtcrime/securesms/logging/PersistentLogger.java deleted file mode 100644 index 9fd5968f6a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/logging/PersistentLogger.java +++ /dev/null @@ -1,250 +0,0 @@ -package org.thoughtcrime.securesms.logging; - -import android.content.Context; - -import androidx.annotation.AnyThread; -import androidx.annotation.WorkerThread; - -import org.session.libsignal.utilities.ListenableFuture; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.NoExternalStorageException; -import org.session.libsignal.utilities.SettableFuture; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.io.PrintStream; -import java.text.SimpleDateFormat; -import java.util.Arrays; -import java.util.Date; -import java.util.LinkedList; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; - -public class PersistentLogger extends Log.Logger { - - private static final String TAG = PersistentLogger.class.getSimpleName(); - - private static final String LOG_V = "V"; - private static final String LOG_D = "D"; - private static final String LOG_I = "I"; - private static final String LOG_W = "W"; - private static final String LOG_E = "E"; - private static final String LOG_WTF = "A"; - - private static final String LOG_DIRECTORY = "log"; - private static final String FILENAME_PREFIX = "log-"; - private static final int MAX_LOG_FILES = 5; - private static final int MAX_LOG_SIZE = 300 * 1024; - private static final int MAX_LOG_EXPORT = 10_000; - private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS zzz"); - - private final Context context; - private final Executor executor; - private final byte[] secret; - - private LogFile.Writer writer; - - public PersistentLogger(Context context) { - this.context = context.getApplicationContext(); - this.secret = LogSecretProvider.getOrCreateAttachmentSecret(context); - this.executor = Executors.newSingleThreadExecutor(r -> { - Thread thread = new Thread(r, "PersistentLogger"); - thread.setPriority(Thread.MIN_PRIORITY); - return thread; - }); - - executor.execute(this::initializeWriter); - } - - @Override - public void v(String tag, String message, Throwable t) { - write(LOG_V, tag, message, t); - } - - @Override - public void d(String tag, String message, Throwable t) { - write(LOG_D, tag, message, t); - } - - @Override - public void i(String tag, String message, Throwable t) { - write(LOG_I, tag, message, t); - } - - @Override - public void w(String tag, String message, Throwable t) { - write(LOG_W, tag, message, t); - } - - @Override - public void e(String tag, String message, Throwable t) { - write(LOG_E, tag, message, t); - } - - @Override - public void wtf(String tag, String message, Throwable t) { - write(LOG_WTF, tag, message, t); - } - - @Override - public void blockUntilAllWritesFinished() { - CountDownLatch latch = new CountDownLatch(1); - - executor.execute(latch::countDown); - - try { - latch.await(); - } catch (InterruptedException e) { - android.util.Log.w(TAG, "Failed to wait for all writes."); - } - } - - @WorkerThread - public ListenableFuture getLogs() { - final SettableFuture future = new SettableFuture<>(); - - executor.execute(() -> { - StringBuilder builder = new StringBuilder(); - long entriesWritten = 0; - - try { - File[] logs = getSortedLogFiles(); - for (int i = logs.length - 1; i >= 0 && entriesWritten <= MAX_LOG_EXPORT; i--) { - try { - LogFile.Reader reader = new LogFile.Reader(secret, logs[i]); - String entry; - while ((entry = reader.readEntry()) != null) { - entriesWritten++; - builder.append(entry).append('\n'); - } - } catch (IOException e) { - android.util.Log.w(TAG, "Failed to read log at index " + i + ". Removing reference."); - logs[i].delete(); - } - } - - future.set(builder.toString()); - } catch (NoExternalStorageException e) { - future.setException(e); - } - }); - - return future; - } - - @WorkerThread - private void initializeWriter() { - try { - writer = new LogFile.Writer(secret, getOrCreateActiveLogFile()); - } catch (NoExternalStorageException | IOException e) { - android.util.Log.e(TAG, "Failed to initialize writer.", e); - } - } - - @AnyThread - private void write(String level, String tag, String message, Throwable t) { - executor.execute(() -> { - try { - if (writer == null) { - return; - } - - if (writer.getLogSize() >= MAX_LOG_SIZE) { - writer.close(); - writer = new LogFile.Writer(secret, createNewLogFile()); - trimLogFilesOverMax(); - } - - for (String entry : buildLogEntries(level, tag, message, t)) { - writer.writeEntry(entry); - } - - } catch (NoExternalStorageException e) { - android.util.Log.w(TAG, "Cannot persist logs.", e); - } catch (IOException e) { - android.util.Log.w(TAG, "Failed to write line. Deleting all logs and starting over."); - deleteAllLogs(); - initializeWriter(); - } - }); - } - - private void trimLogFilesOverMax() throws NoExternalStorageException { - File[] logs = getSortedLogFiles(); - if (logs.length > MAX_LOG_FILES) { - for (int i = MAX_LOG_FILES; i < logs.length; i++) { - logs[i].delete(); - } - } - } - - private void deleteAllLogs() { - try { - File[] logs = getSortedLogFiles(); - for (File log : logs) { - log.delete(); - } - } catch (NoExternalStorageException e) { - android.util.Log.w(TAG, "Was unable to delete logs.", e); - } - } - - private File getOrCreateActiveLogFile() throws NoExternalStorageException { - File[] logs = getSortedLogFiles(); - if (logs.length > 0) { - return logs[0]; - } - - return createNewLogFile(); - } - - private File createNewLogFile() throws NoExternalStorageException { - return new File(getOrCreateLogDirectory(), FILENAME_PREFIX + System.currentTimeMillis()); - } - - private File[] getSortedLogFiles() throws NoExternalStorageException { - File[] logs = getOrCreateLogDirectory().listFiles(); - if (logs != null) { - Arrays.sort(logs, (o1, o2) -> o2.getName().compareTo(o1.getName())); - return logs; - } - return new File[0]; - } - - private File getOrCreateLogDirectory() throws NoExternalStorageException { - File logDir = new File(context.getCacheDir(), LOG_DIRECTORY); - if (!logDir.exists() && !logDir.mkdir()) { - throw new NoExternalStorageException("Unable to create log directory."); - } - - return logDir; - } - - private List buildLogEntries(String level, String tag, String message, Throwable t) { - List entries = new LinkedList<>(); - Date date = new Date(); - - entries.add(buildEntry(level, tag, message, date)); - - if (t != null) { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - t.printStackTrace(new PrintStream(outputStream)); - - String trace = new String(outputStream.toByteArray()); - String[] lines = trace.split("\\n"); - - for (String line : lines) { - entries.add(buildEntry(level, tag, line, date)); - } - } - - return entries; - } - - private String buildEntry(String level, String tag, String message, Date date) { - return DATE_FORMAT.format(date) + ' ' + level + ' ' + tag + ": " + message; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/logging/PersistentLogger.kt b/app/src/main/java/org/thoughtcrime/securesms/logging/PersistentLogger.kt new file mode 100644 index 0000000000..d641719a96 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/logging/PersistentLogger.kt @@ -0,0 +1,339 @@ +package org.thoughtcrime.securesms.logging + +import android.content.Context +import android.net.Uri +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.session.libsignal.utilities.Log.Logger +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import java.io.File +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.regex.Pattern +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +/** + * A [Logger] that writes logs to encrypted files in the app's cache directory. + */ +@Singleton +class PersistentLogger @Inject constructor( + @param:ApplicationContext private val context: Context, + @ManagerScope scope: CoroutineScope, +) : Logger(), OnAppStartupComponent { + private val freeLogEntryPool = LogEntryPool() + private val logEntryChannel: SendChannel + private val logChannelIdleSignal = MutableSharedFlow() + + private val logDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS zzz", Locale.ENGLISH) + + private val secret by lazy { + LogSecretProvider.getOrCreateAttachmentSecret(context) + } + + private val logFolder by lazy { + File(context.cacheDir, "logs").apply { + mkdirs() + } + } + + init { + val channel = Channel(capacity = MAX_PENDING_LOG_ENTRIES) + logEntryChannel = channel + + scope.launch { + val bulk = ArrayList() + var logWriter: LogFile.Writer? = null + val entryBuilder = StringBuilder() + + try { + while (true) { + channel.receiveBulkLogs(bulk) + + if (bulk.isNotEmpty()) { + if (logWriter == null) { + val currentFile = File(logFolder, CURRENT_LOG_FILE_NAME) + + // If current file exist, we need to make sure we can decrypt it + // as this file can come from a previous session. + val append = if (currentFile.exists() && currentFile.length() > 0) { + LogFile.Reader(secret, currentFile).use { + it.readEntryBytes() != null + } + } else { + true + } + + logWriter = LogFile.Writer(secret, currentFile, append) + } + + bulkWrite(entryBuilder, logWriter, bulk) + + // Release entries back to the pool + freeLogEntryPool.release(bulk) + bulk.clear() + + // Rotate the log file if necessary + if (logWriter.logSize > MAX_SINGLE_LOG_FILE_SIZE) { + rotateAndTrimLogFiles(logWriter.file) + logWriter.close() + logWriter = null + } + } + + // Notify that the log channel is idle + logChannelIdleSignal.tryEmit(Unit) + } + } catch (e: Exception) { + logWriter?.close() + + android.util.Log.e( + TAG, + "Error while processing log entries: ${e.message}", + e + ) + } + } + } + + fun deleteAllLogs() { + logFolder.deleteRecursively() + } + + private fun rotateAndTrimLogFiles(currentFile: File) { + val permLogFile = File(logFolder, "${System.currentTimeMillis()}$PERM_LOG_FILE_SUFFIX") + if (currentFile.renameTo(permLogFile)) { + android.util.Log.d(TAG, "Rotated log file: $currentFile to $permLogFile") + currentFile.createNewFile() + } else { + android.util.Log.e(TAG, "Failed to rotate log file: $currentFile") + } + + val logFilesNewToOld = getLogFilesSorted(includeActiveLogFile = false) + + // Keep the last N log files, delete the rest + while (logFilesNewToOld.size > MAX_LOG_FILE_COUNT) { + val last = logFilesNewToOld.removeLastOrNull()!! + if (last.delete()) { + android.util.Log.d(TAG, "Deleted old log file: $last") + } else { + android.util.Log.e(TAG, "Failed to delete log file: $last") + } + } + } + + private fun bulkWrite(sb: StringBuilder, writer: LogFile.Writer, bulk: List) { + for (entry in bulk) { + sb.clear() + sb.append(logDateFormat.format(entry.timestampMills)) + .append(' ') + .append(entry.logLevel) + .append(' ') + .append(entry.tag.orEmpty()) + .append(": ") + .append(entry.message.orEmpty()) + .append('\n') + entry.err?.let { + sb.append('\n') + sb.append(it.stackTraceToString()) + } + writer.writeEntry(sb.toString(), false) + } + + writer.flush() + } + + private suspend fun ReceiveChannel.receiveBulkLogs(out: MutableList) { + out += receive() + + withTimeoutOrNull(500.milliseconds) { + repeat(15) { + out += receiveCatching().getOrNull() ?: return@repeat + } + } + } + + private fun sendLogEntry( + level: String, + tag: String?, + message: String?, + t: Throwable? = null + ) { + val entry = freeLogEntryPool.createLogEntry(level, tag, message, t) + if (logEntryChannel.trySend(entry).isFailure) { + android.util.Log.e(TAG, "Failed to send log entry, buffer is full") + } + } + + override fun v(tag: String?, message: String?, t: Throwable?) = + sendLogEntry(LOG_V, tag, message, t) + + override fun d(tag: String?, message: String?, t: Throwable?) = + sendLogEntry(LOG_D, tag, message, t) + + override fun i(tag: String?, message: String?, t: Throwable?) = + sendLogEntry(LOG_I, tag, message, t) + + override fun w(tag: String?, message: String?, t: Throwable?) = + sendLogEntry(LOG_W, tag, message, t) + + override fun e(tag: String?, message: String?, t: Throwable?) = + sendLogEntry(LOG_E, tag, message, t) + + override fun wtf(tag: String?, message: String?, t: Throwable?) = + sendLogEntry(LOG_WTF, tag, message, t) + + override fun blockUntilAllWritesFinished() { + runBlocking { + withTimeoutOrNull(1000) { + logChannelIdleSignal.first() + } + } + } + + private fun getLogFilesSorted(includeActiveLogFile: Boolean): MutableList { + val files = (logFolder.listFiles()?.asSequence() ?: emptySequence()) + .mapNotNull { + if (!it.isFile) return@mapNotNull null + PERM_LOG_FILE_PATTERN.matcher(it.name).takeIf { it.matches() } + ?.group(1) + ?.toLongOrNull() + ?.let { timestamp -> it to timestamp } + } + .sortedByDescending { (_, timestamp) -> timestamp } + .mapTo(arrayListOf()) { it.first } + + if (includeActiveLogFile) { + val currentLogFile = File(logFolder, CURRENT_LOG_FILE_NAME) + if (currentLogFile.exists()) { + files.add(0, currentLogFile) + } + } + + return files + } + + /** + * Reads all log entries from the log files and writes them as a ZIP file at the specified URI. + * + * This method will block until all log entries are read and written. + */ + fun readAllLogsCompressed(output: Uri) { + val logs = getLogFilesSorted(includeActiveLogFile = true).apply { reverse() } + + if (logs.isEmpty()) { + android.util.Log.w(TAG, "No log files found to read.") + return + } + + requireNotNull(context.contentResolver.openOutputStream(output, "w")?.buffered()) { + "Failed to open output stream for URI: $output" + }.use { outStream -> + ZipOutputStream(outStream).use { zipOut -> + zipOut.putNextEntry(ZipEntry("log.txt")) + + for (log in logs) { + LogFile.Reader(secret, log).use { reader -> + var count = 0 + generateSequence { reader.readEntryBytes() } + .forEach { entry -> + zipOut.write(entry) + + if (entry.isEmpty() || entry.last().toInt() != '\n'.code) { + zipOut.write('\n'.code) + } + + count++ + } + + android.util.Log.d(TAG, "Read $count entries from ${log.name}") + } + } + zipOut.closeEntry() + } + } + } + + private class LogEntry( + var logLevel: String, + var tag: String?, + var message: String?, + var err: Throwable?, + var timestampMills: Long, + ) + + /** + * A pool for reusing [LogEntry] objects to reduce memory allocations. + */ + private class LogEntryPool { + private val pool = ArrayList(MAX_LOG_ENTRIES_POOL_SIZE) + + fun createLogEntry(level: String, tag: String?, message: String?, t: Throwable?): LogEntry { + val fromPool = synchronized(pool) { pool.removeLastOrNull() } + + val now = System.currentTimeMillis() + + if (fromPool != null) { + fromPool.logLevel = level + fromPool.tag = tag + fromPool.message = message + fromPool.err = t + fromPool.timestampMills = now + return fromPool + } + + return LogEntry( + logLevel = level, + tag = tag, + message = message, + err = t, + timestampMills = now + ) + } + + fun release(entry: Iterable) { + val iterator = entry.iterator() + synchronized(pool) { + while (pool.size < MAX_LOG_ENTRIES_POOL_SIZE && iterator.hasNext()) { + pool.add(iterator.next()) + } + } + } + } + + companion object { + private const val TAG = "PersistentLoggingV2" + + private const val LOG_V: String = "V" + private const val LOG_D: String = "D" + private const val LOG_I: String = "I" + private const val LOG_W: String = "W" + private const val LOG_E: String = "E" + private const val LOG_WTF: String = "A" + + private const val PERM_LOG_FILE_SUFFIX = ".permlog" + private const val CURRENT_LOG_FILE_NAME = "current.log" + private val PERM_LOG_FILE_PATTERN by lazy { Pattern.compile("^(\\d+?)\\.permlog$") } + + // Maximum size of a single log file + private const val MAX_SINGLE_LOG_FILE_SIZE = 2 * 1024 * 1024 + + // Maximum number of log files to keep + private const val MAX_LOG_FILE_COUNT = 10 + + private const val MAX_LOG_ENTRIES_POOL_SIZE = 64 + private const val MAX_PENDING_LOG_ENTRIES = 65536 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/DocumentsPage.kt b/app/src/main/java/org/thoughtcrime/securesms/media/DocumentsPage.kt index 4d132f009b..1372d5b5be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/DocumentsPage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/DocumentsPage.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.media +import android.text.format.Formatter import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -8,17 +9,23 @@ 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.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -73,13 +80,14 @@ fun DocumentsPage( verticalAlignment = Alignment.CenterVertically, ) { Image( - painterResource(R.drawable.ic_document_large_dark), + painterResource(R.drawable.ic_file), + colorFilter = ColorFilter.tint(LocalColors.current.text), contentDescription = null ) Column(verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsSpacing)) { Text( - text = file.fileName.orEmpty(), + text = file.filename, style = LocalType.current.large, color = LocalColors.current.text ) @@ -87,7 +95,7 @@ fun DocumentsPage( Row(modifier = Modifier.fillMaxWidth()) { Text( modifier = Modifier.weight(1f), - text = Util.getPrettyFileSize(file.fileSize), + text = Formatter.formatFileSize(LocalContext.current, file.fileSize), style = LocalType.current.small, color = LocalColors.current.textSecondary, textAlign = TextAlign.Start, @@ -105,6 +113,10 @@ fun DocumentsPage( } } } + + item { + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/FixedTimeBuckets.kt b/app/src/main/java/org/thoughtcrime/securesms/media/FixedTimeBuckets.kt index ecfab34aab..31074b65d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/FixedTimeBuckets.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/FixedTimeBuckets.kt @@ -1,13 +1,12 @@ package org.thoughtcrime.securesms.media import android.content.Context -import androidx.annotation.StringRes -import network.loki.messenger.R -import org.thoughtcrime.securesms.util.DateUtils -import org.thoughtcrime.securesms.util.RelativeDay import java.time.ZonedDateTime import java.time.temporal.WeekFields import java.util.Locale +import network.loki.messenger.R +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.RelativeDay /** * A data structure that describes a series of time points in the past. It's primarily @@ -35,10 +34,10 @@ class FixedTimeBuckets( * Test the given time against the buckets and return the appropriate string the time * bucket. If no bucket is appropriate, it will return null. */ - fun getBucketText(context: Context, time: ZonedDateTime): String? { + fun getBucketText(context: Context, dateUtils: DateUtils, time: ZonedDateTime): String? { return when { - time >= startOfToday -> DateUtils.getLocalisedRelativeDayString(RelativeDay.TODAY) - time >= startOfYesterday -> DateUtils.getLocalisedRelativeDayString(RelativeDay.YESTERDAY) + time >= startOfToday -> dateUtils.getLocalisedRelativeDayString(RelativeDay.TODAY) + time >= startOfYesterday -> dateUtils.getLocalisedRelativeDayString(RelativeDay.YESTERDAY) time >= startOfThisWeek -> context.getString(R.string.attachmentsThisWeek) time >= startOfThisMonth -> context.getString(R.string.attachmentsThisMonth) else -> null diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewActivity.kt index 7111c00017..0acbdfa303 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewActivity.kt @@ -2,32 +2,26 @@ package org.thoughtcrime.securesms.media import android.content.Context import android.content.Intent -import android.os.Bundle import androidx.activity.viewModels +import androidx.compose.runtime.Composable import androidx.core.content.IntentCompat +import androidx.hilt.navigation.compose.hiltViewModel import dagger.hilt.android.AndroidEntryPoint import org.session.libsession.utilities.Address -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.ui.setComposeContent +import org.thoughtcrime.securesms.FullComposeScreenLockActivity import javax.inject.Inject @AndroidEntryPoint -class MediaOverviewActivity : PassphraseRequiredActionBarActivity() { - @Inject - lateinit var viewModelFactory: MediaOverviewViewModel.AssistedFactory - - private val viewModel: MediaOverviewViewModel by viewModels { - viewModelFactory.create(IntentCompat.getParcelableExtra(intent, EXTRA_ADDRESS, Address::class.java)!!) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setComposeContent { - MediaOverviewScreen(viewModel, onClose = this::finish) +class MediaOverviewActivity : FullComposeScreenLockActivity() { + @Composable + override fun ComposeContent() { + val viewModel = hiltViewModel { factory -> + factory.create( + IntentCompat.getParcelableExtra(intent, EXTRA_ADDRESS, Address::class.java)!! + ) } - supportActionBar?.hide() + MediaOverviewScreen(viewModel, onClose = this::finish) } companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt index 4bdec3fdf5..38bf670080 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt @@ -7,18 +7,20 @@ import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -41,15 +43,16 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import network.loki.messenger.R import org.thoughtcrime.securesms.ui.AlertDialog -import org.thoughtcrime.securesms.ui.DialogButtonModel +import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator import org.thoughtcrime.securesms.ui.components.SessionTabRow import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @OptIn( - ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, + ExperimentalMaterial3Api::class, ) @Composable fun MediaOverviewScreen( @@ -58,6 +61,7 @@ fun MediaOverviewScreen( ) { val selectedItems by viewModel.selectedItemIDs.collectAsState() val selectionMode by viewModel.inSelectionMode.collectAsState() + val conversationName by viewModel.conversationName.collectAsState() val topAppBarState = rememberTopAppBarState() var showingDeleteConfirmation by remember { mutableStateOf(false) } var showingSaveAttachmentWarning by remember { mutableStateOf(false) } @@ -124,7 +128,7 @@ fun MediaOverviewScreen( topBar = { MediaOverviewTopAppBar( selectionMode = selectionMode, - title = viewModel.title.collectAsState().value, + title = conversationName, onBackClicked = viewModel::onBackClicked, onSaveClicked = { showingSaveAttachmentWarning = true }, onDeleteClicked = { showingDeleteConfirmation = true }, @@ -132,7 +136,8 @@ fun MediaOverviewScreen( numSelected = selectedItems.size, appBarScrollBehavior = appBarScrollBehavior ) - } + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) ) { paddings -> Column( modifier = Modifier @@ -232,8 +237,8 @@ private fun SaveAttachmentWarningDialog( title = context.getString(R.string.warning), text = context.resources.getString(R.string.attachmentsWarning), buttons = listOf( - DialogButtonModel(GetString(R.string.save), color = LocalColors.current.danger, onClick = onAccepted), - DialogButtonModel(GetString(android.R.string.cancel), dismissOnClick = true) + DialogButtonData(GetString(R.string.save), color = LocalColors.current.danger, onClick = onAccepted), + DialogButtonData(GetString(android.R.string.cancel), dismissOnClick = true) ) ) } @@ -250,8 +255,8 @@ private fun DeleteConfirmationDialog( title = stringResource(R.string.delete), text = stringResource(R.string.deleteMessageDeviceOnly), buttons = listOf( - DialogButtonModel(GetString(R.string.delete), color = LocalColors.current.danger, onClick = onAccepted), - DialogButtonModel(GetString(android.R.string.cancel), dismissOnClick = true) + DialogButtonData(GetString(R.string.delete), color = LocalColors.current.danger, onClick = onAccepted), + DialogButtonData(GetString(android.R.string.cancel), dismissOnClick = true) ) ) } @@ -271,7 +276,7 @@ private fun ActionProgressDialog( horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing), verticalAlignment = Alignment.CenterVertically, ) { - CircularProgressIndicator(color = LocalColors.current.primary) + CircularProgressIndicator(color = LocalColors.current.accent) Text( text, style = LocalType.current.large, diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt index c6fa7d4a1f..34476533ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewTopAppBar.kt @@ -3,14 +3,18 @@ package org.thoughtcrime.securesms.media import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import network.loki.messenger.R import org.thoughtcrime.securesms.ui.components.ActionAppBar import org.thoughtcrime.securesms.ui.components.AppBarBackIcon import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.PreviewTheme @Composable @OptIn(ExperimentalMaterial3Api::class) @@ -26,6 +30,7 @@ fun MediaOverviewTopAppBar( ) { ActionAppBar( title = title, + singleLine = true, actionModeTitle = numSelected.toString(), navigationIcon = { AppBarBackIcon(onBack = onBackClicked) }, scrollBehavior = appBarScrollBehavior, @@ -33,7 +38,7 @@ fun MediaOverviewTopAppBar( actionModeActions = { IconButton(onClick = onSaveClicked) { Icon( - painterResource(R.drawable.ic_baseline_save_24), + painterResource(R.drawable.ic_arrow_down_to_line), contentDescription = stringResource(R.string.save), tint = LocalColors.current.text, ) @@ -41,7 +46,7 @@ fun MediaOverviewTopAppBar( IconButton(onClick = onDeleteClicked) { Icon( - painterResource(R.drawable.ic_baseline_delete_24), + painterResource(R.drawable.ic_trash_2), contentDescription = stringResource(R.string.delete), tint = LocalColors.current.text, ) @@ -57,3 +62,23 @@ fun MediaOverviewTopAppBar( } ) } + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +fun PreviewMediaOverviewAppBar() { + PreviewTheme { + MediaOverviewTopAppBar( + selectionMode = false, + numSelected = 0, + title = "Really long title asdlkajsdlkasjdlaskdjalskdjaslkj", + onBackClicked = {}, + onSaveClicked = {}, + onDeleteClicked = {}, + onSelectAllClicked = {}, + appBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior( + rememberTopAppBarState() + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt index 0356dd3238..3951c44763 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt @@ -5,11 +5,16 @@ import android.content.Context import android.content.Intent import android.net.Uri 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 dagger.hilt.android.lifecycle.HiltViewModel +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -41,22 +46,18 @@ import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.SaveAttachmentTask import org.thoughtcrime.securesms.util.asSequence import org.thoughtcrime.securesms.util.observeChanges -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import java.util.Locale -class MediaOverviewViewModel( - private val address: Address, +@HiltViewModel(assistedFactory = MediaOverviewViewModel.Factory::class) +class MediaOverviewViewModel @AssistedInject constructor( + @Assisted private val address: Address, private val application: Application, private val threadDatabase: ThreadDatabase, - private val mediaDatabase: MediaDatabase + private val mediaDatabase: MediaDatabase, + private val dateUtils: DateUtils ) : AndroidViewModel(application) { + private val timeBuckets by lazy { FixedTimeBuckets() } - private val monthTimeBucketFormatter = - DateTimeFormatter.ofPattern("MMMM yyyy", Locale.getDefault()) + private val monthTimeBucketFormatter = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.getDefault()) private val recipient: SharedFlow = application.contentResolver .observeChanges(DatabaseContentProviders.Attachment.CONTENT_URI) @@ -64,10 +65,6 @@ class MediaOverviewViewModel( .map { Recipient.from(application, address, false) } .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) - val title: StateFlow = recipient - .map { it.toShortString() } - .stateIn(viewModelScope, SharingStarted.Eagerly, "") - val mediaListState: StateFlow = recipient .map { recipient -> withContext(Dispatchers.Default) { @@ -94,6 +91,15 @@ class MediaOverviewViewModel( } .stateIn(viewModelScope, SharingStarted.Eagerly, null) + val conversationName: StateFlow = recipient + .map { recipient -> + when { + recipient.isLocalNumber -> application.getString(R.string.noteToSelf) + else -> recipient.name + } + } + .stateIn(viewModelScope, SharingStarted.Eagerly, "") + private val mutableSelectedItemIDs = MutableStateFlow(emptySet()) val selectedItemIDs: StateFlow> get() = mutableSelectedItemIDs @@ -130,7 +136,7 @@ class MediaOverviewViewModel( .groupBy { record -> val time = ZonedDateTime.ofInstant(Instant.ofEpochMilli(record.date), ZoneId.of("UTC")) - timeBuckets.getBucketText(application, time) + timeBuckets.getBucketText(application, dateUtils, time) ?: time.toLocalDate().withDayOfMonth(1) } .map { (bucket, records) -> @@ -154,7 +160,7 @@ class MediaOverviewViewModel( private fun Sequence.groupRecordsByRelativeTime(): List>> { return this .groupBy { record -> - DateUtils.getRelativeDate(application, Locale.getDefault(), record.date) + dateUtils.getRelativeDate(Locale.getDefault(), record.date) } .map { (bucket, records) -> bucket to records.map { record -> @@ -168,7 +174,6 @@ class MediaOverviewViewModel( } } - fun onItemClicked(item: MediaOverviewItem) { if (inSelectionMode.value) { if (item.slide.hasDocument()) { @@ -227,8 +232,7 @@ class MediaOverviewViewModel( fun onSaveClicked() { if (!inSelectionMode.value) { - // Not in selection mode, so we should not be able to save - return + return // Not in selection mode, so we should not be able to save } viewModelScope.launch { @@ -244,7 +248,7 @@ class MediaOverviewViewModel( uri = uri, contentType = it.mediaRecord.contentType, date = it.mediaRecord.date, - fileName = it.mediaRecord.attachment.fileName, + filename = it.mediaRecord.attachment.filename ) } @@ -314,7 +318,7 @@ class MediaOverviewViewModel( } } - threadDatabase.getThreadIdIfExistsFor(address.serialize()) + threadDatabase.getThreadIdIfExistsFor(address.toString()) } // Notify the content provider that the thread has been updated @@ -358,24 +362,10 @@ class MediaOverviewViewModel( } @dagger.assisted.AssistedFactory - interface AssistedFactory { - fun create(address: Address): Factory + interface Factory { + fun create(address: Address): MediaOverviewViewModel } - class Factory @AssistedInject constructor( - @Assisted private val address: Address, - private val application: Application, - private val threadDatabase: ThreadDatabase, - private val mediaDatabase: MediaDatabase - ) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = MediaOverviewViewModel( - address, - application, - threadDatabase, - mediaDatabase - ) as T - } } @@ -414,8 +404,8 @@ data class MediaOverviewItem( val hasPlaceholder: Boolean get() = slide.hasPlaceholder() - val fileName: String? - get() = slide.fileName.orNull() + val filename: String + get() = slide.filename val fileSize: Long get() = slide.fileSize diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt index 35479ae503..ced52541a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaPage.kt @@ -10,11 +10,13 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text @@ -27,7 +29,6 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -98,6 +99,10 @@ fun MediaPage( ) } } + + item { + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) + } } } } @@ -153,29 +158,20 @@ private fun ThumbnailRow( it.diskCacheStrategy(DiskCacheStrategy.NONE) } } else { - // The resource given by the placeholder needs tinting according to our theme. - // But the missing thumbnail picture does not. - var (placeholder, shouldTint) = if (item.hasPlaceholder) { - item.placeholder(LocalContext.current) to true + + val placeholder = if (item.hasPlaceholder) { + item.placeholder(LocalContext.current) } else { - R.drawable.ic_missing_thumbnail_picture to false + R.drawable.ic_triangle_alert } - if (placeholder == 0) { - placeholder = R.drawable.ic_missing_thumbnail_picture - shouldTint = false - } Image( painter = painterResource(placeholder), contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Inside, - colorFilter = if (shouldTint) { - ColorFilter.tint(LocalColors.current.textSecondary) - } else { - null - } + colorFilter = ColorFilter.tint(LocalColors.current.textSecondary) ) } @@ -193,7 +189,7 @@ private fun ThumbnailRow( modifier = Modifier.padding(start = LocalDimensions.current.xxxsSpacing), painter = painterResource(R.drawable.triangle_right), contentDescription = null, - colorFilter = ColorFilter.tint(LocalColors.current.primary) + colorFilter = ColorFilter.tint(LocalColors.current.accent) ) } } @@ -211,7 +207,8 @@ private fun ThumbnailRow( .fillMaxSize() .background(Color.Black.copy(alpha = 0.4f)), contentScale = ContentScale.Inside, - painter = painterResource(R.drawable.ic_check_white_48dp), + painter = painterResource(R.drawable.ic_check), + colorFilter = ColorFilter.tint(Color.White), contentDescription = stringResource(R.string.AccessibilityId_select), ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java index 91dad13848..152e44b313 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java @@ -3,141 +3,167 @@ import android.content.Context; import android.database.Cursor; import android.net.Uri; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; +import dagger.hilt.android.lifecycle.HiltViewModel; +import dagger.hilt.android.qualifiers.ApplicationContext; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import javax.inject.Inject; +import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.guava.Optional; import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord; import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.util.FilenameUtils; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; - +@HiltViewModel public class MediaPreviewViewModel extends ViewModel { - private final MutableLiveData previewData = new MutableLiveData<>(); - - private boolean leftIsRecent; - - private @Nullable Cursor cursor; - - public void setCursor(@NonNull Context context, @Nullable Cursor cursor, boolean leftIsRecent) { - boolean firstLoad = (this.cursor == null) && (cursor != null); - if (this.cursor != null && !this.cursor.equals(cursor)) { - this.cursor.close(); - } - this.cursor = cursor; - this.leftIsRecent = leftIsRecent; + private final Context context; - if (firstLoad) { - setActiveAlbumRailItem(context, 0); - } - } + @Inject + public MediaPreviewViewModel(@ApplicationContext Context context) { this.context = context; } - public void setActiveAlbumRailItem(@NonNull Context context, int activePosition) { - if (cursor == null) { - previewData.postValue(new PreviewData(Collections.emptyList(), null, 0)); - return; - } + private final MutableLiveData previewData = new MutableLiveData<>(); - activePosition = getCursorPosition(activePosition); + private boolean leftIsRecent; - cursor.moveToPosition(activePosition); + private @Nullable Cursor cursor; - MediaRecord activeRecord = MediaRecord.from(context, cursor); - LinkedList rail = new LinkedList<>(); + // map of playback position of the pager's items + private final Map playbackPositions = new HashMap<>(); - Media activeMedia = toMedia(activeRecord); - if (activeMedia != null) rail.add(activeMedia); + public void setCursor(@NonNull Context context, @Nullable Cursor cursor, boolean leftIsRecent) { + boolean firstLoad = (this.cursor == null) && (cursor != null); + if (this.cursor != null && !this.cursor.equals(cursor)) { + this.cursor.close(); + } + this.cursor = cursor; + this.leftIsRecent = leftIsRecent; - while (cursor.moveToPrevious()) { - MediaRecord record = MediaRecord.from(context, cursor); - if (record.getAttachment().getMmsId() == activeRecord.getAttachment().getMmsId()) { - Media media = toMedia(record); - if (media != null) rail.addFirst(media); - } else { - break; - } + if (firstLoad) { + setActiveAlbumRailItem(context, 0); + } } - cursor.moveToPosition(activePosition); - - while (cursor.moveToNext()) { - MediaRecord record = MediaRecord.from(context, cursor); - if (record.getAttachment().getMmsId() == activeRecord.getAttachment().getMmsId()) { - Media media = toMedia(record); - if (media != null) rail.addLast(media); - } else { - break; - } + public void savePlaybackPosition(Uri videoUri, long position) { + playbackPositions.put(videoUri, position); } - if (!leftIsRecent) { - Collections.reverse(rail); + public long getSavedPlaybackPosition(Uri videoUri) { + Long position = playbackPositions.get(videoUri); + return position != null ? position : 0L; } - previewData.postValue(new PreviewData(rail.size() > 1 ? rail : Collections.emptyList(), - activeRecord.getAttachment().getCaption(), - rail.indexOf(activeMedia))); - } - - private int getCursorPosition(int position) { - if (cursor == null) { - return 0; + public void setActiveAlbumRailItem(@NonNull Context context, int activePosition) { + if (cursor == null) { + previewData.postValue(new PreviewData(Collections.emptyList(), null, 0)); + return; + } + + activePosition = getCursorPosition(activePosition); + + cursor.moveToPosition(activePosition); + + MediaRecord activeRecord = MediaRecord.from(context, cursor); + LinkedList rail = new LinkedList<>(); + + Media activeMedia = toMedia(activeRecord); + if (activeMedia != null) rail.add(activeMedia); + + while (cursor.moveToPrevious()) { + MediaRecord record = MediaRecord.from(context, cursor); + if (record.getAttachment().getMmsId() == activeRecord.getAttachment().getMmsId()) { + Media media = toMedia(record); + if (media != null) rail.addFirst(media); + } else { + break; + } + } + + cursor.moveToPosition(activePosition); + + while (cursor.moveToNext()) { + MediaRecord record = MediaRecord.from(context, cursor); + if (record.getAttachment().getMmsId() == activeRecord.getAttachment().getMmsId()) { + Media media = toMedia(record); + if (media != null) rail.addLast(media); + } else { + break; + } + } + + if (!leftIsRecent) { + Collections.reverse(rail); + } + + previewData.postValue(new PreviewData(rail.size() > 1 ? rail : Collections.emptyList(), + activeRecord.getAttachment().getCaption(), + rail.indexOf(activeMedia))); } - if (leftIsRecent) return position; - else return cursor.getCount() - 1 - position; - } - - private @Nullable Media toMedia(@NonNull MediaRecord mediaRecord) { - Uri uri = mediaRecord.getAttachment().getThumbnailUri() != null ? mediaRecord.getAttachment().getThumbnailUri() - : mediaRecord.getAttachment().getDataUri(); - - if (uri == null) { - return null; + private int getCursorPosition(int position) { + if (cursor == null) { return 0; } + if (leftIsRecent) return position; + else return cursor.getCount() - 1 - position; } - return new Media(uri, - mediaRecord.getContentType(), - mediaRecord.getDate(), - mediaRecord.getAttachment().getWidth(), - mediaRecord.getAttachment().getHeight(), - mediaRecord.getAttachment().getSize(), - Optional.absent(), - Optional.fromNullable(mediaRecord.getAttachment().getCaption())); - } - - public LiveData getPreviewData() { - return previewData; - } - - public static class PreviewData { - private final List albumThumbnails; - private final String caption; - private final int activePosition; - - public PreviewData(@NonNull List albumThumbnails, @Nullable String caption, int activePosition) { - this.albumThumbnails = albumThumbnails; - this.caption = caption; - this.activePosition = activePosition; + private @Nullable Media toMedia(@NonNull MediaRecord mediaRecord) { + Uri uri = mediaRecord.getAttachment().getThumbnailUri() != null ? mediaRecord.getAttachment().getThumbnailUri() + : mediaRecord.getAttachment().getDataUri(); + + if (uri == null) { + Log.w("MediaPreviewViewModel", "MediaPreviewViewModel cannot construct Media from a null Uri - bailing."); + return null; + } + + String filename = mediaRecord.getAttachment().getFilename(); + if (filename == null || filename.isEmpty()) { filename = FilenameUtils.getFilenameFromUri(context, uri); } + + return new Media(uri, + filename, + mediaRecord.getContentType(), + mediaRecord.getDate(), + mediaRecord.getAttachment().getWidth(), + mediaRecord.getAttachment().getHeight(), + mediaRecord.getAttachment().getSize(), + null, + mediaRecord.getAttachment().getCaption() + ); } - public @NonNull List getAlbumThumbnails() { - return albumThumbnails; + public LiveData getPreviewData() { + return previewData; } - public @Nullable String getCaption() { - return caption; - } + public static class PreviewData { + private final List albumThumbnails; + private final String caption; + private final int activePosition; + + public PreviewData(@NonNull List albumThumbnails, @Nullable String caption, int activePosition) { + this.albumThumbnails = albumThumbnails; + this.caption = caption; + this.activePosition = activePosition; + } + + public @NonNull List getAlbumThumbnails() { + return albumThumbnails; + } + + public @Nullable String getCaption() { + return caption; + } - public int getActivePosition() { - return activePosition; + public int getActivePosition() { + return activePosition; + } } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java index e695274847..c4990fbdeb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java @@ -138,14 +138,12 @@ static class MediaViewHolder extends MediaRailViewHolder { private final ThumbnailView image; private final View outline; private final View deleteButton; - private final View captionIndicator; MediaViewHolder(@NonNull View itemView) { super(itemView); image = itemView.findViewById(R.id.rail_item_image); outline = itemView.findViewById(R.id.rail_item_outline); deleteButton = itemView.findViewById(R.id.rail_item_delete); - captionIndicator = itemView.findViewById(R.id.rail_item_caption); } void bind(@NonNull Media media, boolean isActive, @NonNull RequestManager glideRequests, @@ -158,8 +156,6 @@ void bind(@NonNull Media media, boolean isActive, @NonNull RequestManager glideR outline.setVisibility(isActive ? View.VISIBLE : View.GONE); - captionIndicator.setVisibility(media.getCaption().isPresent() ? View.VISIBLE : View.GONE); - if (editable && isActive) { deleteButton.setVisibility(View.VISIBLE); deleteButton.setOnClickListener(v -> railItemListener.onRailItemDeleteClicked(distanceFromActive)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Controller.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Controller.java deleted file mode 100644 index 2c14ed8d5c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Controller.java +++ /dev/null @@ -1,259 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.graphics.SurfaceTexture; -import android.hardware.Camera; -import androidx.annotation.NonNull; -import android.view.Surface; - -import org.session.libsignal.utilities.Log; - -import java.util.Collections; -import java.util.Comparator; -import java.util.List; - -class Camera1Controller { - - private static final String TAG = Camera1Controller.class.getSimpleName(); - - private final int screenWidth; - private final int screenHeight; - private final OrderEnforcer enforcer; - private final EventListener eventListener; - - private Camera camera; - private int cameraId; - private SurfaceTexture previewSurface; - private int screenRotation; - - Camera1Controller(int preferredDirection, int screenWidth, int screenHeight, @NonNull EventListener eventListener) { - this.eventListener = eventListener; - this.enforcer = new OrderEnforcer<>(Stage.INITIALIZED, Stage.PREVIEW_STARTED); - this.cameraId = Camera.getNumberOfCameras() > 1 ? preferredDirection : Camera.CameraInfo.CAMERA_FACING_BACK; - this.screenWidth = screenWidth; - this.screenHeight = screenHeight; - } - - void initialize() { - Log.d(TAG, "initialize()"); - - if (Camera.getNumberOfCameras() <= 0) { - Log.w(TAG, "Device doesn't have any cameras."); - onCameraUnavailable(); - return; - } - - try { - camera = Camera.open(cameraId); - } catch (Exception e) { - Log.w(TAG, "Failed to open camera.", e); - onCameraUnavailable(); - return; - } - - if (camera == null) { - Log.w(TAG, "Null camera instance."); - onCameraUnavailable(); - return; - } - - Camera.Parameters params = camera.getParameters(); - Camera.Size previewSize = getClosestSize(camera.getParameters().getSupportedPreviewSizes(), screenWidth, screenHeight); - Camera.Size pictureSize = getClosestSize(camera.getParameters().getSupportedPictureSizes(), screenWidth, screenHeight); - final List focusModes = params.getSupportedFocusModes(); - - Log.d(TAG, "Preview size: " + previewSize.width + "x" + previewSize.height + " Picture size: " + pictureSize.width + "x" + pictureSize.height); - - params.setPreviewSize(previewSize.width, previewSize.height); - params.setPictureSize(pictureSize.width, pictureSize.height); - params.setFlashMode(Camera.Parameters.FLASH_MODE_OFF); - params.setColorEffect(Camera.Parameters.EFFECT_NONE); - params.setWhiteBalance(Camera.Parameters.WHITE_BALANCE_AUTO); - - if (focusModes.contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) { - params.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE); - } else if (focusModes.contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO)) { - params.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO); - } - - - camera.setParameters(params); - - enforcer.markCompleted(Stage.INITIALIZED); - - eventListener.onPropertiesAvailable(getProperties()); - } - - void release() { - Log.d(TAG, "release() called"); - enforcer.run(Stage.INITIALIZED, () -> { - Log.d(TAG, "release() executing"); - previewSurface = null; - camera.stopPreview(); - camera.release(); - enforcer.reset(); - }); - } - - void linkSurface(@NonNull SurfaceTexture surfaceTexture) { - Log.d(TAG, "linkSurface() called"); - enforcer.run(Stage.INITIALIZED, () -> { - try { - Log.d(TAG, "linkSurface() executing"); - previewSurface = surfaceTexture; - - camera.setPreviewTexture(surfaceTexture); - camera.startPreview(); - enforcer.markCompleted(Stage.PREVIEW_STARTED); - } catch (Exception e) { - Log.w(TAG, "Failed to start preview.", e); - eventListener.onCameraUnavailable(); - } - }); - } - - void capture(@NonNull CaptureCallback callback) { - enforcer.run(Stage.PREVIEW_STARTED, () -> { - camera.takePicture(null, null, null, (data, camera) -> { - callback.onCaptureAvailable(data, cameraId == Camera.CameraInfo.CAMERA_FACING_FRONT); - }); - }); - } - - int flip() { - Log.d(TAG, "flip()"); - SurfaceTexture surfaceTexture = previewSurface; - cameraId = (cameraId == Camera.CameraInfo.CAMERA_FACING_BACK) ? Camera.CameraInfo.CAMERA_FACING_FRONT : Camera.CameraInfo.CAMERA_FACING_BACK; - - release(); - initialize(); - linkSurface(surfaceTexture); - setScreenRotation(screenRotation); - - return cameraId; - } - - void setScreenRotation(int screenRotation) { - Log.d(TAG, "setScreenRotation(" + screenRotation + ") called"); - enforcer.run(Stage.PREVIEW_STARTED, () -> { - Log.d(TAG, "setScreenRotation(" + screenRotation + ") executing"); - this.screenRotation = screenRotation; - - int previewRotation = getPreviewRotation(screenRotation); - int outputRotation = getOutputRotation(screenRotation); - - Log.d(TAG, "Preview rotation: " + previewRotation + " Output rotation: " + outputRotation); - - camera.setDisplayOrientation(previewRotation); - - Camera.Parameters params = camera.getParameters(); - params.setRotation(outputRotation); - camera.setParameters(params); - }); - } - - private void onCameraUnavailable() { - enforcer.reset(); - eventListener.onCameraUnavailable(); - } - - private Properties getProperties() { - Camera.Size previewSize = camera.getParameters().getPreviewSize(); - return new Properties(Camera.getNumberOfCameras(), previewSize.width, previewSize.height); - } - - private Camera.Size getClosestSize(List sizes, int width, int height) { - Collections.sort(sizes, ASC_SIZE_COMPARATOR); - - int i = 0; - while (i < sizes.size() && (sizes.get(i).width * sizes.get(i).height) < (width * height)) { - i++; - } - - return sizes.get(Math.min(i, sizes.size() - 1)); - } - - private int getOutputRotation(int displayRotationCode) { - int degrees = convertRotationToDegrees(displayRotationCode); - - Camera.CameraInfo info = new Camera.CameraInfo(); - Camera.getCameraInfo(cameraId, info); - - if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { - return (info.orientation + degrees) % 360; - } else { - return (info.orientation - degrees + 360) % 360; - } - } - - private int getPreviewRotation(int displayRotationCode) { - int degrees = convertRotationToDegrees(displayRotationCode); - - Camera.CameraInfo info = new Camera.CameraInfo(); - Camera.getCameraInfo(cameraId, info); - - int result; - if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { - result = (info.orientation + degrees) % 360; - result = (360 - result) % 360; - } else { - result = (info.orientation - degrees + 360) % 360; - } - - return result; - } - - private int convertRotationToDegrees(int screenRotation) { - switch (screenRotation) { - case Surface.ROTATION_0: return 0; - case Surface.ROTATION_90: return 90; - case Surface.ROTATION_180: return 180; - case Surface.ROTATION_270: return 270; - } - return 0; - } - - private final Comparator ASC_SIZE_COMPARATOR = (o1, o2) -> Integer.compare(o1.width * o1.height, o2.width * o2.height); - - private enum Stage { - INITIALIZED, PREVIEW_STARTED - } - - class Properties { - - private final int cameraCount; - private final int previewWidth; - private final int previewHeight; - - Properties(int cameraCount, int previewWidth, int previewHeight) { - this.cameraCount = cameraCount; - this.previewWidth = previewWidth; - this.previewHeight = previewHeight; - } - - int getCameraCount() { - return cameraCount; - } - - int getPreviewWidth() { - return previewWidth; - } - - int getPreviewHeight() { - return previewHeight; - } - - @Override - public @NonNull String toString() { - return "cameraCount: " + cameraCount + " previewWidth: " + previewWidth + " previewHeight: " + previewHeight; - } - } - - interface EventListener { - void onPropertiesAvailable(@NonNull Properties properties); - void onCameraUnavailable(); - } - - interface CaptureCallback { - void onCaptureAvailable(@NonNull byte[] jpegData, boolean frontFacing); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java deleted file mode 100644 index 3ef7090c30..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java +++ /dev/null @@ -1,333 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.annotation.SuppressLint; -import androidx.lifecycle.ViewModelProvider; -import android.content.res.Configuration; -import android.graphics.Bitmap; -import android.graphics.Matrix; -import android.graphics.Point; -import android.graphics.PointF; -import android.graphics.SurfaceTexture; -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import android.view.Display; -import android.view.GestureDetector; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.TextureView; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowManager; -import android.view.animation.Animation; -import android.view.animation.AnimationUtils; -import android.view.animation.DecelerateInterpolator; -import android.view.animation.RotateAnimation; -import android.widget.Button; -import android.widget.ImageButton; - -import com.bumptech.glide.load.MultiTransformation; -import com.bumptech.glide.load.Transformation; -import com.bumptech.glide.load.resource.bitmap.CenterCrop; -import com.bumptech.glide.request.target.SimpleTarget; -import com.bumptech.glide.request.transition.Transition; - -import network.loki.messenger.R; -import org.session.libsignal.utilities.Log; -import com.bumptech.glide.Glide; -import org.session.libsession.utilities.ServiceUtil; -import org.thoughtcrime.securesms.util.Stopwatch; -import org.session.libsession.utilities.TextSecurePreferences; - -import java.io.ByteArrayOutputStream; - -public class Camera1Fragment extends Fragment implements TextureView.SurfaceTextureListener, - Camera1Controller.EventListener -{ - - private static final String TAG = Camera1Fragment.class.getSimpleName(); - - private TextureView cameraPreview; - private ViewGroup controlsContainer; - private View cameraCloseButton; - private ImageButton flipButton; - private Button captureButton; - private Camera1Controller camera; - private Controller controller; - private OrderEnforcer orderEnforcer; - private Camera1Controller.Properties properties; - private MediaSendViewModel viewModel; - - public static Camera1Fragment newInstance() { - return new Camera1Fragment(); - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (!(getActivity() instanceof Controller)) { - throw new IllegalStateException("Parent activity must implement the Controller interface."); - } - - WindowManager windowManager = ServiceUtil.getWindowManager(getActivity()); - Display display = windowManager.getDefaultDisplay(); - Point displaySize = new Point(); - - display.getSize(displaySize); - - controller = (Controller) getActivity(); - camera = new Camera1Controller(TextSecurePreferences.getDirectCaptureCameraId(getContext()), displaySize.x, displaySize.y, this); - orderEnforcer = new OrderEnforcer<>(Stage.SURFACE_AVAILABLE, Stage.CAMERA_PROPERTIES_AVAILABLE); - viewModel = new ViewModelProvider(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.camera_fragment, container, false); - } - - @SuppressLint("ClickableViewAccessibility") - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - cameraPreview = view.findViewById(R.id.camera_preview); - controlsContainer = view.findViewById(R.id.camera_controls_container); - cameraCloseButton = view.findViewById(R.id.camera_close_button); - - onOrientationChanged(getResources().getConfiguration().orientation); - - cameraPreview.setSurfaceTextureListener(this); - - GestureDetector gestureDetector = new GestureDetector(flipGestureListener); - cameraPreview.setOnTouchListener((v, event) -> gestureDetector.onTouchEvent(event)); - - cameraCloseButton.setOnClickListener(v -> requireActivity().onBackPressed()); - } - - @Override - public void onResume() { - super.onResume(); - viewModel.onCameraStarted(); - camera.initialize(); - - if (cameraPreview.isAvailable()) { - orderEnforcer.markCompleted(Stage.SURFACE_AVAILABLE); - } - - if (properties != null) { - orderEnforcer.markCompleted(Stage.CAMERA_PROPERTIES_AVAILABLE); - } - - orderEnforcer.run(Stage.SURFACE_AVAILABLE, () -> { - camera.linkSurface(cameraPreview.getSurfaceTexture()); - camera.setScreenRotation(controller.getDisplayRotation()); - }); - - orderEnforcer.run(Stage.CAMERA_PROPERTIES_AVAILABLE, this::updatePreviewScale); - - requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); - } - - @Override - public void onPause() { - super.onPause(); - camera.release(); - orderEnforcer.reset(); - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - onOrientationChanged(newConfig.orientation); - } - - @Override - public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { - Log.d(TAG, "onSurfaceTextureAvailable"); - orderEnforcer.markCompleted(Stage.SURFACE_AVAILABLE); - } - - @Override - public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { - orderEnforcer.run(Stage.SURFACE_AVAILABLE, () -> camera.setScreenRotation(controller.getDisplayRotation())); - orderEnforcer.run(Stage.CAMERA_PROPERTIES_AVAILABLE, this::updatePreviewScale); - } - - @Override - public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { - return false; - } - - @Override - public void onSurfaceTextureUpdated(SurfaceTexture surface) { - } - - @Override - public void onPropertiesAvailable(@NonNull Camera1Controller.Properties properties) { - Log.d(TAG, "Got camera properties: " + properties); - this.properties = properties; - orderEnforcer.run(Stage.CAMERA_PROPERTIES_AVAILABLE, this::updatePreviewScale); - orderEnforcer.markCompleted(Stage.CAMERA_PROPERTIES_AVAILABLE); - } - - @Override - public void onCameraUnavailable() { - controller.onCameraError(); - } - - @SuppressLint("ClickableViewAccessibility") - private void initControls() { - flipButton = getView().findViewById(R.id.camera_flip_button); - captureButton = getView().findViewById(R.id.camera_capture_button); - - captureButton.setOnTouchListener((v, event) -> { - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - Animation shrinkAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.camera_capture_button_shrink); - shrinkAnimation.setFillAfter(true); - shrinkAnimation.setFillEnabled(true); - captureButton.startAnimation(shrinkAnimation); - onCaptureClicked(); - break; - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - case MotionEvent.ACTION_OUTSIDE: - Animation growAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.camera_capture_button_grow); - growAnimation.setFillAfter(true); - growAnimation.setFillEnabled(true); - captureButton.startAnimation(growAnimation); - captureButton.setEnabled(false); - break; - } - return true; - }); - - orderEnforcer.run(Stage.CAMERA_PROPERTIES_AVAILABLE, () -> { - if (properties.getCameraCount() > 1) { - flipButton.setVisibility(properties.getCameraCount() > 1 ? View.VISIBLE : View.GONE); - flipButton.setOnClickListener(v -> { - int newCameraId = camera.flip(); - TextSecurePreferences.setDirectCaptureCameraId(getContext(), newCameraId); - - Animation animation = new RotateAnimation(0, -180, RotateAnimation.RELATIVE_TO_SELF, 0.5f, RotateAnimation.RELATIVE_TO_SELF, 0.5f); - animation.setDuration(200); - animation.setInterpolator(new DecelerateInterpolator()); - flipButton.startAnimation(animation); - }); - } else { - flipButton.setVisibility(View.GONE); - } - }); - } - - private void onCaptureClicked() { - orderEnforcer.reset(); - - Stopwatch fastCaptureTimer = new Stopwatch("Capture"); - - camera.capture((jpegData, frontFacing) -> { - fastCaptureTimer.split("captured"); - - Transformation transformation = frontFacing ? new MultiTransformation<>(new CenterCrop(), new FlipTransformation()) - : new CenterCrop(); - - Glide.with(this) - .asBitmap() - .load(jpegData) - .transform(transformation) - .override(cameraPreview.getWidth(), cameraPreview.getHeight()) - .into(new SimpleTarget() { - @Override - public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition transition) { - fastCaptureTimer.split("transform"); - - ByteArrayOutputStream stream = new ByteArrayOutputStream(); - resource.compress(Bitmap.CompressFormat.JPEG, 80, stream); - fastCaptureTimer.split("compressed"); - - byte[] data = stream.toByteArray(); - fastCaptureTimer.split("bytes"); - fastCaptureTimer.stop(TAG); - - controller.onImageCaptured(data, resource.getWidth(), resource.getHeight()); - } - - @Override - public void onLoadFailed(@Nullable Drawable errorDrawable) { - controller.onCameraError(); - } - }); - }); - } - - private PointF getScaleTransform(float viewWidth, float viewHeight, int cameraWidth, int cameraHeight) { - float camWidth = isPortrait() ? Math.min(cameraWidth, cameraHeight) : Math.max(cameraWidth, cameraHeight); - float camHeight = isPortrait() ? Math.max(cameraWidth, cameraHeight) : Math.min(cameraWidth, cameraHeight); - - float scaleX = 1; - float scaleY = 1; - - if ((camWidth / viewWidth) > (camHeight / viewHeight)) { - float targetWidth = viewHeight * (camWidth / camHeight); - scaleX = targetWidth / viewWidth; - } else { - float targetHeight = viewWidth * (camHeight / camWidth); - scaleY = targetHeight / viewHeight; - } - - return new PointF(scaleX, scaleY); - } - - private void onOrientationChanged(int orientation) { - int layout = orientation == Configuration.ORIENTATION_PORTRAIT ? R.layout.camera_controls_portrait - : R.layout.camera_controls_landscape; - - controlsContainer.removeAllViews(); - controlsContainer.addView(LayoutInflater.from(getContext()).inflate(layout, controlsContainer, false)); - initControls(); - } - - private void updatePreviewScale() { - PointF scale = getScaleTransform(cameraPreview.getWidth(), cameraPreview.getHeight(), properties.getPreviewWidth(), properties.getPreviewHeight()); - Matrix matrix = new Matrix(); - - float camWidth = isPortrait() ? Math.min(cameraPreview.getWidth(), cameraPreview.getHeight()) : Math.max(cameraPreview.getWidth(), cameraPreview.getHeight()); - float camHeight = isPortrait() ? Math.max(cameraPreview.getWidth(), cameraPreview.getHeight()) : Math.min(cameraPreview.getWidth(), cameraPreview.getHeight()); - - matrix.setScale(scale.x, scale.y); - matrix.postTranslate((camWidth - (camWidth * scale.x)) / 2, (camHeight - (camHeight * scale.y)) / 2); - cameraPreview.setTransform(matrix); - } - - private boolean isPortrait() { - return getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT; - } - - private final GestureDetector.OnGestureListener flipGestureListener = new GestureDetector.SimpleOnGestureListener() { - @Override - public boolean onDown(MotionEvent e) { - return true; - } - - @Override - public boolean onDoubleTap(MotionEvent e) { - flipButton.performClick(); - return true; - } - }; - - public interface Controller { - void onCameraError(); - void onImageCaptured(@NonNull byte[] data, int width, int height); - int getDisplayRotation(); - } - - private enum Stage { - SURFACE_AVAILABLE, CAMERA_PROPERTIES_AVAILABLE - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt new file mode 100644 index 0000000000..6faff31f88 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt @@ -0,0 +1,278 @@ +package org.thoughtcrime.securesms.mediasend + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.util.Size +import android.view.LayoutInflater +import android.view.OrientationEventListener +import android.view.Surface +import android.view.View +import android.view.ViewGroup +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.ImageProxy +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy +import androidx.camera.view.LifecycleCameraController +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.databinding.CameraxFragmentBinding +import org.session.libsession.utilities.MediaTypes +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.providers.BlobUtils +import org.thoughtcrime.securesms.util.applySafeInsetsMargins +import org.thoughtcrime.securesms.util.setSafeOnClickListener +import java.io.ByteArrayOutputStream +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import javax.inject.Inject + +@AndroidEntryPoint +class CameraXFragment : Fragment() { + + interface Controller { + fun onImageCaptured(imageUri: Uri, size: Long, width: Int, height: Int) + fun onCameraError() + } + + private lateinit var binding: CameraxFragmentBinding + + private var callbacks: Controller? = null + + private lateinit var cameraController: LifecycleCameraController + private lateinit var cameraExecutor: ExecutorService + + + private lateinit var orientationListener: OrientationEventListener + private var lastRotation: Int = Surface.ROTATION_0 + + @Inject + lateinit var prefs: TextSecurePreferences + + companion object { + private const val TAG = "CameraXFragment" + private const val REQUEST_CODE_PERMISSIONS = 10 + private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + binding = CameraxFragmentBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + cameraExecutor = Executors.newSingleThreadExecutor() + + // permissions should be handled prior to landing in this fragment + // but this is added for safety + if (allPermissionsGranted()) { + startCamera() + } else { + ActivityCompat.requestPermissions( + requireActivity(), REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS + ) + } + + binding.cameraControlsSafeArea.applySafeInsetsMargins() + + binding.cameraCaptureButton.setSafeOnClickListener { takePhoto() } + binding.cameraFlipButton.setSafeOnClickListener { flipCamera() } + binding.cameraCloseButton.setSafeOnClickListener { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + + // keep track of orientation changes + orientationListener = object : OrientationEventListener(requireContext()) { + override fun onOrientationChanged(degrees: Int) { + if (degrees == ORIENTATION_UNKNOWN) return + + val newRotation = when { + degrees in 45..134 -> Surface.ROTATION_270 + degrees in 135..224 -> Surface.ROTATION_180 + degrees in 225..314 -> Surface.ROTATION_90 + else -> Surface.ROTATION_0 + } + + if (newRotation != lastRotation) { + lastRotation = newRotation + updateUiForRotation(newRotation) + } + } + } + } + + override fun onResume() { + super.onResume() + orientationListener.enable() + } + + override fun onPause() { + orientationListener.disable() + super.onPause() + } + + override fun onAttach(context: Context) { + super.onAttach(context) + if (context is Controller) { + callbacks = context + } else { + throw RuntimeException("$context must implement CameraXFragment.Controller") + } + } + + private fun updateUiForRotation(rotation: Int = lastRotation) { + val angle = when (rotation) { + Surface.ROTATION_0 -> 0f + Surface.ROTATION_90 -> 90f + Surface.ROTATION_180 -> 180f + else -> 270f + } + + binding.cameraFlipButton.animate() + .rotation(angle) + .setDuration(150) + .start() + } + + private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all { + ContextCompat.checkSelfPermission( + requireContext(), it + ) == PackageManager.PERMISSION_GRANTED + } + + private fun startCamera() { + // work out a resolution based on available memory + val activityManager = requireContext().getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager + val memoryClassMb = activityManager.memoryClass // e.g. 128, 256, etc. + val preferredResolution: Size = when { + memoryClassMb >= 256 -> Size(1920, 1440) + memoryClassMb >= 128 -> Size(1280, 960) + else -> Size(640, 480) + } + Log.d(TAG, "Selected resolution: $preferredResolution based on memory class: $memoryClassMb MB") + + val resolutionSelector = ResolutionSelector.Builder() + .setResolutionStrategy( + ResolutionStrategy( + preferredResolution, + ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER + ) + ) + .build() + + // set up camera + cameraController = LifecycleCameraController(requireContext()).apply { + cameraSelector = prefs.getPreferredCameraDirection() + setImageCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) + setTapToFocusEnabled(true) + setPinchToZoomEnabled(true) + + // Configure image capture resolution + setImageCaptureResolutionSelector(resolutionSelector) + } + + // attach it to the view + binding.previewView.controller = cameraController + cameraController.bindToLifecycle(viewLifecycleOwner) + + // wait for initialisation to complete + cameraController.initializationFuture.addListener({ + val hasFront = cameraController.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA) + val hasBack = cameraController.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA) + + binding.cameraFlipButton.visibility = + if (hasFront && hasBack) View.VISIBLE else View.GONE + }, ContextCompat.getMainExecutor(requireContext())) + } + + private fun takePhoto() { + val isFrontCamera = cameraController.cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA + cameraController.takePicture( + cameraExecutor, + object : ImageCapture.OnImageCapturedCallback() { + override fun onCaptureSuccess(img: ImageProxy) { + try { + val buffer = img.planes[0].buffer + val originalBytes = ByteArray(buffer.remaining()).also { buffer.get(it) } + val rotationDegrees = img.imageInfo.rotationDegrees + img.close() + + // Decode, rotate, mirror if needed + val bitmap = BitmapFactory.decodeByteArray(originalBytes, 0, originalBytes.size) + var correctedBitmap = rotateBitmap(bitmap, rotationDegrees.toFloat()) + if (isFrontCamera) { + correctedBitmap = mirrorBitmap(correctedBitmap) + } + + val outputStream = ByteArrayOutputStream() + correctedBitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) + val compressedBytes = outputStream.toByteArray() + + // Recycle bitmaps + bitmap.recycle() + if (correctedBitmap !== bitmap) correctedBitmap.recycle() + + val uri = BlobUtils.getInstance() + .forData(compressedBytes) + .withMimeType(MediaTypes.IMAGE_JPEG) + .createForSingleSessionInMemory() + + callbacks?.onImageCaptured(uri, compressedBytes.size.toLong(), correctedBitmap.width, correctedBitmap.height) + } catch (t: Throwable) { + Log.e(TAG, "capture failed", t) + callbacks?.onCameraError() + } + } + override fun onError(e: ImageCaptureException) { + Log.e(TAG, "takePicture error", e) + callbacks?.onCameraError() + } + } + ) + } + + private fun mirrorBitmap(src: Bitmap): Bitmap { + val matrix = android.graphics.Matrix().apply { preScale(-1f, 1f) } + return Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true) + } + + private fun rotateBitmap(src: Bitmap, degrees: Float): Bitmap { + if (degrees == 0f) return src + val matrix = android.graphics.Matrix().apply { postRotate(degrees) } + return Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true) + } + + private fun flipCamera() { + val newSelector = + if (cameraController.cameraSelector == CameraSelector.DEFAULT_BACK_CAMERA) + CameraSelector.DEFAULT_FRONT_CAMERA + else + CameraSelector.DEFAULT_BACK_CAMERA + + cameraController.cameraSelector = newSelector + prefs.setPreferredCameraDirection(newSelector) + + // animate icon + binding.cameraFlipButton.animate() + .rotationBy(-180f) + .setDuration(200) + .start() + } + + override fun onDestroyView() { + cameraExecutor.shutdown() + super.onDestroyView() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.java deleted file mode 100644 index 4ae5194f85..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.java +++ /dev/null @@ -1,128 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.net.Uri; -import android.os.Parcel; -import android.os.Parcelable; -import androidx.annotation.NonNull; - -import org.session.libsignal.utilities.guava.Optional; - -/** - * Represents a piece of media that the user has on their device. - */ -public class Media implements Parcelable { - - public static final String ALL_MEDIA_BUCKET_ID = "org.thoughtcrime.securesms.ALL_MEDIA"; - - private final Uri uri; - private final String mimeType; - private final long date; - private final int width; - private final int height; - private final long size; - - private Optional bucketId; - private Optional caption; - - public Media(@NonNull Uri uri, @NonNull String mimeType, long date, int width, int height, long size, Optional bucketId, Optional caption) { - this.uri = uri; - this.mimeType = mimeType; - this.date = date; - this.width = width; - this.height = height; - this.size = size; - this.bucketId = bucketId; - this.caption = caption; - } - - protected Media(Parcel in) { - uri = in.readParcelable(Uri.class.getClassLoader()); - mimeType = in.readString(); - date = in.readLong(); - width = in.readInt(); - height = in.readInt(); - size = in.readLong(); - bucketId = Optional.fromNullable(in.readString()); - caption = Optional.fromNullable(in.readString()); - } - - public Uri getUri() { - return uri; - } - - public String getMimeType() { - return mimeType; - } - - public long getDate() { - return date; - } - - public int getWidth() { - return width; - } - - public int getHeight() { - return height; - } - - public long getSize() { - return size; - } - - public Optional getBucketId() { - return bucketId; - } - - public Optional getCaption() { - return caption; - } - - public void setCaption(String caption) { - this.caption = Optional.fromNullable(caption); - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeParcelable(uri, flags); - dest.writeString(mimeType); - dest.writeLong(date); - dest.writeInt(width); - dest.writeInt(height); - dest.writeLong(size); - dest.writeString(bucketId.orNull()); - dest.writeString(caption.orNull()); - } - - public static final Creator CREATOR = new Creator() { - @Override - public Media createFromParcel(Parcel in) { - return new Media(in); - } - - @Override - public Media[] newArray(int size) { - return new Media[size]; - } - }; - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - Media media = (Media) o; - - return uri.equals(media.uri); - } - - @Override - public int hashCode() { - return uri.hashCode(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.kt new file mode 100644 index 0000000000..c72ced5375 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Media.kt @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.mediasend + +import android.net.Uri +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Represents a piece of media that the user has on their device. + */ +@Parcelize +data class Media( + val uri: Uri, + val filename: String, + val mimeType: String, + val date: Long, + val width: Int, + val height: Int, + val size: Long, + val bucketId: String?, + val caption: String?, +) : Parcelable { + + // The equality check here is performed based only on the URI of the media. + // This behavior very opinionated and shouldn't really be in a generic equality check in the first place. + // However there are too much code working under this assumption and we can't simply change it to + // a generic solution. + // + // To later dev: once sufficient refactors are done, we can remove this equality + // check and rely on the data class default equality check instead. + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Media) return false + + if (uri != other.uri) return false + + return true + } + + override fun hashCode(): Int { + return uri.hashCode() + } + + + companion object { + const val ALL_MEDIA_BUCKET_ID: String = "org.thoughtcrime.securesms.ALL_MEDIA" + } + + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java index 82d6b93085..f04b69e56d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java @@ -27,12 +27,15 @@ import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.guava.Optional; +import org.thoughtcrime.securesms.util.ViewUtilitiesKt; +import dagger.hilt.android.AndroidEntryPoint; import network.loki.messenger.R; /** * Allows the user to select a media folder to explore. */ +@AndroidEntryPoint public class MediaPickerFolderFragment extends Fragment implements MediaPickerFolderAdapter.EventListener { private static final String KEY_RECIPIENT_NAME = "recipient_name"; @@ -45,7 +48,7 @@ public class MediaPickerFolderFragment extends Fragment implements MediaPickerFo public static @NonNull MediaPickerFolderFragment newInstance(@NonNull Recipient recipient) { String name = Optional.fromNullable(recipient.getName()) .or(Optional.fromNullable(recipient.getProfileName())) - .or(recipient.toShortString()); + .or(recipient.getName()); Bundle args = new Bundle(); args.putString(KEY_RECIPIENT_NAME, name); @@ -60,7 +63,7 @@ public class MediaPickerFolderFragment extends Fragment implements MediaPickerFo public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); recipientName = getArguments().getString(KEY_RECIPIENT_NAME); - viewModel = new ViewModelProvider(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); + viewModel = new ViewModelProvider(requireActivity()).get(MediaSendViewModel.class); } @Override @@ -83,6 +86,8 @@ public void onAttach(Context context) { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); + ViewUtilitiesKt.applySafeInsetsPaddings(view); + RecyclerView list = view.findViewById(R.id.mediapicker_folder_list); MediaPickerFolderAdapter adapter = new MediaPickerFolderAdapter(Glide.with(this), this); @@ -102,8 +107,6 @@ public void onResume() { super.onResume(); viewModel.onFolderPickerStarted(); - requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); - requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java index b1c104e32e..6b0055d633 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java @@ -23,15 +23,18 @@ import com.bumptech.glide.Glide; import org.session.libsession.utilities.Util; +import org.thoughtcrime.securesms.util.ViewUtilitiesKt; import java.util.ArrayList; import java.util.List; +import dagger.hilt.android.AndroidEntryPoint; import network.loki.messenger.R; /** * Allows the user to select a set of media items from a specified folder. */ +@AndroidEntryPoint public class MediaPickerItemFragment extends Fragment implements MediaPickerItemAdapter.EventListener { private static final String KEY_BUCKET_ID = "bucket_id"; @@ -66,7 +69,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) { bucketId = getArguments().getString(KEY_BUCKET_ID); folderTitle = getArguments().getString(KEY_FOLDER_TITLE); maxSelection = getArguments().getInt(KEY_MAX_SELECTION); - viewModel = new ViewModelProvider(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); + viewModel = new ViewModelProvider(requireActivity()).get(MediaSendViewModel.class); } @Override @@ -89,6 +92,8 @@ public void onAttach(Context context) { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); + ViewUtilitiesKt.applySafeInsetsPaddings(view); + RecyclerView imageList = view.findViewById(R.id.mediapicker_item_list); adapter = new MediaPickerItemAdapter(Glide.with(this), this, maxSelection); @@ -115,8 +120,6 @@ public void onResume() { super.onResume(); viewModel.onItemPickerStarted(); - requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); - requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java index f74910f870..66b491042d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.mediasend; +import android.content.ContentUris; import android.content.Context; import android.database.Cursor; import android.net.Uri; @@ -8,28 +9,20 @@ import android.provider.MediaStore.Video; import android.provider.OpenableColumns; import android.util.Pair; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; - import com.annimon.stream.Stream; - -import org.session.libsession.utilities.Util; import org.session.libsignal.utilities.guava.Optional; import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.util.MediaUtil; - -import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; -import java.util.Comparator; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; - import network.loki.messenger.R; /** @@ -37,299 +30,340 @@ */ class MediaRepository { - /** - * Retrieves a list of folders that contain media. - */ - void getFolders(@NonNull Context context, @NonNull Callback> callback) { - AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> callback.onComplete(getFolders(context))); - } - - /** - * Retrieves a list of media items (images and videos) that are present int he specified bucket. - */ - void getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Callback> callback) { - AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> callback.onComplete(getMediaInBucket(context, bucketId))); - } - - /** - * Given an existing list of {@link Media}, this will ensure that the media is populate with as - * much data as we have, like width/height. - */ - void getPopulatedMedia(@NonNull Context context, @NonNull List media, @NonNull Callback> callback) { - if (Stream.of(media).allMatch(this::isPopulated)) { - callback.onComplete(media); - return; - } - - AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> callback.onComplete(getPopulatedMedia(context, media))); - } - - @WorkerThread - private @NonNull List getFolders(@NonNull Context context) { - FolderResult imageFolders = getFolders(context, Images.Media.EXTERNAL_CONTENT_URI); - FolderResult videoFolders = getFolders(context, Video.Media.EXTERNAL_CONTENT_URI); - Map folders = new HashMap<>(imageFolders.getFolderData()); - - for (Map.Entry entry : videoFolders.getFolderData().entrySet()) { - if (folders.containsKey(entry.getKey())) { - folders.get(entry.getKey()).incrementCount(entry.getValue().getCount()); - } else { - folders.put(entry.getKey(), entry.getValue()); - } + /** + * Retrieves a list of folders that contain media. + */ + void getFolders(@NonNull Context context, @NonNull Callback> callback) { + AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> callback.onComplete(getFolders(context))); } - Comparator folderNameSorter = (Comparator) (first, second) -> { - if (first == null || first.getTitle() == null) return 1; - if (second == null || second.getTitle() == null) return -1; - return first.getTitle().toLowerCase().compareTo(second.getTitle().toLowerCase()); - }; - - List mediaFolders = Stream.of(folders.values()).map(folder -> new MediaFolder(folder.getThumbnail(), - folder.getTitle(), - folder.getCount(), - folder.getBucketId())) - .sorted(folderNameSorter) - .toList(); - - Uri allMediaThumbnail = imageFolders.getThumbnailTimestamp() > videoFolders.getThumbnailTimestamp() ? imageFolders.getThumbnail() : videoFolders.getThumbnail(); - if (allMediaThumbnail != null) { - int allMediaCount = Stream.of(mediaFolders).reduce(0, (count, folder) -> count + folder.getItemCount()); - mediaFolders.add(0, new MediaFolder(allMediaThumbnail, context.getString(R.string.conversationsSettingsAllMedia), allMediaCount, Media.ALL_MEDIA_BUCKET_ID)); + /** + * Retrieves a list of media items (images and videos) that are present int he specified bucket. + */ + void getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Callback> callback) { + AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> callback.onComplete(getMediaInBucket(context, bucketId))); } - return mediaFolders; - } - - @WorkerThread - private @NonNull FolderResult getFolders(@NonNull Context context, @NonNull Uri contentUri) { - Uri globalThumbnail = null; - long thumbnailTimestamp = 0; - Map folders = new HashMap<>(); - - String[] projection = new String[] { Images.Media.DATA, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_TAKEN }; - String selection = Images.Media.DATA + " NOT NULL"; - String sortBy = Images.Media.BUCKET_DISPLAY_NAME + " COLLATE NOCASE ASC, " + Images.Media.DATE_TAKEN + " DESC"; - - try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, null, sortBy)) { - while (cursor != null && cursor.moveToNext()) { - String path = cursor.getString(cursor.getColumnIndexOrThrow(projection[0])); - Uri thumbnail = Uri.fromFile(new File(path)); - String bucketId = cursor.getString(cursor.getColumnIndexOrThrow(projection[1])); - String title = cursor.getString(cursor.getColumnIndexOrThrow(projection[2])); - long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(projection[3])); - FolderData folder = Util.getOrDefault(folders, bucketId, new FolderData(thumbnail, title, bucketId)); - - folder.incrementCount(); - folders.put(bucketId, folder); - - if (timestamp > thumbnailTimestamp) { - globalThumbnail = thumbnail; - thumbnailTimestamp = timestamp; + /** + * Given an existing list of {@link Media}, this will ensure that the media is populate with as + * much data as we have, like width/height. + */ + void getPopulatedMedia(@NonNull Context context, @NonNull List media, @NonNull Callback> callback) { + if (Stream.of(media).allMatch(this::isPopulated)) { + callback.onComplete(media); + return; } - } - } - - return new FolderResult(globalThumbnail, thumbnailTimestamp, folders); - } - @WorkerThread - private @NonNull List getMediaInBucket(@NonNull Context context, @NonNull String bucketId) { - List images = getMediaInBucket(context, bucketId, Images.Media.EXTERNAL_CONTENT_URI, true); - List videos = getMediaInBucket(context, bucketId, Video.Media.EXTERNAL_CONTENT_URI, false); - List media = new ArrayList<>(images.size() + videos.size()); + AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> callback.onComplete(getPopulatedMedia(context, media))); + } - media.addAll(images); - media.addAll(videos); - Collections.sort(media, (o1, o2) -> Long.compare(o2.getDate(), o1.getDate())); + @WorkerThread + private @NonNull List getFolders(@NonNull Context context) { + FolderResult imageFolders = getFolders(context, Images.Media.EXTERNAL_CONTENT_URI); + FolderResult videoFolders = getFolders(context, Video.Media.EXTERNAL_CONTENT_URI); + + // Merge image and video folder data + Map mergedFolders = new HashMap<>(imageFolders.getFolderData()); + for (Map.Entry entry : videoFolders.getFolderData().entrySet()) { + if (mergedFolders.containsKey(entry.getKey())) { + mergedFolders.get(entry.getKey()).incrementCount(entry.getValue().getCount()); + // Also update timestamp if the video has a more recent timestamp. + mergedFolders.get(entry.getKey()).updateTimestamp(entry.getValue().getLatestTimestamp()); + } else { + mergedFolders.put(entry.getKey(), entry.getValue()); + } + } - return media; - } + // Create a list from merged folder data + List folderDataList = new ArrayList<>(mergedFolders.values()); + // Sort folders by their latestTimestamp (most recent first) + Collections.sort(folderDataList, (fd1, fd2) -> Long.compare(fd2.getLatestTimestamp(), fd1.getLatestTimestamp())); - @WorkerThread - private @NonNull List getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Uri contentUri, boolean hasOrientation) { - List media = new LinkedList<>(); - String selection = Images.Media.BUCKET_ID + " = ? AND " + Images.Media.DATA + " NOT NULL"; - String[] selectionArgs = new String[] { bucketId }; - String sortBy = Images.Media.DATE_TAKEN + " DESC"; + List mediaFolders = new ArrayList<>(); + for (FolderData fd : folderDataList) { + if (fd.getTitle() != null) { + mediaFolders.add(new MediaFolder(fd.getThumbnail(), fd.getTitle(), fd.getCount(), fd.getBucketId())); + } + } - String[] projection; + // Determine the global thumbnail from the most recent media across image and video queries + Uri allMediaThumbnail = imageFolders.getThumbnailTimestamp() > videoFolders.getThumbnailTimestamp() + ? imageFolders.getThumbnail() : videoFolders.getThumbnail(); + + if (allMediaThumbnail != null) { + int allMediaCount = 0; + for (MediaFolder folder : mediaFolders) { + allMediaCount += folder.getItemCount(); + } + // Prepend an "All Media" folder + mediaFolders.add(0, new MediaFolder(allMediaThumbnail, context.getString(R.string.conversationsSettingsAllMedia), allMediaCount, Media.ALL_MEDIA_BUCKET_ID)); + } - if (hasOrientation) { - projection = new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.ORIENTATION, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE}; - } else { - projection = new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE}; + return mediaFolders; } + @WorkerThread + private @NonNull FolderResult getFolders(@NonNull Context context, @NonNull Uri contentUri) { + Uri globalThumbnail = null; + long thumbnailTimestamp = 0; + Map folders = new HashMap<>(); + + String[] projection = new String[] { + Images.Media._ID, + Images.Media.BUCKET_ID, + Images.Media.BUCKET_DISPLAY_NAME, + Images.Media.DATE_MODIFIED + }; + + String selection = null; + String sortBy = Images.Media.BUCKET_DISPLAY_NAME + " COLLATE NOCASE ASC, " + + Images.Media.DATE_MODIFIED + " DESC"; + + try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, null, sortBy)) { + if (cursor != null) { + int idIndex = cursor.getColumnIndexOrThrow(Images.Media._ID); + int bucketIdIndex = cursor.getColumnIndexOrThrow(Images.Media.BUCKET_ID); + int bucketDisplayNameIndex = cursor.getColumnIndexOrThrow(Images.Media.BUCKET_DISPLAY_NAME); + int dateIndex = cursor.getColumnIndexOrThrow(Images.Media.DATE_MODIFIED); + + while (cursor.moveToNext()) { + long rowId = cursor.getLong(idIndex); + Uri thumbnail = ContentUris.withAppendedId(contentUri, rowId); + String bucketId = cursor.getString(bucketIdIndex); + String title = cursor.getString(bucketDisplayNameIndex); + long timestamp = cursor.getLong(dateIndex); + + FolderData folder = folders.get(bucketId); + if (folder == null) { + folder = new FolderData(thumbnail, title, bucketId); + folders.put(bucketId, folder); + } + folder.incrementCount(); + folder.updateTimestamp(timestamp); + + if (timestamp > thumbnailTimestamp) { + globalThumbnail = thumbnail; + thumbnailTimestamp = timestamp; + } + } + } + } - if (Media.ALL_MEDIA_BUCKET_ID.equals(bucketId)) { - selection = Images.Media.DATA + " NOT NULL"; - selectionArgs = null; + return new FolderResult(globalThumbnail, thumbnailTimestamp, folders); } + @WorkerThread + private @NonNull List getMediaInBucket(@NonNull Context context, @NonNull String bucketId) { + List images = getMediaInBucket(context, bucketId, Images.Media.EXTERNAL_CONTENT_URI, true); + List videos = getMediaInBucket(context, bucketId, Video.Media.EXTERNAL_CONTENT_URI, false); + List media = new ArrayList<>(images.size() + videos.size()); - try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, selectionArgs, sortBy)) { - while (cursor != null && cursor.moveToNext()) { - Uri uri = Uri.withAppendedPath(contentUri, cursor.getString(cursor.getColumnIndexOrThrow(Images.Media._ID))); - String mimetype = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.MIME_TYPE)); - long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_TAKEN)); - int orientation = hasOrientation ? cursor.getInt(cursor.getColumnIndexOrThrow(Images.Media.ORIENTATION)) : 0; - int width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation))); - int height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation))); - long size = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.SIZE)); - - media.add(new Media(uri, mimetype, dateTaken, width, height, size, Optional.of(bucketId), Optional.absent())); - } + media.addAll(images); + media.addAll(videos); + Collections.sort(media, (o1, o2) -> Long.compare(o2.getDate(), o1.getDate())); + + return media; } + @WorkerThread + private @NonNull List getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Uri contentUri, boolean isImage) { + List media = new LinkedList<>(); + String selection = Images.Media.BUCKET_ID + " = ?"; + String[] selectionArgs = new String[] { bucketId}; + String sortBy = Images.Media.DATE_MODIFIED + " DESC"; + + String[] projection; - return media; - } - - @WorkerThread - private List getPopulatedMedia(@NonNull Context context, @NonNull List media) { - return Stream.of(media).map(m -> { - try { - if (isPopulated(m)) { - return m; - } else if (PartAuthority.isLocalUri(m.getUri())) { - return getLocallyPopulatedMedia(context, m); + if (isImage) { + projection = new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_MODIFIED, Images.Media.ORIENTATION, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE, Images.Media.DISPLAY_NAME}; } else { - return getContentResolverPopulatedMedia(context, m); + projection = new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_MODIFIED, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE, Images.Media.DISPLAY_NAME}; + } + + if (Media.ALL_MEDIA_BUCKET_ID.equals(bucketId)) { + selection = null; + selectionArgs = null; } - } catch (IOException e) { - return m; - } - }).toList(); - } - - @SuppressWarnings("SuspiciousNameCombination") - private String getWidthColumn(int orientation) { - if (orientation == 0 || orientation == 180) return Images.Media.WIDTH; - else return Images.Media.HEIGHT; - } - - @SuppressWarnings("SuspiciousNameCombination") - private String getHeightColumn(int orientation) { - if (orientation == 0 || orientation == 180) return Images.Media.HEIGHT; - else return Images.Media.WIDTH; - } - - private boolean isPopulated(@NonNull Media media) { - return media.getWidth() > 0 && media.getHeight() > 0 && media.getSize() > 0; - } - - private Media getLocallyPopulatedMedia(@NonNull Context context, @NonNull Media media) throws IOException { - int width = media.getWidth(); - int height = media.getHeight(); - long size = media.getSize(); - - if (size <= 0) { - Optional optionalSize = Optional.fromNullable(PartAuthority.getAttachmentSize(context, media.getUri())); - size = optionalSize.isPresent() ? optionalSize.get() : 0; + + try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, selectionArgs, sortBy)) { + while (cursor != null && cursor.moveToNext()) { + long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0])); + Uri uri = ContentUris.withAppendedId(contentUri, rowId); + String mimetype = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.MIME_TYPE)); + long date = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_MODIFIED)); + int orientation = isImage ? cursor.getInt(cursor.getColumnIndexOrThrow(Images.Media.ORIENTATION)) : 0; + int width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation))); + int height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation))); + long size = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.SIZE)); + String filename = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.DISPLAY_NAME)); + + // skip media if the filename or mimetype is null here + if (filename == null || mimetype == null) { + continue; + } + + media.add(new Media(uri, filename, mimetype, date, width, height, size, bucketId, null)); + } + } + + return media; + } + @WorkerThread + private List getPopulatedMedia(@NonNull Context context, @NonNull List media) { + return Stream.of(media).map(m -> { + try { + if (isPopulated(m)) { + return m; + } else if (PartAuthority.isLocalUri(m.getUri())) { + return getLocallyPopulatedMedia(context, m); + } else { + return getContentResolverPopulatedMedia(context, m); + } + } catch (IOException e) { + return m; + } + }).toList(); } - if (size <= 0) { - size = MediaUtil.getMediaSize(context, media.getUri()); + @SuppressWarnings("SuspiciousNameCombination") + private String getWidthColumn(int orientation) { + if (orientation == 0 || orientation == 180) return Images.Media.WIDTH; + else return Images.Media.HEIGHT; } - if (width == 0 || height == 0) { - Pair dimens = MediaUtil.getDimensions(context, media.getMimeType(), media.getUri()); - width = dimens.first; - height = dimens.second; + @SuppressWarnings("SuspiciousNameCombination") + private String getHeightColumn(int orientation) { + if (orientation == 0 || orientation == 180) return Images.Media.HEIGHT; + else return Images.Media.WIDTH; } - return new Media(media.getUri(), media.getMimeType(), media.getDate(), width, height, size, media.getBucketId(), media.getCaption()); - } + private boolean isPopulated(@NonNull Media media) { + return media.getWidth() > 0 && media.getHeight() > 0 && media.getSize() > 0; + } - private Media getContentResolverPopulatedMedia(@NonNull Context context, @NonNull Media media) throws IOException { - int width = media.getWidth(); - int height = media.getHeight(); - long size = media.getSize(); + private Media getLocallyPopulatedMedia(@NonNull Context context, @NonNull Media media) throws IOException { + int width = media.getWidth(); + int height = media.getHeight(); + long size = media.getSize(); - if (size <= 0) { - try (Cursor cursor = context.getContentResolver().query(media.getUri(), null, null, null, null)) { - if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.SIZE) >= 0) { - size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); + if (size <= 0) { + Optional optionalSize = Optional.fromNullable(PartAuthority.getAttachmentSize(context, media.getUri())); + size = optionalSize.isPresent() ? optionalSize.get() : 0; } - } - } - if (size <= 0) { - size = MediaUtil.getMediaSize(context, media.getUri()); - } + if (size <= 0) { + size = MediaUtil.getMediaSize(context, media.getUri()); + } - if (width == 0 || height == 0) { - Pair dimens = MediaUtil.getDimensions(context, media.getMimeType(), media.getUri()); - width = dimens.first; - height = dimens.second; - } + if (width == 0 || height == 0) { + Pair dimens = MediaUtil.getDimensions(context, media.getMimeType(), media.getUri()); + width = dimens.first; + height = dimens.second; + } - return new Media(media.getUri(), media.getMimeType(), media.getDate(), width, height, size, media.getBucketId(), media.getCaption()); - } - - private static class FolderResult { - private final Uri thumbnail; - private final long thumbnailTimestamp; - private final Map folderData; - - private FolderResult(@Nullable Uri thumbnail, - long thumbnailTimestamp, - @NonNull Map folderData) - { - this.thumbnail = thumbnail; - this.thumbnailTimestamp = thumbnailTimestamp; - this.folderData = folderData; + return new Media(media.getUri(), media.getFilename(), media.getMimeType(), media.getDate(), width, height, size, media.getBucketId(), media.getCaption()); } + private Media getContentResolverPopulatedMedia(@NonNull Context context, @NonNull Media media) throws IOException { + int width = media.getWidth(); + int height = media.getHeight(); + long size = media.getSize(); + + if (size <= 0) { + try (Cursor cursor = context.getContentResolver().query(media.getUri(), null, null, null, null)) { + if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.SIZE) >= 0) { + size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); + } + } + } - @Nullable Uri getThumbnail() { - return thumbnail; - } + if (size <= 0) { + size = MediaUtil.getMediaSize(context, media.getUri()); + } - long getThumbnailTimestamp() { - return thumbnailTimestamp; - } + if (width == 0 || height == 0) { + Pair dimens = MediaUtil.getDimensions(context, media.getMimeType(), media.getUri()); + width = dimens.first; + height = dimens.second; + } - @NonNull Map getFolderData() { - return folderData; + return new Media(media.getUri(), media.getFilename(), media.getMimeType(), media.getDate(), width, height, size, media.getBucketId(), media.getCaption()); } - } - private static class FolderData { - private final Uri thumbnail; - private final String title; - private final String bucketId; + private static class FolderResult { + private final Uri thumbnail; + private final long thumbnailTimestamp; + private final Map folderData; + + private FolderResult(@Nullable Uri thumbnail, + long thumbnailTimestamp, + @NonNull Map folderData) + { + this.thumbnail = thumbnail; + this.thumbnailTimestamp = thumbnailTimestamp; + this.folderData = folderData; + } - private int count; + @Nullable Uri getThumbnail() { + return thumbnail; + } - private FolderData(Uri thumbnail, String title, String bucketId) { - this.thumbnail = thumbnail; - this.title = title; - this.bucketId = bucketId; - } + long getThumbnailTimestamp() { + return thumbnailTimestamp; + } - Uri getThumbnail() { - return thumbnail; + @NonNull Map getFolderData() { + return folderData; + } } - String getTitle() { - return title; - } + private static class FolderData { + private final Uri thumbnail; + private final String title; + private final String bucketId; + private int count; + private long latestTimestamp; // New field + + private FolderData(@NonNull Uri thumbnail, @NonNull String title, @NonNull String bucketId) { + this.thumbnail = thumbnail; + this.title = title; + this.bucketId = bucketId; + this.count = 0; + this.latestTimestamp = 0; + } - String getBucketId() { - return bucketId; - } + Uri getThumbnail() { + return thumbnail; + } - int getCount() { - return count; - } + String getTitle() { + return title; + } - void incrementCount() { - incrementCount(1); - } + String getBucketId() { + return bucketId; + } + + int getCount() { + return count; + } + + void incrementCount() { + incrementCount(1); + } + + void incrementCount(int amount) { + count += amount; + } - void incrementCount(int amount) { - count += amount; + void updateTimestamp(long ts) { + if (ts > latestTimestamp) { + latestTimestamp = ts; + } + } + + long getLatestTimestamp() { + return latestTimestamp; + } } - } - interface Callback { - void onComplete(@NonNull E result); - } + interface Callback { + void onComplete(@NonNull E result); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java deleted file mode 100644 index d7ced3ef26..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java +++ /dev/null @@ -1,455 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; - -import android.Manifest; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.view.View; -import android.view.animation.AccelerateDecelerateInterpolator; -import android.view.animation.AccelerateInterpolator; -import android.view.animation.Animation; -import android.view.animation.DecelerateInterpolator; -import android.view.animation.OvershootInterpolator; -import android.view.animation.ScaleAnimation; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.lifecycle.ViewModelProvider; - -import com.squareup.phrase.Phrase; - -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.MediaTypes; -import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.concurrent.SimpleTask; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.guava.Optional; -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; -import org.thoughtcrime.securesms.permissions.Permissions; -import org.thoughtcrime.securesms.providers.BlobProvider; -import org.thoughtcrime.securesms.scribbles.ImageEditorFragment; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -import network.loki.messenger.R; - -/** - * Encompasses the entire flow of sending media, starting from the selection process to the actual - * captioning and editing of the content. - * - * This activity is intended to be launched via {@link #startActivityForResult(Intent, int)}. - * It will return the {@link Media} that the user decided to send. - */ -public class MediaSendActivity extends PassphraseRequiredActionBarActivity implements MediaPickerFolderFragment.Controller, - MediaPickerItemFragment.Controller, - MediaSendFragment.Controller, - ImageEditorFragment.Controller, - Camera1Fragment.Controller -{ - private static final String TAG = MediaSendActivity.class.getSimpleName(); - - public static final String EXTRA_MEDIA = "media"; - public static final String EXTRA_MESSAGE = "message"; - - - private static final String KEY_ADDRESS = "address"; - private static final String KEY_BODY = "body"; - private static final String KEY_MEDIA = "media"; - private static final String KEY_IS_CAMERA = "is_camera"; - - private static final String TAG_FOLDER_PICKER = "folder_picker"; - private static final String TAG_ITEM_PICKER = "item_picker"; - private static final String TAG_SEND = "send"; - private static final String TAG_CAMERA = "camera"; - - - private Recipient recipient; - private MediaSendViewModel viewModel; - - private View countButton; - private TextView countButtonText; - private View cameraButton; - - /** - * Get an intent to launch the media send flow starting with the picker. - */ - public static Intent buildGalleryIntent(@NonNull Context context, @NonNull Recipient recipient, @NonNull String body) { - Intent intent = new Intent(context, MediaSendActivity.class); - intent.putExtra(KEY_ADDRESS, recipient.getAddress().serialize()); - intent.putExtra(KEY_BODY, body); - return intent; - } - - /** - * Get an intent to launch the media send flow starting with the camera. - */ - public static Intent buildCameraIntent(@NonNull Context context, @NonNull Recipient recipient) { - Intent intent = buildGalleryIntent(context, recipient, ""); - intent.putExtra(KEY_IS_CAMERA, true); - return intent; - } - - /** - * Get an intent to launch the media send flow with a specific list of media. Will jump right to - * the editor screen. - */ - public static Intent buildEditorIntent(@NonNull Context context, - @NonNull List media, - @NonNull Recipient recipient, - @NonNull String body) - { - Intent intent = buildGalleryIntent(context, recipient, body); - intent.putParcelableArrayListExtra(KEY_MEDIA, new ArrayList<>(media)); - return intent; - } - - @Override - protected void onCreate(Bundle savedInstanceState, boolean ready) { - super.onCreate(savedInstanceState, ready); - - setContentView(R.layout.mediasend_activity); - setResult(RESULT_CANCELED); - - if (savedInstanceState != null) { - return; - } - - countButton = findViewById(R.id.mediasend_count_button); - countButtonText = findViewById(R.id.mediasend_count_button_text); - cameraButton = findViewById(R.id.mediasend_camera_button); - - viewModel = new ViewModelProvider(this, new MediaSendViewModel.Factory(getApplication(), new MediaRepository())).get(MediaSendViewModel.class); - recipient = Recipient.from(this, Address.fromSerialized(getIntent().getStringExtra(KEY_ADDRESS)), true); - - viewModel.onBodyChanged(getIntent().getStringExtra(KEY_BODY)); - - List media = getIntent().getParcelableArrayListExtra(KEY_MEDIA); - boolean isCamera = getIntent().getBooleanExtra(KEY_IS_CAMERA, false); - - if (isCamera) { - Fragment fragment = Camera1Fragment.newInstance(); - getSupportFragmentManager().beginTransaction() - .replace(R.id.mediasend_fragment_container, fragment, TAG_CAMERA) - .commit(); - - } else if (!Util.isEmpty(media)) { - viewModel.onSelectedMediaChanged(this, media); - - Fragment fragment = MediaSendFragment.newInstance(recipient); - getSupportFragmentManager().beginTransaction() - .replace(R.id.mediasend_fragment_container, fragment, TAG_SEND) - .commit(); - } else { - MediaPickerFolderFragment fragment = MediaPickerFolderFragment.newInstance(recipient); - getSupportFragmentManager().beginTransaction() - .replace(R.id.mediasend_fragment_container, fragment, TAG_FOLDER_PICKER) - .commit(); - } - - initializeCountButtonObserver(); - initializeCameraButtonObserver(); - initializeErrorObserver(); - - cameraButton.setOnClickListener(v -> { - int maxSelection = viewModel.getMaxSelection(); - - if (viewModel.getSelectedMedia().getValue() != null && viewModel.getSelectedMedia().getValue().size() >= maxSelection) { - Toast.makeText(this, getString(R.string.attachmentsErrorNumber), Toast.LENGTH_SHORT).show(); - } else { - navigateToCamera(); - } - }); - } - - @Override - public void onBackPressed() { - MediaSendFragment sendFragment = (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND); - if (sendFragment == null || !sendFragment.isVisible() || !sendFragment.handleBackPress()) { - super.onBackPressed(); - - if (getIntent().getBooleanExtra(KEY_IS_CAMERA, false) && getSupportFragmentManager().getBackStackEntryCount() == 0) { - viewModel.onImageCaptureUndo(this); - } - } - } - - @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 onFolderSelected(@NonNull MediaFolder folder) { - if(folder == null || viewModel == null){ - return; - } - - viewModel.onFolderSelected(folder.getBucketId()); - - MediaPickerItemFragment fragment = MediaPickerItemFragment.newInstance(folder.getBucketId(), folder.getTitle(), viewModel.getMaxSelection()); - getSupportFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right) - .replace(R.id.mediasend_fragment_container, fragment, TAG_ITEM_PICKER) - .addToBackStack(null) - .commit(); - } - - @Override - public void onMediaSelected(@NonNull Media media) { - viewModel.onSingleMediaSelected(this, media); - navigateToMediaSend(recipient); - } - - @Override - public void onAddMediaClicked(@NonNull String bucketId) { - // TODO: Get actual folder title somehow - MediaPickerFolderFragment folderFragment = MediaPickerFolderFragment.newInstance(recipient); - MediaPickerItemFragment itemFragment = MediaPickerItemFragment.newInstance(bucketId, "", viewModel.getMaxSelection()); - - getSupportFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.stationary, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right) - .replace(R.id.mediasend_fragment_container, folderFragment, TAG_FOLDER_PICKER) - .addToBackStack(null) - .commit(); - - getSupportFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.slide_from_right, R.anim.stationary, R.anim.slide_from_left, R.anim.slide_to_right) - .replace(R.id.mediasend_fragment_container, itemFragment, TAG_ITEM_PICKER) - .addToBackStack(null) - .commit(); - } - - @Override - public void onSendClicked(@NonNull List media, @NonNull String message) { - viewModel.onSendClicked(); - - ArrayList mediaList = new ArrayList<>(media); - Intent intent = new Intent(); - - intent.putParcelableArrayListExtra(EXTRA_MEDIA, mediaList); - intent.putExtra(EXTRA_MESSAGE, message); - setResult(RESULT_OK, intent); - finish(); - - overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom); - } - - @Override - public void onNoMediaAvailable() { - setResult(RESULT_CANCELED); - finish(); - } - - @Override - public void onTouchEventsNeeded(boolean needed) { - MediaSendFragment fragment = (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND); - if (fragment != null) { - fragment.onTouchEventsNeeded(needed); - } - } - - @Override - public void onCameraError() { - Toast.makeText(this, R.string.cameraErrorUnavailable, Toast.LENGTH_SHORT).show(); - setResult(RESULT_CANCELED, new Intent()); - finish(); - } - - @Override - public void onImageCaptured(@NonNull byte[] data, int width, int height) { - Log.i(TAG, "Camera image captured."); - - SimpleTask.run(getLifecycle(), () -> { - try { - Uri uri = BlobProvider.getInstance() - .forData(data) - .withMimeType(MediaTypes.IMAGE_JPEG) - .createForSingleSessionOnDisk(this, e -> Log.w(TAG, "Failed to write to disk.", e)); - return new Media(uri, - MediaTypes.IMAGE_JPEG, - System.currentTimeMillis(), - width, - height, - data.length, - Optional.of(Media.ALL_MEDIA_BUCKET_ID), - Optional.absent()); - } catch (IOException e) { - return null; - } - }, media -> { - if (media == null) { - onNoMediaAvailable(); - return; - } - - Log.i(TAG, "Camera capture stored: " + media.getUri().toString()); - - viewModel.onImageCaptured(media); - navigateToMediaSend(recipient); - }); - } - - @Override - public int getDisplayRotation() { - return getWindowManager().getDefaultDisplay().getRotation(); - } - - private void initializeCountButtonObserver() { - viewModel.getCountButtonState().observe(this, buttonState -> { - if (buttonState == null) return; - - countButtonText.setText(String.valueOf(buttonState.getCount())); - countButton.setEnabled(buttonState.isVisible()); - animateButtonVisibility(countButton, countButton.getVisibility(), buttonState.isVisible() ? View.VISIBLE : View.GONE); - - if (buttonState.getCount() > 0) { - countButton.setOnClickListener(v -> navigateToMediaSend(recipient)); - if (buttonState.isVisible()) { - animateButtonTextChange(countButton); - } - } else { - countButton.setOnClickListener(null); - } - }); - } - - private void initializeCameraButtonObserver() { - viewModel.getCameraButtonVisibility().observe(this, visible -> { - if (visible == null) return; - animateButtonVisibility(cameraButton, cameraButton.getVisibility(), visible ? View.VISIBLE : View.GONE); - }); - } - - private void initializeErrorObserver() { - viewModel.getError().observe(this, error -> { - if (error == null) return; - - switch (error) { - case ITEM_TOO_LARGE: - Toast.makeText(this, R.string.attachmentsErrorSize, Toast.LENGTH_LONG).show(); - break; - case TOO_MANY_ITEMS: - // In modern session we'll say you can't sent more than 32 items, but if we ever want - // the exact count of how many items the user attempted to send it's: viewModel.getMaxSelection() - Toast.makeText(this, getString(R.string.attachmentsErrorNumber), Toast.LENGTH_SHORT).show(); - break; - } - }); - } - - private void navigateToMediaSend(@NonNull Recipient recipient) { - MediaSendFragment fragment = MediaSendFragment.newInstance(recipient); - String backstackTag = null; - - if (getSupportFragmentManager().findFragmentByTag(TAG_SEND) != null) { - getSupportFragmentManager().popBackStack(TAG_SEND, FragmentManager.POP_BACK_STACK_INCLUSIVE); - backstackTag = TAG_SEND; - } - - getSupportFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right) - .replace(R.id.mediasend_fragment_container, fragment, TAG_SEND) - .addToBackStack(backstackTag) - .commit(); - } - - private void navigateToCamera() { - - Context c = getApplicationContext(); - String permanentDenialTxt = Phrase.from(c, R.string.permissionsCameraDenied) - .put(APP_NAME_KEY, c.getString(R.string.app_name)) - .format().toString(); - String requireCameraPermissionsTxt = Phrase.from(c, R.string.cameraGrantAccessDescription) - .put(APP_NAME_KEY, c.getString(R.string.app_name)) - .format().toString(); - - Permissions.with(this) - .request(Manifest.permission.CAMERA) - .withPermanentDenialDialog(permanentDenialTxt) - .onAllGranted(() -> { - Camera1Fragment fragment = getOrCreateCameraFragment(); - getSupportFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right) - .replace(R.id.mediasend_fragment_container, fragment, TAG_CAMERA) - .addToBackStack(null) - .commit(); - }) - .onAnyDenied(() -> Toast.makeText(MediaSendActivity.this, requireCameraPermissionsTxt, Toast.LENGTH_LONG).show()) - .execute(); - } - - private Camera1Fragment getOrCreateCameraFragment() { - Camera1Fragment fragment = (Camera1Fragment) getSupportFragmentManager().findFragmentByTag(TAG_CAMERA); - - return fragment != null ? fragment - : Camera1Fragment.newInstance(); - } - - private void animateButtonVisibility(@NonNull View button, int oldVisibility, int newVisibility) { - if (oldVisibility == newVisibility) return; - - if (button.getAnimation() != null) { - button.clearAnimation(); - button.setVisibility(newVisibility); - } else if (newVisibility == View.VISIBLE) { - button.setVisibility(View.VISIBLE); - - Animation animation = new ScaleAnimation(0, 1, 0, 1, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f); - animation.setDuration(250); - animation.setInterpolator(new OvershootInterpolator()); - button.startAnimation(animation); - } else { - Animation animation = new ScaleAnimation(1, 0, 1, 0, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f); - animation.setDuration(150); - animation.setInterpolator(new AccelerateDecelerateInterpolator()); - animation.setAnimationListener(new SimpleAnimationListener() { - @Override - public void onAnimationEnd(Animation animation) { - button.clearAnimation(); - button.setVisibility(View.GONE); - } - }); - - button.startAnimation(animation); - } - } - - private void animateButtonTextChange(@NonNull View button) { - if (button.getAnimation() != null) { - button.clearAnimation(); - } - - Animation grow = new ScaleAnimation(1f, 1.3f, 1f, 1.3f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f); - grow.setDuration(125); - grow.setInterpolator(new AccelerateInterpolator()); - grow.setAnimationListener(new SimpleAnimationListener() { - @Override - public void onAnimationEnd(Animation animation) { - Animation shrink = new ScaleAnimation(1.3f, 1f, 1.3f, 1f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f); - shrink.setDuration(125); - shrink.setInterpolator(new DecelerateInterpolator()); - button.startAnimation(shrink); - } - }); - - button.startAnimation(grow); - } - - @Override - public void onRequestFullScreen(boolean fullScreen) { - MediaSendFragment sendFragment = (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND); - if (sendFragment != null && sendFragment.isVisible()) { - sendFragment.onRequestFullScreen(fullScreen); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt new file mode 100644 index 0000000000..cc948d9302 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt @@ -0,0 +1,553 @@ +package org.thoughtcrime.securesms.mediasend + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.AccelerateInterpolator +import android.view.animation.Animation +import android.view.animation.DecelerateInterpolator +import android.view.animation.OvershootInterpolator +import android.view.animation.ScaleAnimation +import android.widget.Toast +import androidx.activity.viewModels +import androidx.core.view.ViewGroupCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.lifecycleScope +import com.squareup.phrase.Phrase +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import network.loki.messenger.R +import network.loki.messenger.databinding.MediasendActivityBinding +import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.MediaTypes +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.Util.isEmpty +import org.session.libsession.utilities.concurrent.SimpleTask +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.ScreenLockActionBarActivity +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel.CountButtonState +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.scribbles.ImageEditorFragment +import org.thoughtcrime.securesms.util.FilenameUtils.constructPhotoFilename +import org.thoughtcrime.securesms.util.applySafeInsetsPaddings + +/** + * Encompasses the entire flow of sending media, starting from the selection process to the actual + * captioning and editing of the content. + * + * This activity is intended to be launched via [.startActivityForResult]. + * It will return the [Media] that the user decided to send. + */ +@AndroidEntryPoint +class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragment.Controller, + MediaPickerItemFragment.Controller, MediaSendFragment.Controller, + ImageEditorFragment.Controller, CameraXFragment.Controller{ + private var recipient: Recipient? = null + private val viewModel: MediaSendViewModel by viewModels() + + private lateinit var binding: MediasendActivityBinding + + private val threadId: Long by lazy { + intent.getLongExtra(KEY_THREADID, 0L) + } + + override val applyDefaultWindowInsets: Boolean + get() = false // we want to handle window insets manually here for fullscreen fragments like the camera screen + + override val applyAutoScrimForNavigationBar: Boolean + get() = false + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + super.onCreate(savedInstanceState, ready) + + binding = MediasendActivityBinding.inflate(layoutInflater).also { + setContentView(it.root) + ViewGroupCompat.installCompatInsetsDispatch(it.root) + } + + setResult(RESULT_CANCELED) + + if (savedInstanceState != null) { + return + } + + // Apply windowInsets for our own UI (not the fragment ones because they will want to do their own things) + binding.mediasendBottomBar.applySafeInsetsPaddings() + + recipient = Recipient.from( + this, fromSerialized( + intent.getStringExtra(KEY_ADDRESS)!! + ), true + ) + + viewModel.onBodyChanged(intent.getStringExtra(KEY_BODY)!!) + + val media: List? = intent.getParcelableArrayListExtra(KEY_MEDIA) + val isCamera = intent.getBooleanExtra(KEY_IS_CAMERA, false) + + if (isCamera) { + val fragment: Fragment = CameraXFragment() + supportFragmentManager.beginTransaction() + .replace(R.id.mediasend_fragment_container, fragment, TAG_CAMERA) + .commit() + } else if (!isEmpty(media)) { + viewModel.onSelectedMediaChanged(this, media!!) + + val fragment: Fragment = MediaSendFragment.newInstance(recipient!!, threadId) + + supportFragmentManager.beginTransaction() + .replace(R.id.mediasend_fragment_container, fragment, TAG_SEND) + .commit() + } else { + val fragment = MediaPickerFolderFragment.newInstance( + recipient!! + ) + supportFragmentManager.beginTransaction() + .replace(R.id.mediasend_fragment_container, fragment, TAG_FOLDER_PICKER) + .commit() + } + + initializeCountButtonObserver() + initializeCameraButtonObserver() + initializeErrorObserver() + + binding.mediasendCameraButton.setOnClickListener { v: View? -> + val maxSelection = MediaSendViewModel.MAX_SELECTED_FILES + if (viewModel.getSelectedMedia().value != null && viewModel.getSelectedMedia().value!!.size >= maxSelection) { + Toast.makeText(this, getString(R.string.attachmentsErrorNumber), Toast.LENGTH_SHORT) + .show() + } else { + navigateToCamera() + } + } + } + + override fun onBackPressed() { + super.onBackPressed() + + if (intent.getBooleanExtra( + KEY_IS_CAMERA, + false + ) && supportFragmentManager.backStackEntryCount == 0 + ) { + viewModel.onImageCaptureUndo(this) + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) + } + + override fun onFolderSelected(folder: MediaFolder) { + viewModel.onFolderSelected(folder.bucketId) + + val fragment = MediaPickerItemFragment.newInstance( + folder.bucketId, + folder.title, + MediaSendViewModel.MAX_SELECTED_FILES + ) + supportFragmentManager.beginTransaction() + .setCustomAnimations( + R.anim.slide_from_right, + R.anim.slide_to_left, + R.anim.slide_from_left, + R.anim.slide_to_right + ) + .replace(R.id.mediasend_fragment_container, fragment, TAG_ITEM_PICKER) + .addToBackStack(null) + .commit() + } + + override fun onMediaSelected(media: Media) { + try { + viewModel.onSingleMediaSelected(this, media) + navigateToMediaSend(recipient!!) + } catch (e: Exception){ + Log.e(TAG, "Error selecting media", e) + Toast.makeText(this, R.string.errorUnknown, Toast.LENGTH_LONG).show() + } + } + + override fun onAddMediaClicked(bucketId: String) { + val folderFragment = MediaPickerFolderFragment.newInstance( + recipient!! + ) + val itemFragment = + MediaPickerItemFragment.newInstance(bucketId, "", MediaSendViewModel.MAX_SELECTED_FILES) + + supportFragmentManager.beginTransaction() + .setCustomAnimations( + R.anim.stationary, + R.anim.slide_to_left, + R.anim.slide_from_left, + R.anim.slide_to_right + ) + .replace(R.id.mediasend_fragment_container, folderFragment, TAG_FOLDER_PICKER) + .addToBackStack(null) + .commit() + + supportFragmentManager.beginTransaction() + .setCustomAnimations( + R.anim.slide_from_right, + R.anim.stationary, + R.anim.slide_from_left, + R.anim.slide_to_right + ) + .replace(R.id.mediasend_fragment_container, itemFragment, TAG_ITEM_PICKER) + .addToBackStack(null) + .commit() + } + + override fun onSendClicked(media: List, message: String) { + viewModel.onSendClicked() + + val mediaList = ArrayList(media) + val intent = Intent() + + intent.putParcelableArrayListExtra(EXTRA_MEDIA, mediaList) + intent.putExtra(EXTRA_MESSAGE, message) + setResult(RESULT_OK, intent) + finish() + + overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom) + } + + override fun onNoMediaAvailable() { + setResult(RESULT_CANCELED) + finish() + } + + override fun onTouchEventsNeeded(needed: Boolean) { + val fragment = supportFragmentManager.findFragmentByTag(TAG_SEND) as MediaSendFragment? + fragment?.onTouchEventsNeeded(needed) + } + + override fun onCameraError() { + lifecycleScope.launch { + Toast.makeText(applicationContext, R.string.cameraErrorUnavailable, Toast.LENGTH_SHORT).show() + setResult(RESULT_CANCELED, Intent()) + finish() + } + } + + override fun onImageCaptured(imageUri: Uri, size: Long, width: Int, height: Int) { + Log.i(TAG, "Camera image captured.") + SimpleTask.run(lifecycle, { + try { + return@run Media( + imageUri, + constructPhotoFilename(this), + MediaTypes.IMAGE_JPEG, + System.currentTimeMillis(), + width, + height, + size, + Media.ALL_MEDIA_BUCKET_ID, + null + ) + } catch (e: Exception) { + return@run null + } + }, { media: Media? -> + if (media == null) { + onNoMediaAvailable() + return@run + } + Log.i(TAG, "Camera capture stored: " + media.uri.toString()) + + viewModel.onImageCaptured(media) + navigateToMediaSend(recipient!!) + }) + } + + private fun initializeCountButtonObserver() { + viewModel.getCountButtonState().observe( + this + ) { buttonState: CountButtonState? -> + if (buttonState == null) return@observe + binding.mediasendCountButtonText.text = buttonState.count.toString() + binding.mediasendCountButton.isEnabled = buttonState.isVisible + animateButtonVisibility( + binding.mediasendCountButton, + binding.mediasendCountButton.visibility, + if (buttonState.isVisible) View.VISIBLE else View.GONE + ) + if (buttonState.count > 0) { + binding.mediasendCountButton.setOnClickListener { v: View? -> + navigateToMediaSend( + recipient!! + ) + } + if (buttonState.isVisible) { + animateButtonTextChange(binding.mediasendCountButton) + } + } else { + binding.mediasendCountButton.setOnClickListener(null) + } + } + } + + private fun initializeCameraButtonObserver() { + viewModel.getCameraButtonVisibility().observe( + this + ) { visible: Boolean? -> + if (visible == null) return@observe + animateButtonVisibility( + binding.mediasendCameraButton, + binding.mediasendCameraButton.visibility, + if (visible) View.VISIBLE else View.GONE + ) + } + } + + private fun initializeErrorObserver() { + viewModel.getError().observe( + this + ) { error: MediaSendViewModel.Error? -> + if (error == null) return@observe + when (error) { + MediaSendViewModel.Error.ITEM_TOO_LARGE -> Toast.makeText( + this, + R.string.attachmentsErrorSize, + Toast.LENGTH_LONG + ).show() + + MediaSendViewModel.Error.TOO_MANY_ITEMS -> // In modern session we'll say you can't sent more than 32 items, but if we ever want + // the exact count of how many items the user attempted to send it's: viewModel.getMaxSelection() + Toast.makeText( + this, + getString(R.string.attachmentsErrorNumber), + Toast.LENGTH_SHORT + ).show() + } + } + } + + private fun navigateToMediaSend(recipient: Recipient) { + val fragment = MediaSendFragment.newInstance(recipient, threadId) + var backstackTag: String? = null + + if (supportFragmentManager.findFragmentByTag(TAG_SEND) != null) { + supportFragmentManager.popBackStack(TAG_SEND, FragmentManager.POP_BACK_STACK_INCLUSIVE) + backstackTag = TAG_SEND + } + + supportFragmentManager.beginTransaction() + .setCustomAnimations( + R.anim.slide_from_right, + R.anim.slide_to_left, + R.anim.slide_from_left, + R.anim.slide_to_right + ) + .replace(R.id.mediasend_fragment_container, fragment, TAG_SEND) + .addToBackStack(backstackTag) + .commit() + } + + private fun navigateToCamera() { + val c = applicationContext + val permanentDenialTxt = Phrase.from(c, R.string.permissionsCameraDenied) + .put(APP_NAME_KEY, c.getString(R.string.app_name)) + .format().toString() + val requireCameraPermissionsTxt = Phrase.from(c, R.string.cameraGrantAccessDescription) + .put(APP_NAME_KEY, c.getString(R.string.app_name)) + .format().toString() + + Permissions.with(this) + .request(Manifest.permission.CAMERA) + .withPermanentDenialDialog(permanentDenialTxt) + .onAllGranted { + val fragment = orCreateCameraFragment + supportFragmentManager.beginTransaction() + .setCustomAnimations( + R.anim.slide_from_right, + R.anim.slide_to_left, + R.anim.slide_from_left, + R.anim.slide_to_right + ) + .replace( + R.id.mediasend_fragment_container, + fragment, + TAG_CAMERA + ) + .addToBackStack(null) + .commit() + + viewModel.onCameraStarted() + } + .onAnyDenied { + Toast.makeText( + this@MediaSendActivity, + requireCameraPermissionsTxt, + Toast.LENGTH_LONG + ).show() + } + .execute() + } + + private val orCreateCameraFragment: CameraXFragment + get() { + val fragment = + supportFragmentManager.findFragmentByTag(TAG_CAMERA) as CameraXFragment? + + return fragment ?: CameraXFragment() + } + + private fun animateButtonVisibility(button: View, oldVisibility: Int, newVisibility: Int) { + if (oldVisibility == newVisibility) return + + if (button.animation != null) { + button.clearAnimation() + button.visibility = newVisibility + } else if (newVisibility == View.VISIBLE) { + button.visibility = View.VISIBLE + + val animation: Animation = ScaleAnimation( + 0f, + 1f, + 0f, + 1f, + Animation.RELATIVE_TO_SELF, + 0.5f, + Animation.RELATIVE_TO_SELF, + 0.5f + ) + animation.duration = 250 + animation.interpolator = OvershootInterpolator() + button.startAnimation(animation) + } else { + val animation: Animation = ScaleAnimation( + 1f, + 0f, + 1f, + 0f, + Animation.RELATIVE_TO_SELF, + 0.5f, + Animation.RELATIVE_TO_SELF, + 0.5f + ) + animation.duration = 150 + animation.interpolator = AccelerateDecelerateInterpolator() + animation.setAnimationListener(object : SimpleAnimationListener() { + override fun onAnimationEnd(animation: Animation) { + button.clearAnimation() + button.visibility = View.GONE + } + }) + + button.startAnimation(animation) + } + } + + private fun animateButtonTextChange(button: View) { + if (button.animation != null) { + button.clearAnimation() + } + + val grow: Animation = ScaleAnimation( + 1f, + 1.3f, + 1f, + 1.3f, + Animation.RELATIVE_TO_SELF, + 0.5f, + Animation.RELATIVE_TO_SELF, + 0.5f + ) + grow.duration = 125 + grow.interpolator = AccelerateInterpolator() + grow.setAnimationListener(object : SimpleAnimationListener() { + override fun onAnimationEnd(animation: Animation) { + val shrink: Animation = ScaleAnimation( + 1.3f, + 1f, + 1.3f, + 1f, + Animation.RELATIVE_TO_SELF, + 0.5f, + Animation.RELATIVE_TO_SELF, + 0.5f + ) + shrink.duration = 125 + shrink.interpolator = DecelerateInterpolator() + button.startAnimation(shrink) + } + }) + + button.startAnimation(grow) + } + + override fun onRequestFullScreen(fullScreen: Boolean) { + val sendFragment = supportFragmentManager.findFragmentByTag(TAG_SEND) as MediaSendFragment? + if (sendFragment != null && sendFragment.isVisible) { + sendFragment.onRequestFullScreen(fullScreen) + } + } + + companion object { + private val TAG: String = MediaSendActivity::class.java.simpleName + + const val EXTRA_MEDIA: String = "media" + const val EXTRA_MESSAGE: String = "message" + + private const val KEY_ADDRESS = "address" + private const val KEY_THREADID = "threadid" + private const val KEY_BODY = "body" + private const val KEY_MEDIA = "media" + private const val KEY_IS_CAMERA = "is_camera" + + private const val TAG_FOLDER_PICKER = "folder_picker" + private const val TAG_ITEM_PICKER = "item_picker" + private const val TAG_SEND = "send" + private const val TAG_CAMERA = "camera" + + /** + * Get an intent to launch the media send flow starting with the picker. + */ + @JvmStatic + fun buildGalleryIntent(context: Context, recipient: Recipient, threadId: Long, body: String): Intent { + val intent = Intent(context, MediaSendActivity::class.java) + intent.putExtra(KEY_ADDRESS, recipient.address.toString()) + intent.putExtra(KEY_BODY, body) + intent.putExtra(KEY_THREADID, threadId) + return intent + } + + /** + * Get an intent to launch the media send flow starting with the camera. + */ + @JvmStatic + fun buildCameraIntent(context: Context, recipient: Recipient, threadId: Long, body: String): Intent { + val intent = buildGalleryIntent(context, recipient, threadId, body) + intent.putExtra(KEY_IS_CAMERA, true) + return intent + } + + /** + * Get an intent to launch the media send flow with a specific list of media. Will jump right to + * the editor screen. + */ + fun buildEditorIntent( + context: Context, + media: List, + recipient: Recipient, + threadId: Long, + body: String + ): Intent { + val intent = buildGalleryIntent(context, recipient, threadId, body) + intent.putParcelableArrayListExtra(KEY_MEDIA, ArrayList(media)) + return intent + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java deleted file mode 100644 index 169ac83ead..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java +++ /dev/null @@ -1,552 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.annotation.SuppressLint; -import androidx.lifecycle.ViewModelProvider; -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Rect; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.viewpager.widget.ViewPager; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.view.ContextThemeWrapper; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import android.text.Editable; -import android.text.TextWatcher; -import android.view.KeyEvent; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewTreeObserver; -import android.view.WindowManager; -import android.view.inputmethod.EditorInfo; -import android.widget.ImageButton; -import android.widget.TextView; - -import org.session.libsession.utilities.MediaTypes; -import org.thoughtcrime.securesms.components.ComposeText; -import org.thoughtcrime.securesms.components.ControllableViewPager; -import org.thoughtcrime.securesms.components.InputAwareLayout; -import org.thoughtcrime.securesms.components.emoji.EmojiEditText; -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.util.SimpleTextWatcher; -import org.thoughtcrime.securesms.imageeditor.model.EditorModel; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter; -import com.bumptech.glide.Glide; -import org.thoughtcrime.securesms.providers.BlobProvider; -import org.session.libsession.utilities.recipients.Recipient; -import org.thoughtcrime.securesms.scribbles.ImageEditorFragment; -import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState; -import org.thoughtcrime.securesms.util.PushCharacterCalculator; -import org.thoughtcrime.securesms.util.Stopwatch; -import org.session.libsignal.utilities.guava.Optional; - -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.Stub; -import org.session.libsignal.utilities.ListenableFuture; -import org.session.libsignal.utilities.SettableFuture; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.ExecutionException; - -import network.loki.messenger.R; - -/** - * Allows the user to edit and caption a set of media items before choosing to send them. - */ -public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGlobalLayoutListener, - MediaRailAdapter.RailItemListener, - InputAwareLayout.OnKeyboardShownListener, - InputAwareLayout.OnKeyboardHiddenListener -{ - - private static final String TAG = MediaSendFragment.class.getSimpleName(); - - private static final String KEY_ADDRESS = "address"; - - private InputAwareLayout hud; - private View captionAndRail; - private ImageButton sendButton; - private ComposeText composeText; - private ViewGroup composeContainer; - private EmojiEditText captionText; - private EmojiToggle emojiToggle; - private Stub emojiDrawer; - private ViewGroup playbackControlsContainer; - private TextView charactersLeft; - private View closeButton; - - private ControllableViewPager fragmentPager; - private MediaSendFragmentPagerAdapter fragmentPagerAdapter; - private RecyclerView mediaRail; - private MediaRailAdapter mediaRailAdapter; - - private int visibleHeight; - private MediaSendViewModel viewModel; - private Controller controller; - - private final Rect visibleBounds = new Rect(); - - private final PushCharacterCalculator characterCalculator = new PushCharacterCalculator(); - - public static MediaSendFragment newInstance(@NonNull Recipient recipient) { - Bundle args = new Bundle(); - args.putParcelable(KEY_ADDRESS, recipient.getAddress()); - - MediaSendFragment fragment = new MediaSendFragment(); - fragment.setArguments(args); - return fragment; - } - - @Override - public void onAttach(Context context) { - super.onAttach(context); - - if (!(requireActivity() instanceof Controller)) { - throw new IllegalStateException("Parent activity must implement controller interface."); - } - - controller = (Controller) requireActivity(); - } - - @Override - public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.mediasend_fragment, container, false); - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - initViewModel(); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - hud = view.findViewById(R.id.mediasend_hud); - captionAndRail = view.findViewById(R.id.mediasend_caption_and_rail); - sendButton = view.findViewById(R.id.mediasend_send_button); - composeText = view.findViewById(R.id.mediasend_compose_text); - composeContainer = view.findViewById(R.id.mediasend_compose_container); - captionText = view.findViewById(R.id.mediasend_caption); - emojiToggle = view.findViewById(R.id.mediasend_emoji_toggle); - emojiDrawer = new Stub<>(view.findViewById(R.id.mediasend_emoji_drawer_stub)); - fragmentPager = view.findViewById(R.id.mediasend_pager); - mediaRail = view.findViewById(R.id.mediasend_media_rail); - playbackControlsContainer = view.findViewById(R.id.mediasend_playback_controls_container); - charactersLeft = view.findViewById(R.id.mediasend_characters_left); - closeButton = view.findViewById(R.id.mediasend_close_button); - - View sendButtonBkg = view.findViewById(R.id.mediasend_send_button_bkg); - - sendButton.setOnClickListener(v -> { - if (hud.isKeyboardOpen()) { - hud.hideSoftkey(composeText, null); - } - - processMedia(fragmentPagerAdapter.getAllMedia(), fragmentPagerAdapter.getSavedState()); - }); - -// sendButton.addOnTransportChangedListener((newTransport, manuallySelected) -> { -// presentCharactersRemaining(); -// composeText.setTransport(newTransport); -// sendButtonBkg.getBackground().setColorFilter(getResources().getColor(R.color.transparent), PorterDuff.Mode.MULTIPLY); -// sendButtonBkg.getBackground().invalidateSelf(); -// }); - - ComposeKeyPressedListener composeKeyPressedListener = new ComposeKeyPressedListener(); - - composeText.setOnKeyListener(composeKeyPressedListener); - composeText.addTextChangedListener(composeKeyPressedListener); - composeText.setOnClickListener(composeKeyPressedListener); - composeText.setOnFocusChangeListener(composeKeyPressedListener); - - captionText.clearFocus(); - composeText.requestFocus(); - - fragmentPagerAdapter = new MediaSendFragmentPagerAdapter(getChildFragmentManager()); - fragmentPager.setAdapter(fragmentPagerAdapter); - - FragmentPageChangeListener pageChangeListener = new FragmentPageChangeListener(); - fragmentPager.addOnPageChangeListener(pageChangeListener); - fragmentPager.post(() -> pageChangeListener.onPageSelected(fragmentPager.getCurrentItem())); - - mediaRailAdapter = new MediaRailAdapter(Glide.with(this), this, true); - mediaRail.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); - mediaRail.setAdapter(mediaRailAdapter); - - hud.getRootView().getViewTreeObserver().addOnGlobalLayoutListener(this); - hud.addOnKeyboardShownListener(this); - hud.addOnKeyboardHiddenListener(this); - - captionText.addTextChangedListener(new SimpleTextWatcher() { - @Override - public void onTextChanged(String text) { - viewModel.onCaptionChanged(text); - } - }); - - composeText.append(viewModel.getBody()); - - Recipient recipient = Recipient.from(requireContext(), getArguments().getParcelable(KEY_ADDRESS), false); - String displayName = Optional.fromNullable(recipient.getName()) - .or(Optional.fromNullable(recipient.getProfileName()) - .or(recipient.getAddress().serialize())); - composeText.setHint(getString(R.string.message, displayName), null); - composeText.setOnEditorActionListener((v, actionId, event) -> { - boolean isSend = actionId == EditorInfo.IME_ACTION_SEND; - if (isSend) sendButton.performClick(); - return isSend; - }); - - if (TextSecurePreferences.isSystemEmojiPreferred(getContext())) { - emojiToggle.setVisibility(View.GONE); - } else { - emojiToggle.setOnClickListener(this::onEmojiToggleClicked); - } - - closeButton.setOnClickListener(v -> requireActivity().onBackPressed()); - } - - @Override - public void onStart() { - super.onStart(); - - fragmentPagerAdapter.restoreState(viewModel.getDrawState()); - viewModel.onImageEditorStarted(); - - requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); - } - - @Override - public void onHiddenChanged(boolean hidden) { - super.onHiddenChanged(hidden); - } - - @Override - public void onStop() { - super.onStop(); - fragmentPagerAdapter.saveAllState(); - viewModel.saveDrawState(fragmentPagerAdapter.getSavedState()); - } - - @Override - public void onGlobalLayout() { - hud.getRootView().getWindowVisibleDisplayFrame(visibleBounds); - - int currentVisibleHeight = visibleBounds.height(); - - if (currentVisibleHeight != visibleHeight) { - hud.getLayoutParams().height = currentVisibleHeight; - hud.layout(visibleBounds.left, visibleBounds.top, visibleBounds.right, visibleBounds.bottom); - hud.requestLayout(); - - visibleHeight = currentVisibleHeight; - } - } - - @Override - public void onRailItemClicked(int distanceFromActive) { - viewModel.onPageChanged(fragmentPager.getCurrentItem() + distanceFromActive); - } - - @Override - public void onRailItemDeleteClicked(int distanceFromActive) { - viewModel.onMediaItemRemoved(requireContext(), fragmentPager.getCurrentItem() + distanceFromActive); - } - - @Override - public void onKeyboardShown() { - if (captionText.hasFocus()) { - mediaRail.setVisibility(View.VISIBLE); - composeContainer.setVisibility(View.GONE); - captionText.setVisibility(View.VISIBLE); - } else if (composeText.hasFocus()) { - mediaRail.setVisibility(View.VISIBLE); - composeContainer.setVisibility(View.VISIBLE); - captionText.setVisibility(View.GONE); - } else { - mediaRail.setVisibility(View.GONE); - composeContainer.setVisibility(View.VISIBLE); - captionText.setVisibility(View.GONE); - } - } - - @Override - public void onKeyboardHidden() { - composeContainer.setVisibility(View.VISIBLE); - mediaRail.setVisibility(View.VISIBLE); - - if (!Util.isEmpty(viewModel.getSelectedMedia().getValue()) && viewModel.getSelectedMedia().getValue().size() > 1) { - captionText.setVisibility(View.VISIBLE); - } - } - - public void onTouchEventsNeeded(boolean needed) { - if (fragmentPager != null) { - fragmentPager.setEnabled(!needed); - } - } - - public boolean handleBackPress() { - if (hud.isInputOpen()) { - hud.hideCurrentInput(composeText); - return true; - } - return false; - } - - private void initViewModel() { - viewModel = new ViewModelProvider(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); - - viewModel.getSelectedMedia().observe(this, media -> { - if (Util.isEmpty(media)) { - controller.onNoMediaAvailable(); - return; - } - - fragmentPagerAdapter.setMedia(media); - - mediaRail.setVisibility(View.VISIBLE); - captionText.setVisibility((media.size() > 1 || media.get(0).getCaption().isPresent()) ? View.VISIBLE : View.GONE); - mediaRailAdapter.setMedia(media); - }); - - viewModel.getPosition().observe(this, position -> { - if (position == null || position < 0) return; - - fragmentPager.setCurrentItem(position, true); - mediaRailAdapter.setActivePosition(position); - mediaRail.smoothScrollToPosition(position); - - if (fragmentPagerAdapter.getAllMedia().size() > position) { - captionText.setText(fragmentPagerAdapter.getAllMedia().get(position).getCaption().or("")); - } - - View playbackControls = fragmentPagerAdapter.getPlaybackControls(position); - - if (playbackControls != null) { - ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - playbackControls.setLayoutParams(params); - playbackControlsContainer.removeAllViews(); - playbackControlsContainer.addView(playbackControls); - } else { - playbackControlsContainer.removeAllViews(); - } - }); - - viewModel.getBucketId().observe(this, bucketId -> { - if (bucketId == null) return; - - mediaRailAdapter.setAddButtonListener(() -> controller.onAddMediaClicked(bucketId)); - }); - } - - private EmojiEditText getActiveInputField() { - if (captionText.hasFocus()) return captionText; - else return composeText; - } - - - private void presentCharactersRemaining() { - String messageBody = composeText.getTextTrimmed(); - CharacterState characterState = characterCalculator.calculateCharacters(messageBody); - - if (characterState.charactersRemaining <= 15 || characterState.messagesSpent > 1) { - charactersLeft.setText(String.format(Locale.getDefault(), - "%d/%d (%d)", - characterState.charactersRemaining, - characterState.maxTotalMessageSize, - characterState.messagesSpent)); - charactersLeft.setVisibility(View.VISIBLE); - } else { - charactersLeft.setVisibility(View.GONE); - } - } - - private void onEmojiToggleClicked(View v) { - if (!emojiDrawer.resolved()) { - emojiDrawer.get().setProviders(0, new EmojiKeyboardProvider(requireContext(), new EmojiEventListener() { - @Override - public void onKeyEvent(KeyEvent keyEvent) { - getActiveInputField().dispatchKeyEvent(keyEvent); - } - - @Override - public void onEmojiSelected(String emoji) { - getActiveInputField().insertEmoji(emoji); - } - })); - emojiToggle.attach(emojiDrawer.get()); - } - - if (hud.getCurrentInput() == emojiDrawer.get()) { - hud.showSoftkey(composeText); - } else { - hud.hideSoftkey(composeText, () -> hud.post(() -> hud.show(composeText, emojiDrawer.get()))); - } - } - - @SuppressLint("StaticFieldLeak") - private void processMedia(@NonNull List mediaList, @NonNull Map savedState) { - Map> futures = new HashMap<>(); - - for (Media media : mediaList) { - Object state = savedState.get(media.getUri()); - - if (state instanceof ImageEditorFragment.Data) { - EditorModel model = ((ImageEditorFragment.Data) state).readModel(); - if (model != null && model.isChanged()) { - futures.put(media, render(requireContext(), model)); - } - } - } - - new AsyncTask>() { - - private Stopwatch renderTimer; - private Runnable progressTimer; - private AlertDialog dialog; - - @Override - protected void onPreExecute() { - renderTimer = new Stopwatch("ProcessMedia"); - progressTimer = () -> { - dialog = new AlertDialog.Builder(new ContextThemeWrapper(requireContext(), R.style.Theme_TextSecure_Dialog_MediaSendProgress)) - .setView(R.layout.progress_dialog) - .setCancelable(false) - .create(); - dialog.show(); - dialog.getWindow().setLayout(getResources().getDimensionPixelSize(R.dimen.mediasend_progress_dialog_size), - getResources().getDimensionPixelSize(R.dimen.mediasend_progress_dialog_size)); - }; - Util.runOnMainDelayed(progressTimer, 250); - } - - @Override - protected List doInBackground(Void... voids) { - Context context = requireContext(); - List updatedMedia = new ArrayList<>(mediaList.size()); - - for (Media media : mediaList) { - if (futures.containsKey(media)) { - try { - Bitmap bitmap = futures.get(media).get(); - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - bitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos); - - Uri uri = BlobProvider.getInstance() - .forData(baos.toByteArray()) - .withMimeType(MediaTypes.IMAGE_JPEG) - .createForSingleSessionOnDisk(context, e -> Log.w(TAG, "Failed to write to disk.", e)); - - Media updated = new Media(uri, MediaTypes.IMAGE_JPEG, media.getDate(), bitmap.getWidth(), bitmap.getHeight(), baos.size(), media.getBucketId(), media.getCaption()); - - updatedMedia.add(updated); - renderTimer.split("item"); - } catch (InterruptedException | ExecutionException | IOException e) { - Log.w(TAG, "Failed to render image. Using base image."); - updatedMedia.add(media); - } - } else { - updatedMedia.add(media); - } - } - return updatedMedia; - } - - @Override - protected void onPostExecute(List media) { - controller.onSendClicked(media, composeText.getTextTrimmed()); - Util.cancelRunnableOnMain(progressTimer); - if (dialog != null) { - dialog.dismiss(); - } - renderTimer.stop(TAG); - } - }.execute(); - } - - private static ListenableFuture render(@NonNull Context context, @NonNull EditorModel model) { - SettableFuture future = new SettableFuture<>(); - - AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> future.set(model.render(context))); - - return future; - } - - public void onRequestFullScreen(boolean fullScreen) { - captionAndRail.setVisibility(fullScreen ? View.GONE : View.VISIBLE); - } - - private class FragmentPageChangeListener extends ViewPager.SimpleOnPageChangeListener { - @Override - public void onPageSelected(int position) { - viewModel.onPageChanged(position); - } - } - - private class ComposeKeyPressedListener implements View.OnKeyListener, View.OnClickListener, TextWatcher, View.OnFocusChangeListener { - - int beforeLength; - - @Override - public boolean onKey(View v, int keyCode, KeyEvent event) { - if (event.getAction() == KeyEvent.ACTION_DOWN) { - if (keyCode == KeyEvent.KEYCODE_ENTER) { - if (TextSecurePreferences.isEnterSendsEnabled(requireContext())) { - sendButton.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)); - sendButton.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER)); - return true; - } - } - } - return false; - } - - @Override - public void onClick(View v) { - hud.showSoftkey(composeText); - } - - @Override - public void beforeTextChanged(CharSequence s, int start, int count,int after) { - beforeLength = composeText.getTextTrimmed().length(); - } - - @Override - public void afterTextChanged(Editable s) { - presentCharactersRemaining(); - viewModel.onBodyChanged(s); - } - - @Override - public void onTextChanged(CharSequence s, int start, int before,int count) {} - - @Override - public void onFocusChange(View v, boolean hasFocus) {} - } - - public interface Controller { - void onAddMediaClicked(@NonNull String bucketId); - void onSendClicked(@NonNull List media, @NonNull String body); - void onNoMediaAvailable(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt new file mode 100644 index 0000000000..f2b122fdf7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt @@ -0,0 +1,430 @@ +package org.thoughtcrime.securesms.mediasend + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import android.widget.TextView +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.viewpager.widget.ViewPager.SimpleOnPageChangeListener +import com.bumptech.glide.Glide +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.future.await +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import network.loki.messenger.databinding.MediasendFragmentBinding +import org.session.libsession.utilities.MediaTypes +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.InputBarDialogs +import org.thoughtcrime.securesms.conversation.v2.ConversationV2Dialogs +import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate +import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel +import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities +import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter +import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter.RailItemListener +import org.thoughtcrime.securesms.providers.BlobUtils +import org.thoughtcrime.securesms.scribbles.ImageEditorFragment +import org.thoughtcrime.securesms.ui.setThemedContent +import org.thoughtcrime.securesms.util.applySafeInsetsPaddings +import org.thoughtcrime.securesms.util.hideKeyboard +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import javax.inject.Inject + +/** + * Allows the user to edit and caption a set of media items before choosing to send them. + */ +@AndroidEntryPoint +class MediaSendFragment : Fragment(), RailItemListener, InputBarDelegate { + private var binding: MediasendFragmentBinding? = null + + private var fragmentPagerAdapter: MediaSendFragmentPagerAdapter? = null + private var mediaRailAdapter: MediaRailAdapter? = null + + private var viewModel: MediaSendViewModel? = null + + private val controller: Controller + get() = (parentFragment as? Controller) ?: requireActivity() as Controller + + // Mentions + private val threadId: Long + get() = arguments?.getLong(KEY_THREADID) ?: -1L + + @Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory + private val mentionViewModel: MentionViewModel by viewModels { + mentionViewModelFactory.create(threadId) + } + + override fun onAttach(context: Context) { + super.onAttach(context) + + viewModel = ViewModelProvider(requireActivity()).get( + MediaSendViewModel::class.java + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return MediasendFragmentBinding.inflate(inflater, container, false).root + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initViewModel() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = MediasendFragmentBinding.bind(view).also { + this.binding = it + } + + binding.mediasendSafeArea.applySafeInsetsPaddings( + applyBottom = false, + alsoApply = { + binding.bottomSpacer.updateLayoutParams { + height = it.bottom + } + } + ) + + binding.inputBar.delegate = this + binding.inputBar.setInputBarEditableFactory(mentionViewModel.editableFactory) + + viewLifecycleOwner.lifecycleScope.launch { + val pretty = mentionViewModel.reconstructMentions(viewModel?.body?.toString().orEmpty()) + binding.inputBar.setText(pretty, TextView.BufferType.EDITABLE) + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel?.inputBarState?.collect { state -> + binding.inputBar.setState(state) + } + } + } + + fragmentPagerAdapter = MediaSendFragmentPagerAdapter(childFragmentManager) + binding.mediasendPager.setAdapter(fragmentPagerAdapter) + + val pageChangeListener = FragmentPageChangeListener() + binding.mediasendPager.addOnPageChangeListener(pageChangeListener) + binding.mediasendPager.post { pageChangeListener.onPageSelected(binding.mediasendPager.currentItem) } + + mediaRailAdapter = MediaRailAdapter(Glide.with(this), this, true) + binding.mediasendMediaRail.setLayoutManager( + LinearLayoutManager( + requireContext(), + LinearLayoutManager.HORIZONTAL, + false + ) + ) + binding.mediasendMediaRail.setAdapter(mediaRailAdapter) + + binding.mediasendCloseButton.setOnClickListener { + binding.inputBar.clearFocus() + binding.root.hideKeyboard() + requireActivity().finish() + } + + // set the compose dialog content + binding.dialogs.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setThemedContent { + if(viewModel == null) return@setThemedContent + val dialogsState by viewModel!!.inputBarStateDialogsState.collectAsState() + InputBarDialogs ( + inputBarDialogsState = dialogsState, + sendCommand = { + viewModel?.onInputBarCommand(it) + } + ) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + + binding = null + } + + override fun onStart() { + super.onStart() + + val viewModel = viewModel + val adapter = fragmentPagerAdapter + + if (viewModel != null && adapter != null) { + adapter.restoreState(viewModel.drawState) + viewModel.onImageEditorStarted() + } + } + + override fun onStop() { + super.onStop() + + val viewModel = viewModel + val adapter = fragmentPagerAdapter + + if (viewModel != null && adapter != null) { + adapter.saveAllState() + viewModel.saveDrawState(adapter.savedState) + } + } + + + override fun onRailItemClicked(distanceFromActive: Int) { + val currentItem = binding?.mediasendPager?.currentItem ?: return + viewModel?.onPageChanged(currentItem + distanceFromActive) + } + + override fun onRailItemDeleteClicked(distanceFromActive: Int) { + val currentItem = binding?.mediasendPager?.currentItem ?: return + + viewModel?.onMediaItemRemoved( + requireContext(), + currentItem + distanceFromActive + ) + } + + fun onTouchEventsNeeded(needed: Boolean) { + binding?.mediasendPager?.isEnabled = !needed + } + + private fun initViewModel() { + val viewModel = requireNotNull(viewModel) { + "ViewModel is not initialized" + } + + viewModel.getSelectedMedia().observe( + this + ) { media: List? -> + if (media.isNullOrEmpty()) { + controller.onNoMediaAvailable() + return@observe + } + + fragmentPagerAdapter?.setMedia(media) + + binding?.mediasendMediaRail?.visibility = View.VISIBLE + mediaRailAdapter?.setMedia(media) + } + + viewModel.getPosition().observe(this) { position: Int? -> + if (position == null || position < 0) return@observe + binding?.mediasendPager?.setCurrentItem(position, true) + mediaRailAdapter?.setActivePosition(position) + binding?.mediasendMediaRail?.smoothScrollToPosition(position) + } + + viewModel.getBucketId().observe(this) { bucketId: String? -> + if (bucketId == null) return@observe + mediaRailAdapter!!.setAddButtonListener { + // save existing text in VM + viewModel.onBodyChanged(mentionViewModel.deconstructMessageMentions()) + + controller.onAddMediaClicked(bucketId) + } + } + } + + private fun processMedia(mediaList: List, savedState: Map) { + val binding = binding ?: return // If the view is destroyed, this process should not continue + + val context = requireContext().applicationContext + + lifecycleScope.launch { + val delayedShowLoader = launch { + delay(250) + binding.loader.isVisible = true + } + + val updatedMedia = supervisorScope { + // For each media, render the image in the background if necessary + val renderingTasks = mediaList + .asSequence() + .map { media -> + media to (savedState[media.uri] as? ImageEditorFragment.Data) + ?.readModel() + ?.takeIf { it.isChanged } + } + .associate { (media, model) -> + media.uri to async { + runCatching { + if (model != null) { + // While we render the bitmap in the background, make sure + // we limit the number of parallel tasks to avoid overwhelming the memory, + // as bitmaps are memory intensive. + withContext(Dispatchers.Default.limitedParallelism(2)) { + val bitmap = model.render(context) + try { + // Compress the bitmap to JPEG + val jpegOut = requireNotNull( + File.createTempFile( + "media_preview", + ".jpg", + context.cacheDir + ) + ) { + "Unable to create temporary file" + } + + val (jpegSize, uri) = try { + FileOutputStream(jpegOut).use { out -> + bitmap.compress( + Bitmap.CompressFormat.JPEG, + 80, + out + ) + } + + // Once we have the JPEG file, save it as our blob + val jpegSize = jpegOut.length() + jpegSize to BlobUtils.getInstance() + .forData(FileInputStream(jpegOut), jpegSize) + .withMimeType(MediaTypes.IMAGE_JPEG) + .withFileName(media.filename) + .createForSingleSessionOnDisk(context, null) + .await() + } finally { + // Clean up the temporary file + jpegOut.delete() + } + + media.copy( + uri = uri, + mimeType = MediaTypes.IMAGE_JPEG, + width = bitmap.width, + height = bitmap.height, + size = jpegSize, + ) + } finally { + bitmap.recycle() + } + } + } else { + // No changes to the original media, copy and return as is + val newUri = BlobUtils.getInstance() + .forData(requireNotNull(context.contentResolver.openInputStream(media.uri)) { + "Invalid URI" + }, media.size) + .withMimeType(media.mimeType) + .withFileName(media.filename) + .createForSingleSessionOnDisk(context, null) + .await() + + media.copy(uri = newUri) + } + } + } + } + + // For each media, if there's a rendered version, use that or keep the original + mediaList.map { media -> + renderingTasks[media.uri]?.await()?.let { rendered -> + if (rendered.isFailure) { + Log.w(TAG, "Error rendering image", rendered.exceptionOrNull()) + media + } else { + rendered.getOrThrow() + } + } ?: media + } + } + + controller.onSendClicked(updatedMedia, mentionViewModel.normalizeMessageBody()) + delayedShowLoader.cancel() + binding.loader.isVisible = false + binding.inputBar.clearFocus() + binding.root.hideKeyboard() + } + } + + fun onRequestFullScreen(fullScreen: Boolean) { + binding?.mediasendCaptionAndRail?.visibility = + if (fullScreen) View.GONE else View.VISIBLE + } + + override fun inputBarEditTextContentChanged(newContent: CharSequence) { + // use the normalised version of the text's body to get the characters amount with the + // mentions as their account id + viewModel?.onTextChanged(mentionViewModel.deconstructMessageMentions()) + } + + override fun sendMessage() { + // validate message length before sending + if(viewModel == null || viewModel?.validateMessageLength() == false) return + + fragmentPagerAdapter?.let { processMedia(it.allMedia, it.savedState) } + } + + override fun onCharLimitTapped() { + viewModel?.onCharLimitTapped() + } + + // Unused callbacks + override fun commitInputContent(contentUri: Uri) {} + override fun toggleAttachmentOptions() {} + override fun showVoiceMessageUI() {} + override fun startRecordingVoiceMessage() {} + override fun onMicrophoneButtonMove(event: MotionEvent) {} + override fun onMicrophoneButtonCancel(event: MotionEvent) {} + override fun onMicrophoneButtonUp(event: MotionEvent) {} + + private inner class FragmentPageChangeListener : SimpleOnPageChangeListener() { + override fun onPageSelected(position: Int) { + viewModel!!.onPageChanged(position) + } + } + + interface Controller { + fun onAddMediaClicked(bucketId: String) + fun onSendClicked(media: List, body: String) + fun onNoMediaAvailable() + } + + companion object { + private val TAG: String = MediaSendFragment::class.java.simpleName + + private const val KEY_ADDRESS = "address" + private const val KEY_THREADID = "threadid" + + fun newInstance(recipient: Recipient, threadId: Long): MediaSendFragment { + val args = Bundle() + args.putParcelable(KEY_ADDRESS, recipient.address) + args.putLong(KEY_THREADID, threadId) + + val fragment = MediaSendFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java index 4d6107044f..fc56b04609 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java @@ -17,6 +17,8 @@ import java.util.List; import java.util.Map; +import kotlin.collections.CollectionsKt; + class MediaSendFragmentPagerAdapter extends FragmentStatePagerAdapter { private final List media; @@ -83,7 +85,7 @@ public int getCount() { } List getAllMedia() { - return media; + return CollectionsKt.toList(media); } void setMedia(@NonNull List media) { @@ -116,7 +118,4 @@ void restoreState(@NonNull Map state) { savedState.putAll(state); } - @Nullable View getPlaybackControls(int position) { - return fragments.containsKey(position) ? fragments.get(position).getPlaybackControls() : null; - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendGifFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendGifFragment.java index 41293c6256..d1bd4c11ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendGifFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendGifFragment.java @@ -53,11 +53,6 @@ public void setUri(@NonNull Uri uri) { return uri; } - @Override - public @Nullable View getPlaybackControls() { - return null; - } - @Override public @Nullable Object saveState() { return null; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendPageFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendPageFragment.java index 6de248489f..1456e6c690 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendPageFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendPageFragment.java @@ -14,8 +14,6 @@ public interface MediaSendPageFragment { void setUri(@NonNull Uri uri); - @Nullable View getPlaybackControls(); - @Nullable Object saveState(); void restoreState(@NonNull Object state); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendVideoFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendVideoFragment.java index 825012c032..309ebeb7f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendVideoFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendVideoFragment.java @@ -2,23 +2,21 @@ import android.net.Uri; import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.OptIn; import androidx.fragment.app.Fragment; import androidx.media3.common.util.UnstableApi; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - +import java.io.IOException; import network.loki.messenger.R; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.mms.VideoSlide; +import org.thoughtcrime.securesms.util.FilenameUtils; import org.thoughtcrime.securesms.video.VideoPlayer; -import java.io.IOException; - @OptIn(markerClass = UnstableApi.class) public class MediaSendVideoFragment extends Fragment implements MediaSendPageFragment { @@ -47,8 +45,15 @@ public static MediaSendVideoFragment newInstance(@NonNull Uri uri) { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - uri = getArguments().getParcelable(KEY_URI); - VideoSlide slide = new VideoSlide(requireContext(), uri, 0); + if (getArguments() != null) { + uri = getArguments().getParcelable(KEY_URI); + } else { + Log.w(TAG, "Could not get uri from arguments - bailing."); + return; + } + + String filename = FilenameUtils.getFilenameFromUri(requireContext(), uri); + VideoSlide slide = new VideoSlide(requireContext(), uri, filename, 0); try { ((VideoPlayer) view).setWindow(requireActivity().getWindow()); ((VideoPlayer) view).setVideoSource(slide, false); @@ -60,32 +65,17 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat @Override public void onDestroyView() { super.onDestroyView(); - - if (getView() != null) { - ((VideoPlayer) getView()).cleanup(); - } - } - - @Override - public void setUri(@NonNull Uri uri) { - this.uri = uri; + if (getView() != null) { ((VideoPlayer)getView()).cleanup(); } } @Override - public @NonNull Uri getUri() { - return uri; - } + public void setUri(@NonNull Uri uri) { this.uri = uri; } @Override - public @Nullable View getPlaybackControls() { - VideoPlayer player = (VideoPlayer) getView(); - return player != null ? player.getControlView() : null; - } + public @NonNull Uri getUri() { return uri; } @Override - public @Nullable Object saveState() { - return null; - } + public @Nullable Object saveState() { return null; } @Override public void restoreState(@NonNull Object state) { } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java deleted file mode 100644 index 5336503589..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java +++ /dev/null @@ -1,369 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.app.Application; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.ViewModel; -import androidx.lifecycle.ViewModelProvider; -import android.content.Context; -import android.net.Uri; -import androidx.annotation.NonNull; -import android.text.TextUtils; - -import com.annimon.stream.Stream; - -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.mms.MediaConstraints; -import org.thoughtcrime.securesms.providers.BlobProvider; -import org.thoughtcrime.securesms.util.MediaUtil; -import org.thoughtcrime.securesms.util.SingleLiveEvent; -import org.session.libsession.utilities.Util; -import org.session.libsignal.utilities.guava.Optional; - -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; - -/** - * Manages the observable datasets available in {@link MediaSendActivity}. - */ -class MediaSendViewModel extends ViewModel { - - private static final String TAG = MediaSendViewModel.class.getSimpleName(); - - private static final int MAX_SELECTION = 32; - - private final Application application; - private final MediaRepository repository; - private final MutableLiveData> selectedMedia; - private final MutableLiveData> bucketMedia; - private final MutableLiveData position; - private final MutableLiveData bucketId; - private final MutableLiveData> folders; - private final MutableLiveData countButtonState; - private final MutableLiveData cameraButtonVisibility; - private final SingleLiveEvent error; - private final Map savedDrawState; - - private final MediaConstraints mediaConstraints = MediaConstraints.getPushMediaConstraints(); - - private CharSequence body; - private CountButtonState.Visibility countButtonVisibility; - private boolean sentMedia; - private Optional lastImageCapture; - - private MediaSendViewModel(@NonNull Application application, @NonNull MediaRepository repository) { - this.application = application; - this.repository = repository; - this.selectedMedia = new MutableLiveData<>(); - this.bucketMedia = new MutableLiveData<>(); - this.position = new MutableLiveData<>(); - this.bucketId = new MutableLiveData<>(); - this.folders = new MutableLiveData<>(); - this.countButtonState = new MutableLiveData<>(); - this.cameraButtonVisibility = new MutableLiveData<>(); - this.error = new SingleLiveEvent<>(); - this.savedDrawState = new HashMap<>(); - this.countButtonVisibility = CountButtonState.Visibility.FORCED_OFF; - this.lastImageCapture = Optional.absent(); - this.body = ""; - - position.setValue(-1); - countButtonState.setValue(new CountButtonState(0, countButtonVisibility)); - cameraButtonVisibility.setValue(false); - } - - void onSelectedMediaChanged(@NonNull Context context, @NonNull List newMedia) { - repository.getPopulatedMedia(context, newMedia, populatedMedia -> { - Util.runOnMain(() -> { - - List filteredMedia = getFilteredMedia(context, populatedMedia, mediaConstraints); - - if (filteredMedia.size() != newMedia.size()) { - error.setValue(Error.ITEM_TOO_LARGE); - } else if (filteredMedia.size() > MAX_SELECTION) { - filteredMedia = filteredMedia.subList(0, MAX_SELECTION); - error.setValue(Error.TOO_MANY_ITEMS); - } - - if (filteredMedia.size() > 0) { - String computedId = Stream.of(filteredMedia) - .skip(1) - .reduce(filteredMedia.get(0).getBucketId().or(Media.ALL_MEDIA_BUCKET_ID), (id, m) -> { - if (Util.equals(id, m.getBucketId().or(Media.ALL_MEDIA_BUCKET_ID))) { - return id; - } else { - return Media.ALL_MEDIA_BUCKET_ID; - } - }); - bucketId.setValue(computedId); - } else { - bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID); - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL; - } - - selectedMedia.setValue(filteredMedia); - countButtonState.setValue(new CountButtonState(filteredMedia.size(), countButtonVisibility)); - }); - }); - } - - void onSingleMediaSelected(@NonNull Context context, @NonNull Media media) { - repository.getPopulatedMedia(context, Collections.singletonList(media), populatedMedia -> { - Util.runOnMain(() -> { - List filteredMedia = getFilteredMedia(context, populatedMedia, mediaConstraints); - - if (filteredMedia.isEmpty()) { - error.setValue(Error.ITEM_TOO_LARGE); - bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID); - } else { - bucketId.setValue(filteredMedia.get(0).getBucketId().or(Media.ALL_MEDIA_BUCKET_ID)); - } - - countButtonVisibility = CountButtonState.Visibility.FORCED_OFF; - - selectedMedia.setValue(filteredMedia); - countButtonState.setValue(new CountButtonState(filteredMedia.size(), countButtonVisibility)); - }); - }); - } - - void onMultiSelectStarted() { - countButtonVisibility = CountButtonState.Visibility.FORCED_ON; - countButtonState.setValue(new CountButtonState(getSelectedMediaOrDefault().size(), countButtonVisibility)); - } - - void onImageEditorStarted() { - countButtonVisibility = CountButtonState.Visibility.FORCED_OFF; - countButtonState.setValue(new CountButtonState(getSelectedMediaOrDefault().size(), countButtonVisibility)); - cameraButtonVisibility.setValue(false); - } - - void onCameraStarted() { - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL; - countButtonState.setValue(new CountButtonState(getSelectedMediaOrDefault().size(), countButtonVisibility)); - cameraButtonVisibility.setValue(false); - } - - void onItemPickerStarted() { - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL; - countButtonState.setValue(new CountButtonState(getSelectedMediaOrDefault().size(), countButtonVisibility)); - cameraButtonVisibility.setValue(true); - } - - void onFolderPickerStarted() { - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL; - countButtonState.setValue(new CountButtonState(getSelectedMediaOrDefault().size(), countButtonVisibility)); - cameraButtonVisibility.setValue(true); - } - - void onBodyChanged(@NonNull CharSequence body) { - this.body = body; - } - - void onFolderSelected(@NonNull String bucketId) { - this.bucketId.setValue(bucketId); - bucketMedia.setValue(Collections.emptyList()); - } - - void onPageChanged(int position) { - if (position < 0 || position >= getSelectedMediaOrDefault().size()) { - Log.w(TAG, "Tried to move to an out-of-bounds item. Size: " + getSelectedMediaOrDefault().size() + ", position: " + position); - return; - } - - this.position.setValue(position); - } - - void onMediaItemRemoved(@NonNull Context context, int position) { - if (position < 0 || position >= getSelectedMediaOrDefault().size()) { - Log.w(TAG, "Tried to remove an out-of-bounds item. Size: " + getSelectedMediaOrDefault().size() + ", position: " + position); - return; - } - - Media removed = getSelectedMediaOrDefault().remove(position); - - if (removed != null && BlobProvider.isAuthority(removed.getUri())) { - BlobProvider.getInstance().delete(context, removed.getUri()); - } - - selectedMedia.setValue(selectedMedia.getValue()); - } - - void onImageCaptured(@NonNull Media media) { - List selected = selectedMedia.getValue(); - - if (selected == null) { - selected = new LinkedList<>(); - } - - if (selected.size() >= MAX_SELECTION) { - error.setValue(Error.TOO_MANY_ITEMS); - return; - } - - lastImageCapture = Optional.of(media); - - selected.add(media); - selectedMedia.setValue(selected); - position.setValue(selected.size() - 1); - bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID); - - if (selected.size() == 1) { - countButtonVisibility = CountButtonState.Visibility.FORCED_OFF; - } else { - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL; - } - - countButtonState.setValue(new CountButtonState(selected.size(), countButtonVisibility)); - } - - void onImageCaptureUndo(@NonNull Context context) { - List selected = getSelectedMediaOrDefault(); - - if (lastImageCapture.isPresent() && selected.contains(lastImageCapture.get()) && selected.size() == 1) { - selected.remove(lastImageCapture.get()); - selectedMedia.setValue(selected); - countButtonState.setValue(new CountButtonState(selected.size(), countButtonVisibility)); - BlobProvider.getInstance().delete(context, lastImageCapture.get().getUri()); - } - } - - - void onCaptionChanged(@NonNull String newCaption) { - if (position.getValue() >= 0 && !Util.isEmpty(selectedMedia.getValue())) { - selectedMedia.getValue().get(position.getValue()).setCaption(TextUtils.isEmpty(newCaption) ? null : newCaption); - } - } - - void saveDrawState(@NonNull Map state) { - savedDrawState.clear(); - savedDrawState.putAll(state); - } - - void onSendClicked() { - sentMedia = true; - } - - @NonNull Map getDrawState() { - return savedDrawState; - } - - @NonNull LiveData> getSelectedMedia() { - return selectedMedia; - } - - @NonNull LiveData> getMediaInBucket(@NonNull Context context, @NonNull String bucketId) { - repository.getMediaInBucket(context, bucketId, bucketMedia::postValue); - return bucketMedia; - } - - @NonNull LiveData> getFolders(@NonNull Context context) { - repository.getFolders(context, folders::postValue); - return folders; - } - - @NonNull LiveData getCountButtonState() { - return countButtonState; - } - - @NonNull LiveData getCameraButtonVisibility() { - return cameraButtonVisibility; - } - - @NonNull CharSequence getBody() { - return body; - } - - @NonNull LiveData getPosition() { - return position; - } - - @NonNull LiveData getBucketId() { - return bucketId; - } - - @NonNull LiveData getError() { - return error; - } - - int getMaxSelection() { - return MAX_SELECTION; - } - - private @NonNull List getSelectedMediaOrDefault() { - return selectedMedia.getValue() == null ? Collections.emptyList() - : selectedMedia.getValue(); - } - - private @NonNull List getFilteredMedia(@NonNull Context context, @NonNull List media, @NonNull MediaConstraints mediaConstraints) { - return Stream.of(media).filter(m -> MediaUtil.isGif(m.getMimeType()) || - MediaUtil.isImageType(m.getMimeType()) || - MediaUtil.isVideoType(m.getMimeType())) - .filter(m -> { - return (MediaUtil.isImageType(m.getMimeType()) && !MediaUtil.isGif(m.getMimeType())) || - (MediaUtil.isGif(m.getMimeType()) && m.getSize() < mediaConstraints.getGifMaxSize(context)) || - (MediaUtil.isVideoType(m.getMimeType()) && m.getSize() < mediaConstraints.getVideoMaxSize(context)); - }).toList(); - - } - - @Override - protected void onCleared() { - if (!sentMedia) { - Stream.of(getSelectedMediaOrDefault()) - .map(Media::getUri) - .filter(BlobProvider::isAuthority) - .forEach(uri -> BlobProvider.getInstance().delete(application.getApplicationContext(), uri)); - } - } - - enum Error { - ITEM_TOO_LARGE, TOO_MANY_ITEMS - } - - static class CountButtonState { - private final int count; - private final Visibility visibility; - - private CountButtonState(int count, @NonNull Visibility visibility) { - this.count = count; - this.visibility = visibility; - } - - int getCount() { - return count; - } - - boolean isVisible() { - switch (visibility) { - case FORCED_ON: return true; - case FORCED_OFF: return false; - case CONDITIONAL: return count > 0; - default: return false; - } - } - - enum Visibility { - CONDITIONAL, FORCED_ON, FORCED_OFF - } - } - - static class Factory extends ViewModelProvider.NewInstanceFactory { - - private final Application application; - private final MediaRepository repository; - - Factory(@NonNull Application application, @NonNull MediaRepository repository) { - this.application = application; - this.repository = repository; - } - - @Override - public @NonNull T create(@NonNull Class modelClass) { - return modelClass.cast(new MediaSendViewModel(application, repository)); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt new file mode 100644 index 0000000000..944793b07c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt @@ -0,0 +1,373 @@ +package org.thoughtcrime.securesms.mediasend + +import android.app.Application +import android.content.Context +import android.net.Uri +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.annimon.stream.Stream +import dagger.hilt.android.lifecycle.HiltViewModel +import org.session.libsession.utilities.Util.equals +import org.session.libsession.utilities.Util.runOnMain +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.InputbarViewModel +import org.thoughtcrime.securesms.mms.MediaConstraints +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.providers.BlobUtils +import org.thoughtcrime.securesms.util.MediaUtil +import org.thoughtcrime.securesms.util.SingleLiveEvent +import java.util.LinkedList +import javax.inject.Inject + +/** + * Manages the observable datasets available in [MediaSendActivity]. + */ + +@HiltViewModel +internal class MediaSendViewModel @Inject constructor( + private val application: Application, + private val proStatusManager: ProStatusManager, +) : InputbarViewModel( + application = application, + proStatusManager = proStatusManager +) { + private val selectedMedia: MutableLiveData?> + private val bucketMedia: MutableLiveData> + private val position: MutableLiveData + private val bucketId: MutableLiveData + private val folders: MutableLiveData> + private val countButtonState: MutableLiveData + private val cameraButtonVisibility: MutableLiveData + private val error: SingleLiveEvent + private val savedDrawState: MutableMap + + private val mediaConstraints: MediaConstraints = MediaConstraints.getPushMediaConstraints() + private val repository: MediaRepository = MediaRepository() + + var body: CharSequence + private set + private var countButtonVisibility: CountButtonState.Visibility + private var sentMedia: Boolean = false + private var lastImageCapture: Optional + + init { + this.selectedMedia = MutableLiveData() + this.bucketMedia = MutableLiveData() + this.position = MutableLiveData() + this.bucketId = MutableLiveData() + this.folders = MutableLiveData() + this.countButtonState = MutableLiveData() + this.cameraButtonVisibility = MutableLiveData() + this.error = SingleLiveEvent() + this.savedDrawState = HashMap() + this.countButtonVisibility = CountButtonState.Visibility.FORCED_OFF + this.lastImageCapture = Optional.absent() + this.body = "" + + position.value = -1 + countButtonState.value = CountButtonState(0, countButtonVisibility) + cameraButtonVisibility.value = false + } + + fun onSelectedMediaChanged(context: Context, newMedia: List) { + repository.getPopulatedMedia(context, newMedia, + { populatedMedia: List -> + runOnMain( + { + var filteredMedia: List = + getFilteredMedia(context, populatedMedia, mediaConstraints) + if (filteredMedia.size != newMedia.size) { + error.setValue(Error.ITEM_TOO_LARGE) + } else if (filteredMedia.size > MAX_SELECTED_FILES) { + filteredMedia = filteredMedia.subList(0, MAX_SELECTED_FILES) + error.setValue(Error.TOO_MANY_ITEMS) + } + + if (filteredMedia.size > 0) { + val computedId: String = Stream.of(filteredMedia) + .skip(1) + .reduce(filteredMedia.get(0).bucketId ?: Media.ALL_MEDIA_BUCKET_ID, + { id: String?, m: Media -> + if (equals(id, m.bucketId ?: Media.ALL_MEDIA_BUCKET_ID)) { + return@reduce id + } else { + return@reduce Media.ALL_MEDIA_BUCKET_ID + } + }) + bucketId.setValue(computedId) + } else { + bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID) + countButtonVisibility = CountButtonState.Visibility.CONDITIONAL + } + + selectedMedia.setValue(filteredMedia) + countButtonState.setValue( + CountButtonState( + filteredMedia.size, + countButtonVisibility + ) + ) + }) + }) + } + + fun onSingleMediaSelected(context: Context, media: Media) { + repository.getPopulatedMedia(context, listOf(media), + { populatedMedia: List -> + runOnMain( + { + val filteredMedia: List = + getFilteredMedia(context, populatedMedia, mediaConstraints) + if (filteredMedia.isEmpty()) { + error.setValue(Error.ITEM_TOO_LARGE) + bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID) + } else { + bucketId.setValue(filteredMedia.get(0).bucketId ?: Media.ALL_MEDIA_BUCKET_ID) + } + + countButtonVisibility = CountButtonState.Visibility.FORCED_OFF + + selectedMedia.value = filteredMedia + countButtonState.setValue( + CountButtonState( + filteredMedia.size, + countButtonVisibility + ) + ) + }) + }) + } + + fun onMultiSelectStarted() { + countButtonVisibility = CountButtonState.Visibility.FORCED_ON + countButtonState.value = + CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) + } + + fun onImageEditorStarted() { + countButtonVisibility = CountButtonState.Visibility.FORCED_OFF + countButtonState.value = + CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) + cameraButtonVisibility.value = false + } + + fun onCameraStarted() { + countButtonVisibility = CountButtonState.Visibility.CONDITIONAL + countButtonState.value = + CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) + cameraButtonVisibility.value = false + } + + fun onItemPickerStarted() { + countButtonVisibility = CountButtonState.Visibility.CONDITIONAL + countButtonState.value = + CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) + cameraButtonVisibility.value = true + } + + fun onFolderPickerStarted() { + countButtonVisibility = CountButtonState.Visibility.CONDITIONAL + countButtonState.value = + CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) + cameraButtonVisibility.value = true + } + + fun onBodyChanged(body: CharSequence) { + this.body = body + } + + fun onFolderSelected(bucketId: String) { + this.bucketId.value = bucketId + bucketMedia.value = + emptyList() + } + + fun onPageChanged(position: Int) { + if (position < 0 || position >= selectedMediaOrDefault.size) { + Log.w(TAG, + "Tried to move to an out-of-bounds item. Size: " + selectedMediaOrDefault.size + ", position: " + position + ) + return + } + + this.position.value = position + } + + fun onMediaItemRemoved(context: Context, position: Int) { + if (position < 0 || position >= selectedMediaOrDefault.size) { + Log.w( + TAG, + "Tried to remove an out-of-bounds item. Size: " + selectedMediaOrDefault.size + ", position: " + position + ) + return + } + + val updatedList = selectedMediaOrDefault.toMutableList() + val removed: Media = updatedList.removeAt(position) + + if (BlobUtils.isAuthority(removed.uri)) { + BlobUtils.getInstance().delete(context, removed.uri) + } + + selectedMedia.setValue(updatedList) + } + + fun onImageCaptured(media: Media) { + var selected: MutableList? = selectedMedia.value?.toMutableList() + + if (selected == null) { + selected = LinkedList() + } + + if (selected.size >= MAX_SELECTED_FILES) { + error.setValue(Error.TOO_MANY_ITEMS) + return + } + + lastImageCapture = Optional.of(media) + + selected.add(media) + selectedMedia.setValue(selected) + position.setValue(selected.size - 1) + bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID) + + if (selected.size == 1) { + countButtonVisibility = CountButtonState.Visibility.FORCED_OFF + } else { + countButtonVisibility = CountButtonState.Visibility.CONDITIONAL + } + + countButtonState.setValue(CountButtonState(selected.size, countButtonVisibility)) + } + + fun onImageCaptureUndo(context: Context) { + val selected: MutableList = selectedMediaOrDefault.toMutableList() + + if (lastImageCapture.isPresent && selected.contains(lastImageCapture.get()) && selected.size == 1) { + selected.remove(lastImageCapture.get()) + selectedMedia.value = selected + countButtonState.value = CountButtonState(selected.size, countButtonVisibility) + BlobUtils.getInstance().delete(context, lastImageCapture.get().uri) + } + } + + fun saveDrawState(state: Map) { + savedDrawState.clear() + savedDrawState.putAll(state) + } + + fun onSendClicked() { + sentMedia = true + } + + val drawState: Map + get() = savedDrawState + + fun getSelectedMedia(): LiveData?> { + return selectedMedia + } + + fun getMediaInBucket(context: Context, bucketId: String): LiveData> { + repository.getMediaInBucket(context, bucketId, + { value: List -> bucketMedia.postValue(value) }) + return bucketMedia + } + + fun getFolders(context: Context): LiveData> { + repository.getFolders(context, + { value: List -> folders.postValue(value) }) + return folders + } + + fun getCountButtonState(): LiveData { + return countButtonState + } + + fun getCameraButtonVisibility(): LiveData { + return cameraButtonVisibility + } + + fun getPosition(): LiveData { + return position + } + + fun getBucketId(): LiveData { + return bucketId + } + + fun getError(): LiveData { + return error + } + + private val selectedMediaOrDefault: List + get() = if (selectedMedia.value == null) emptyList() else + selectedMedia.value!! + + private fun getFilteredMedia( + context: Context, + media: List, + mediaConstraints: MediaConstraints + ): List { + return Stream.of(media).filter( + { m: Media -> + MediaUtil.isGif(m.mimeType) || + MediaUtil.isImageType(m.mimeType) || + MediaUtil.isVideoType(m.mimeType) + }) + .filter({ m: Media -> + (MediaUtil.isImageType(m.mimeType) && !MediaUtil.isGif(m.mimeType)) || + (MediaUtil.isGif(m.mimeType) && m.size < mediaConstraints.getGifMaxSize( + context + )) || + (MediaUtil.isVideoType(m.mimeType) && m.size < mediaConstraints.getVideoMaxSize( + context + )) + }).toList() + } + + override fun onCleared() { + if (!sentMedia) { + Stream.of(selectedMediaOrDefault) + .map({ obj: Media -> obj.uri }) + .filter({ uri: Uri? -> + BlobUtils.isAuthority( + uri!! + ) + }) + .forEach({ uri: Uri? -> + BlobUtils.getInstance().delete( + application.applicationContext, uri!! + ) + }) + } + } + + internal enum class Error { + ITEM_TOO_LARGE, TOO_MANY_ITEMS + } + + internal class CountButtonState(val count: Int, private val visibility: Visibility) { + val isVisible: Boolean + get() { + when (visibility) { + Visibility.FORCED_ON -> return true + Visibility.FORCED_OFF -> return false + Visibility.CONDITIONAL -> return count > 0 + else -> return false + } + } + + internal enum class Visibility { + CONDITIONAL, FORCED_ON, FORCED_OFF + } + } + + companion object { + private val TAG: String = MediaSendViewModel::class.java.simpleName + + // the maximum amount of files that can be selected to send as attachment + const val MAX_SELECTED_FILES: Int = 32 + } +} 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 2faac58487..a10c5817d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt @@ -2,9 +2,14 @@ package org.thoughtcrime.securesms.messagerequests import android.content.Context import android.content.res.Resources +import android.graphics.drawable.ColorDrawable import android.util.AttributeSet +import android.util.TypedValue import android.view.LayoutInflater +import android.view.View import android.widget.LinearLayout +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import network.loki.messenger.R import network.loki.messenger.databinding.ViewMessageRequestBinding @@ -12,14 +17,27 @@ import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions import org.thoughtcrime.securesms.database.model.ThreadRecord import com.bumptech.glide.RequestManager +import dagger.hilt.android.AndroidEntryPoint +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.ui.ProBadgeText +import org.thoughtcrime.securesms.ui.setThemedContent +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.bold import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.UnreadStylingHelper +import org.thoughtcrime.securesms.util.getAccentColor import java.util.Locale +import javax.inject.Inject +@AndroidEntryPoint class MessageRequestView : LinearLayout { private lateinit var binding: ViewMessageRequestBinding private val screenWidth = Resources.getSystem().displayMetrics.widthPixels var thread: ThreadRecord? = null + @Inject lateinit var proStatusManager: ProStatusManager + // region Lifecycle constructor(context: Context) : super(context) { initialize() } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } @@ -32,13 +50,41 @@ class MessageRequestView : LinearLayout { // endregion // region Updating - fun bind(thread: ThreadRecord, glide: RequestManager) { + fun bind(thread: ThreadRecord, dateUtils: DateUtils) { this.thread = thread val senderDisplayName = getUserDisplayName(thread.recipient) ?: thread.recipient.address.toString() + val unreadCount = thread.unreadCount + val isUnread = unreadCount > 0 && !thread.isRead + + binding.root.background = UnreadStylingHelper.getUnreadBackground(context, isUnread) + + binding.accentView.apply { + this.background = UnreadStylingHelper.getAccentBackground(context) + visibility = if(isUnread) View.VISIBLE else View.INVISIBLE + } + + binding.unreadCountTextView.apply{ + text = UnreadStylingHelper.formatUnreadCount(unreadCount) + isVisible = isUnread + } + + binding.displayName.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setThemedContent { + ProBadgeText( + text = senderDisplayName, + textStyle = LocalType.current.h8.bold().copy(color = LocalColors.current.text), + showBadge = proStatusManager.shouldShowProBadge(thread.recipient.address) + && !thread.recipient.isLocalNumber, + ) + } + } + + binding.timestampTextView.text = dateUtils.getDisplayFormattedTimeSpanString( + thread.date + ) - binding.displayNameTextView.text = senderDisplayName - binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date) val snippet = highlightMentions( text = thread.getDisplayBody(context), formatOnly = true, // no styling here, only text formatting @@ -46,7 +92,10 @@ class MessageRequestView : LinearLayout { context = context ) - binding.snippetTextView.text = snippet + binding.snippetTextView.apply { + text = snippet + typeface = UnreadStylingHelper.getUnreadTypeface(isUnread) + } post { binding.profilePictureView.update(thread.recipient) 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 442a455da2..004c8a7050 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt @@ -3,45 +3,50 @@ package org.thoughtcrime.securesms.messagerequests import android.content.Intent import android.database.Cursor import android.os.Bundle +import android.view.ViewGroup.MarginLayoutParams import androidx.activity.viewModels import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding import androidx.loader.app.LoaderManager import androidx.loader.content.Loader +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestManager import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ActivityMessageRequestsBinding -import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.Address +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord -import com.bumptech.glide.Glide -import com.bumptech.glide.RequestManager import org.thoughtcrime.securesms.showSessionDialog -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.applySafeInsetsPaddings import org.thoughtcrime.securesms.util.push @AndroidEntryPoint -class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), ConversationClickListener, LoaderManager.LoaderCallbacks { +class MessageRequestsActivity : ScreenLockActionBarActivity(), ConversationClickListener, LoaderManager.LoaderCallbacks { private lateinit var binding: ActivityMessageRequestsBinding private lateinit var glide: RequestManager @Inject lateinit var threadDb: ThreadDatabase + @Inject lateinit var dateUtils: DateUtils private val viewModel: MessageRequestsViewModel by viewModels() private val adapter: MessageRequestsAdapter by lazy { - MessageRequestsAdapter(context = this, cursor = null, listener = this) + MessageRequestsAdapter(context = this, cursor = null, dateUtils = dateUtils, listener = this) } + override val applyDefaultWindowInsets: Boolean + get() = false + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { super.onCreate(savedInstanceState, ready) binding = ActivityMessageRequestsBinding.inflate(layoutInflater) @@ -54,6 +59,10 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat binding.recyclerView.adapter = adapter binding.clearAllMessageRequestsButton.setOnClickListener { deleteAll() } + + binding.root.applySafeInsetsPaddings( + applyBottom = false, + ) } override fun onResume() { @@ -92,7 +101,7 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat showSessionDialog { title(R.string.block) text(Phrase.from(context, R.string.blockDescription) - .put(NAME_KEY, thread.recipient.toShortString()) + .put(NAME_KEY, thread.recipient.name) .format()) dangerButton(R.string.block, R.string.AccessibilityId_blockConfirm) { doBlock() @@ -109,7 +118,7 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat showSessionDialog { title(R.string.delete) - text(resources.getString(R.string.messageRequestsDelete)) + text(resources.getString(R.string.messageRequestsContactDelete)) dangerButton(R.string.delete) { doDecline() } button(R.string.cancel) } 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 a9e699dcec..409dfa43e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt @@ -16,10 +16,12 @@ import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent import com.bumptech.glide.RequestManager +import org.thoughtcrime.securesms.util.DateUtils class MessageRequestsAdapter( context: Context, cursor: Cursor?, + val dateUtils: DateUtils, val listener: ConversationClickListener ) : CursorRecyclerViewAdapter(context, cursor) { private val threadDatabase = DatabaseComponent.get(context).threadDatabase() @@ -44,7 +46,7 @@ class MessageRequestsAdapter( override fun onBindItemViewHolder(viewHolder: ViewHolder, cursor: Cursor) { val thread = getThread(cursor)!! - viewHolder.view.bind(thread, glide) + viewHolder.view.bind(thread, dateUtils) } override fun onItemViewRecycled(holder: ViewHolder?) { 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 d9003d005a..720ac2b31c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt @@ -16,7 +16,7 @@ class MessageRequestsViewModel @Inject constructor( // We assume thread.recipient is a contact or thread.invitingAdmin is not null fun blockMessageRequest(thread: ThreadRecord, blockRecipient: Recipient) = viewModelScope.launch { - repository.setBlocked(thread.threadId, blockRecipient, true) + repository.setBlocked(blockRecipient, true) deleteMessageRequest(thread) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migration/DatabaseMigrationManager.kt b/app/src/main/java/org/thoughtcrime/securesms/migration/DatabaseMigrationManager.kt new file mode 100644 index 0000000000..7cbede4d86 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migration/DatabaseMigrationManager.kt @@ -0,0 +1,266 @@ +package org.thoughtcrime.securesms.migration + +import android.app.Application +import android.os.SystemClock +import androidx.annotation.StringRes +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import net.zetetic.database.sqlcipher.SQLiteConnection +import net.zetetic.database.sqlcipher.SQLiteDatabase +import net.zetetic.database.sqlcipher.SQLiteDatabaseHook +import network.loki.messenger.R +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DatabaseMigrationManager @Inject constructor( + private val application: Application, + private val prefs: TextSecurePreferences, + private val databaseSecretProvider: DatabaseSecretProvider, + @param:ManagerScope private val scope: CoroutineScope, +) : OnAppStartupComponent { + private val dbSecret by lazy { + databaseSecretProvider.getOrCreateDatabaseSecret() + } + + // Access to openHelper: it's guaranteed to be created after the migration is done. + val openHelper: SQLCipherOpenHelper by lazy { + requestMigration(false) + + // First perform a cheap check to see if the migration is done, if so we can skip the wait. + if (mutableMigrationState.value != MigrationState.Completed) { + // Wait until the migration is done. This is a semi-expensive call but it's necessary + // to block the callers from accessing the database until we have sorted out the migration + // process. Note that we don't pass in errors here as the callers of this function + // don't expect the exceptions at all so we have no choice but to block them. + runBlocking { + migrationState.first { it == MigrationState.Completed } + } + } + + SQLCipherOpenHelper(application, dbSecret) + } + + private val mutableMigrationState = MutableStateFlow(MigrationState.Idle) + + val migrationState: StateFlow + get() = mutableMigrationState + + @Synchronized + private fun migrateDatabaseIfNeeded(fromRetry: Boolean) { + val currState = mutableMigrationState.value + if (currState == MigrationState.Completed || currState is MigrationState.Migrating) { + Log.w(TAG, "Already completed or in progress") + return + } + + if (!fromRetry && currState is MigrationState.Error) { + Log.w(TAG, "Migration failed before, aborting as it's not an explicit retry") + return + } + + // List steps to be performed: pair of action name string res id and the action lambda + val stepDescriptors: List = listOf( + ProgressStepDescriptor("Migrate cipher settings", R.string.databaseOptimizing, R.string.waitFewMinutes, ::migrateCipherSettings) + ) + + // Accumulated progress steps + val steps = MutableList(stepDescriptors.size) { + ProgressStep( + title = application.getString(stepDescriptors[it].title), + subtitle = application.getString(stepDescriptors[it].subtitle), + percentage = 0 + ) + } + + mutableMigrationState.value = MigrationState.Migrating(steps.toList()) + + try { + for ((index, desc) in stepDescriptors.withIndex()) { + Log.d(TAG, "Starting migration step: ${desc.name}") + val stepStartedAt = SystemClock.elapsedRealtime() + try { + (desc.action)(fromRetry) + } catch (e: Exception) { + Log.d(TAG, "Error performing migration step: ${desc.name}", e) + throw e + } + + steps[index] = steps[index].copy(percentage = 100) + Log.d(TAG, "Completed migration step: ${desc.name}, time taken = ${SystemClock.elapsedRealtime() - stepStartedAt}ms") + + mutableMigrationState.value = MigrationState.Migrating(steps.toList()) + } + + mutableMigrationState.value = MigrationState.Completed + } catch (ec: Exception) { + mutableMigrationState.value = MigrationState.Error(ec) + return + } + } + + private fun migrateCipherSettings(fromRetry: Boolean) { + if (prefs.migratedToDisablingKDF) { + Log.i(TAG, "Already migrated to latest cipher settings") + return + } + + // List of the possible old databases and their settings. The order + // is important: we start from the latest version and go to the oldest. + // This is because we only migrate the latest version, since they have the + // latest user data. + val oldDatabasesAndSettings = listOf( + CIPHER4_DB_NAME to mapOf( + "kdf_iter" to "256000", + "cipher_page_size" to "4096", + ), + + CIPHER3_DB_NAME to mapOf( + "kdf_iter" to "1", + "cipher_page_size" to "4096", + "cipher_compatibility" to "3", + ) + ) + + val newDb = application.getDatabasePath(SQLCipherOpenHelper.DATABASE_NAME) + val newDbSettings = mapOf( + "kdf_iter" to "1", + "cipher_page_size" to "4096", + ) + + // Try to find an old db to update, if not, bail. + val (oldDb, oldDbSettings) = oldDatabasesAndSettings.firstNotNullOfOrNull { (db, settings) -> + val dbFile = application.getDatabasePath(db) + if (dbFile.exists()) { + dbFile to settings + } else { + null + } + } ?: run { + Log.i(TAG, "No database to migrate") + prefs.migratedToDisablingKDF = true + return + } + + Log.d(TAG, "Start migrating ${oldDb.path}") + + val hook = object : SQLiteDatabaseHook { + override fun preKey(connection: SQLiteConnection) { + // Set the new settings + oldDbSettings.forEach { (key, value) -> + connection.executeRaw("PRAGMA $key = '$value';", null, null) + } + } + + override fun postKey(connection: SQLiteConnection) = preKey(connection) + } + + if (newDb.exists()) { + Log.d( + TAG, + "New database exists but we haven't done our migration, it's likely corrupted. Deleting." + ) + application.deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME) + } + + SQLiteDatabase.openDatabase( + oldDb.absolutePath, + dbSecret.asString(), + null, + SQLiteDatabase.OPEN_READWRITE or SQLiteDatabase.CREATE_IF_NECESSARY, + hook + ).use { db -> + db.rawExecSQL( + "ATTACH DATABASE ? AS new_db KEY ?", + newDb.absolutePath, + dbSecret.asString() + ) + + // Apply new cipher settings + for ((key, value) in newDbSettings) { + db.rawExecSQL("PRAGMA new_db.$key = '$value'") + } + + // Apply the same user version as the old database + db.rawExecSQL("PRAGMA new_db.user_version = ${db.version}") + + // Export the old database to the new one + db.rawExecSQL("SELECT sqlcipher_export('new_db')") + + // Detach the new database + db.rawExecSQL("DETACH DATABASE new_db") + +// // Delay and fail at first +// if (BuildConfig.DEBUG && !fromRetry) { +// Thread.sleep(2000) +// throw RuntimeException("Fail") +// } + } + + check(newDb.exists()) { "New database was not created" } + prefs.migratedToDisablingKDF = true + } + + fun requestMigration(fromRetry: Boolean) { + val dispatcher = if (fromRetry) { + Dispatchers.IO + } else { + Dispatchers.Default + } + + scope.launch(dispatcher) { + migrateDatabaseIfNeeded(fromRetry) + } + } + + private data class ProgressStepDescriptor( + val name: String, + + @StringRes + val title: Int, + + @StringRes + val subtitle: Int, + + val action: (fromRetry: Boolean) -> Unit, + ) + + override fun onPostAppStarted() { + requestMigration(fromRetry = false) + } + + data class ProgressStep( + val title: String, + val subtitle: String, + val percentage: Int, + ) + + sealed interface MigrationState { + data object Idle : MigrationState + data class Migrating(val steps: List) : MigrationState { + init { + check(steps.isNotEmpty()) { "Steps must not be empty" } + } + } + data class Error(val throwable: Throwable) : MigrationState + data object Completed : MigrationState + } + + companion object { + const val CIPHER3_DB_NAME = "signal.db" + const val CIPHER4_DB_NAME = "signal_v4.db" + + private const val TAG = "DatabaseMigrationManager" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/migration/DatabaseMigrationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/migration/DatabaseMigrationScreen.kt new file mode 100644 index 0000000000..74fb9332d4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migration/DatabaseMigrationScreen.kt @@ -0,0 +1,255 @@ +package org.thoughtcrime.securesms.migration + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +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 androidx.fragment.app.FragmentManager +import com.squareup.phrase.Phrase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.thoughtcrime.securesms.preferences.ShareLogsDialog +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.DialogButtonData +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.components.OutlineButton +import org.thoughtcrime.securesms.ui.components.AccentFillButton +import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.util.ClearDataUtils + + +@Composable +fun DatabaseMigrationScreen( + migrationManager: DatabaseMigrationManager, + clearDataUtils: ClearDataUtils, + fm: FragmentManager, +) { + val scope = rememberCoroutineScope() + + DatabaseMigration( + state = migrationManager.migrationState.collectAsState().value, + onRetry = { + migrationManager.requestMigration(fromRetry = true) + }, + onExportLogs = { + ShareLogsDialog {}.show(fm, "share_log") + }, + onClearData = { + scope.launch { + clearDataUtils.clearAllDataAndRestart(Dispatchers.IO) + } + }, + onClearDataWithoutLoggingOut = { + scope.launch { + clearDataUtils.clearAllDataWithoutLoggingOutAndRestart(Dispatchers.IO) + } + } + ) +} + +@Composable +@Preview +private fun DatabaseMigration( + @PreviewParameter(DatabaseMigrationStateProvider::class) + state: DatabaseMigrationManager.MigrationState, + onRetry: () -> Unit = {}, + onExportLogs: () -> Unit = {}, + onClearData: () -> Unit = {}, + onClearDataWithoutLoggingOut: () -> Unit = {}, +) { + var showingClearDeviceRestoreWarning by remember { mutableStateOf(false) } + var showingClearDeviceRestartWarning by remember { mutableStateOf(false) } + + Surface( + color = LocalColors.current.background, + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(LocalDimensions.current.smallSpacing), + contentAlignment = Alignment.Center + ) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .animateContentSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + modifier = Modifier.size(120.dp), + contentDescription = null + ) + + when (state) { + is DatabaseMigrationManager.MigrationState.Completed, + DatabaseMigrationManager.MigrationState.Idle -> { + } + + is DatabaseMigrationManager.MigrationState.Error -> { + val title = + Phrase.from(LocalContext.current, R.string.databaseErrorGeneric) + .put(APP_NAME_KEY, stringResource(R.string.app_name)) + .format() + .toString() + + Text( + modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing), + text = title, + textAlign = TextAlign.Center, + style = LocalType.current.base, + color = LocalColors.current.text, + ) + + Spacer(Modifier.size(LocalDimensions.current.spacing)) + + Row { + AccentFillButton( + text = stringResource(R.string.retry), + onClick = onRetry + ) + + Spacer(Modifier.size(LocalDimensions.current.smallSpacing)) + + OutlineButton( + text = stringResource(R.string.helpReportABugExportLogs), + onClick = onExportLogs + ) + } + + Spacer(Modifier.size(LocalDimensions.current.mediumSpacing)) + + OutlineButton( + text = stringResource(R.string.clearDeviceRestore), + color = LocalColors.current.danger, + onClick = { showingClearDeviceRestoreWarning = true } + ) + Spacer(Modifier.size(LocalDimensions.current.xsSpacing)) + OutlineButton( + text = stringResource(R.string.clearDeviceRestart), + color = LocalColors.current.danger, + onClick = { showingClearDeviceRestartWarning = true } + ) + + Spacer(Modifier.size(LocalDimensions.current.xsSpacing)) + + } + + is DatabaseMigrationManager.MigrationState.Migrating -> { + val currentStep = state.steps.lastOrNull { it.percentage < 100 } + ?: state.steps.first() + + Text( + text = currentStep.title, + style = LocalType.current.h7, + color = LocalColors.current.text, + ) + + Spacer(Modifier.size(LocalDimensions.current.xsSpacing)) + + Text( + text = currentStep.subtitle, + style = LocalType.current.base, + color = LocalColors.current.text, + ) + + Spacer(Modifier.size(LocalDimensions.current.spacing)) + + SmallCircularProgressIndicator(color = LocalColors.current.text) + } + } + } + } + } + + if (showingClearDeviceRestartWarning) { + AlertDialog( + onDismissRequest = { showingClearDeviceRestartWarning = false }, + text = stringResource(R.string.databaseErrorClearDataWarning), + buttons = listOf( + DialogButtonData( + text = GetString.FromResId(R.string.clear), + color = LocalColors.current.danger, + onClick = onClearData + ), + DialogButtonData( + text = GetString.FromResId(R.string.cancel), + onClick = { showingClearDeviceRestartWarning = false } + ) + ) + ) + } + + if (showingClearDeviceRestoreWarning) { + AlertDialog( + onDismissRequest = { showingClearDeviceRestoreWarning = false }, + text = stringResource(R.string.databaseErrorRestoreDataWarning), + buttons = listOf( + DialogButtonData( + text = GetString.FromResId(R.string.clear), + color = LocalColors.current.danger, + onClick = onClearDataWithoutLoggingOut + ), + DialogButtonData( + text = GetString.FromResId(R.string.cancel), + onClick = { showingClearDeviceRestoreWarning = false } + ) + ) + ) + } +} + +private class DatabaseMigrationStateProvider : + PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + DatabaseMigrationManager.MigrationState.Idle, + DatabaseMigrationManager.MigrationState.Completed, + DatabaseMigrationManager.MigrationState.Error(Exception("Test error")), + DatabaseMigrationManager.MigrationState.Migrating( + listOf( + DatabaseMigrationManager.ProgressStep("Step 1", "A few minutes", 100), + DatabaseMigrationManager.ProgressStep( + "Step 2 in progress", + "Wait a few seconds", + 40 + ) + ) + ) + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migration/DatabaseMigrationStateActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/migration/DatabaseMigrationStateActivity.kt new file mode 100644 index 0000000000..f4b66a3498 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migration/DatabaseMigrationStateActivity.kt @@ -0,0 +1,38 @@ +package org.thoughtcrime.securesms.migration + +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.core.content.IntentCompat +import dagger.hilt.android.AndroidEntryPoint +import org.thoughtcrime.securesms.FullComposeActivity +import org.thoughtcrime.securesms.util.ClearDataUtils +import javax.inject.Inject + +@AndroidEntryPoint +class DatabaseMigrationStateActivity : FullComposeActivity() { + @Inject + lateinit var migrationManager: DatabaseMigrationManager + + @Inject + lateinit var clearDataUtils: ClearDataUtils + + @Composable + override fun ComposeContent() { + DatabaseMigrationScreen( + migrationManager = migrationManager, + fm = supportFragmentManager, + clearDataUtils = clearDataUtils, + ) + + val state = migrationManager.migrationState.collectAsState().value + LaunchedEffect(state) { + if (state == DatabaseMigrationManager.MigrationState.Completed) { + IntentCompat.getParcelableExtra(intent, "next_intent", Intent::class.java) + ?.let(::startActivity) + finish() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java deleted file mode 100644 index de3d1c3925..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Copyright (C) 2011 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 . - */ -package org.thoughtcrime.securesms.mms; - -import android.content.Context; -import android.content.res.Resources.Theme; -import android.net.Uri; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import network.loki.messenger.R; -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; -import org.session.libsession.utilities.MediaTypes; -import org.thoughtcrime.securesms.database.AttachmentDatabase; -import org.thoughtcrime.securesms.util.ResUtil; - -public class AudioSlide extends Slide { - - public AudioSlide(Context context, Uri uri, long dataSize, boolean voiceNote) { - super(context, constructAttachmentFromUri(context, uri, MediaTypes.AUDIO_UNSPECIFIED, dataSize, 0, 0, false, null, null, voiceNote, false)); - } - - public AudioSlide(Context context, Uri uri, long dataSize, String contentType, boolean voiceNote) { - super(context, new UriAttachment(uri, null, contentType, AttachmentTransferProgress.TRANSFER_PROGRESS_STARTED, dataSize, 0, 0, null, null, voiceNote, false, null)); - } - - public AudioSlide(Context context, Attachment attachment) { - super(context, attachment); - } - - @Override - @Nullable - public Uri getThumbnailUri() { - return null; - } - - @Override - public boolean hasPlaceholder() { - return true; - } - - @Override - public boolean hasImage() { - return true; - } - - @Override - public boolean hasAudio() { - return true; - } - - @NonNull - @Override - public String getContentDescription() { - return context.getString(R.string.audio); - } - - @Override - public @DrawableRes int getPlaceholderRes(Theme theme) { - return ResUtil.getDrawableRes(theme, R.attr.conversation_icon_attach_audio); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.kt new file mode 100644 index 0000000000..23e89efa38 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.kt @@ -0,0 +1,87 @@ +/** + * Copyright (C) 2011 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 //www.gnu.org/licenses/>. + */ +package org.thoughtcrime.securesms.mms + +import android.content.Context +import android.content.res.Resources +import android.net.Uri +import androidx.annotation.DrawableRes +import network.loki.messenger.R +import org.session.libsession.messaging.sending_receiving.attachments.Attachment +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState +import org.session.libsession.messaging.sending_receiving.attachments.UriAttachment +import org.session.libsession.utilities.MediaTypes +import org.thoughtcrime.securesms.util.FilenameUtils + +class AudioSlide : Slide { + + override val contentDescription: String + get() = context.getString(R.string.audio) + + override val thumbnailUri: Uri? + get() = null + + constructor(context: Context, uri: Uri, filename: String?, dataSize: Long, voiceNote: Boolean, durationMills: Long) + // Note: The `caption` field of `constructAttachmentFromUri` is repurposed to store the interim + : super(context, + constructAttachmentFromUri( + context, + uri, + MediaTypes.AUDIO_UNSPECIFIED, + dataSize, + 0, // width + 0, // height + false, // hasThumbnail + filename, + null, + voiceNote, + false, + durationMills) // quote + ) + + constructor(context: Context, uri: Uri, filename: String?, dataSize: Long, contentType: String, voiceNote: Boolean, durationMills: Long) + : super(context, + UriAttachment( + uri, + null, // thumbnailUri + contentType, + AttachmentState.DOWNLOADING.value, + dataSize, + 0, // width + 0, // height + filename, + null, // fastPreflightId + voiceNote, + false, // quote + null, + durationMills) + ) + + constructor(context: Context, attachment: Attachment) : super(context, attachment) + + override fun hasPlaceholder() = true + override fun hasImage() = false + override fun hasAudio() = true + + // Legacy voice messages don't have filenames at all - so should we come across one we must synthesize a filename using the delivery date obtained from the attachment + override fun generateSuitableFilenameFromUri(context: Context, uri: Uri?): String { + return FilenameUtils.constructAudioMessageFilenameFromAttachment(context, attachment) + } + + @DrawableRes + override fun getPlaceholderRes(theme: Resources.Theme?) = R.drawable.ic_volume_2 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/DocumentSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/DocumentSlide.java index 7e1c0b871d..7a9ce6ea4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/DocumentSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/DocumentSlide.java @@ -15,10 +15,7 @@ public DocumentSlide(@NonNull Context context, @NonNull Attachment attachment) { super(context, attachment); } - public DocumentSlide(@NonNull Context context, @NonNull Uri uri, - @NonNull String contentType, long size, - @Nullable String fileName) - { + public DocumentSlide(@NonNull Context context, @NonNull Uri uri, @Nullable String fileName, @NonNull String contentType, long size) { super(context, constructAttachmentFromUri(context, uri, contentType, size, 0, 0, true, ExternalStorageUtil.getCleanFileName(fileName), null, false, false)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/GifSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/GifSlide.java index 5ec7a36353..52679cc58d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/GifSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/GifSlide.java @@ -3,28 +3,18 @@ import android.content.Context; import android.net.Uri; import androidx.annotation.Nullable; - import org.session.libsession.messaging.sending_receiving.attachments.Attachment; import org.session.libsession.utilities.MediaTypes; public class GifSlide extends ImageSlide { - public GifSlide(Context context, Attachment attachment) { - super(context, attachment); - } - - - public GifSlide(Context context, Uri uri, long size, int width, int height) { - this(context, uri, size, width, height, null); - } + public GifSlide(Context context, Attachment attachment) { super(context, attachment); } - public GifSlide(Context context, Uri uri, long size, int width, int height, @Nullable String caption) { - super(context, constructAttachmentFromUri(context, uri, MediaTypes.IMAGE_GIF, size, width, height, true, null, caption, false, false)); - } + public GifSlide(Context context, Uri uri, String filename, long size, int width, int height, @Nullable String caption) { + super(context, constructAttachmentFromUri(context, uri, MediaTypes.IMAGE_GIF, size, width, height, true, filename, caption, false, false)); + } - @Override - @Nullable - public Uri getThumbnailUri() { - return getUri(); - } + @Override + @Nullable + public Uri getThumbnailUri() { return getUri(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/ImageSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/ImageSlide.java index 042486dc36..73e58b0f70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/ImageSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/ImageSlide.java @@ -22,7 +22,6 @@ import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; - import network.loki.messenger.R; import org.session.libsession.messaging.sending_receiving.attachments.Attachment; import org.session.libsession.utilities.MediaTypes; @@ -36,32 +35,20 @@ public ImageSlide(@NonNull Context context, @NonNull Attachment attachment) { super(context, attachment); } - public ImageSlide(Context context, Uri uri, long size, int width, int height) { - this(context, uri, size, width, height, null); - } - - public ImageSlide(Context context, Uri uri, long size, int width, int height, @Nullable String caption) { - super(context, constructAttachmentFromUri(context, uri, MediaTypes.IMAGE_JPEG, size, width, height, true, null, caption, false, false)); + public ImageSlide(Context context, Uri uri, String filename, long size, int width, int height, @Nullable String caption) { + super(context, constructAttachmentFromUri(context, uri, MediaTypes.IMAGE_JPEG, size, width, height, true, filename, caption, false, false)); } @Override - public @DrawableRes int getPlaceholderRes(Theme theme) { - return 0; - } + public @DrawableRes int getPlaceholderRes(Theme theme) { return 0; } @Override - public @Nullable Uri getThumbnailUri() { - return getUri(); - } + public @Nullable Uri getThumbnailUri() { return getUri(); } @Override - public boolean hasImage() { - return true; - } + public boolean hasImage() { return true; } @NonNull @Override - public String getContentDescription() { - return context.getString(R.string.image); - } + public String getContentDescription() { return context.getString(R.string.image); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MediaStream.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MediaStream.java index c736dd3a96..8150e8ed3e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/MediaStream.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MediaStream.java @@ -31,19 +31,11 @@ public MediaStream(InputStream stream, String mimeType, int width, int height) { this.height = height; } - public InputStream getStream() { - return stream; - } + public InputStream getStream() { return stream; } - public String getMimeType() { - return mimeType; - } + public String getMimeType() { return mimeType; } - public int getWidth() { - return width; - } + public int getWidth() { return width; } - public int getHeight() { - return height; - } + public int getHeight() { return height; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java b/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java index a6de8db41e..c9c004e599 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java @@ -4,24 +4,23 @@ import android.content.Context; import android.content.UriMatcher; import android.net.Uri; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; - +import java.io.IOException; +import java.io.InputStream; import org.session.libsession.messaging.sending_receiving.attachments.Attachment; import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.providers.BlobProvider; -import org.thoughtcrime.securesms.providers.PartProvider; +import org.thoughtcrime.securesms.providers.BlobUtils; +import org.thoughtcrime.securesms.providers.PartAndBlobProvider; -import java.io.IOException; -import java.io.InputStream; +import network.loki.messenger.BuildConfig; public class PartAuthority { - private static final String PART_URI_STRING = "content://network.loki.provider.securesms/part"; - private static final String THUMB_URI_STRING = "content://network.loki.provider.securesms/thumb"; - private static final String STICKER_URI_STRING = "content://network.loki.provider.securesms/sticker"; + private static final String PART_URI_STRING = "content://network.loki.provider.securesms" + BuildConfig.AUTHORITY_POSTFIX + "/part"; + private static final String THUMB_URI_STRING = "content://network.loki.provider.securesms" + BuildConfig.AUTHORITY_POSTFIX + "/thumb"; + private static final String STICKER_URI_STRING = "content://network.loki.provider.securesms" + BuildConfig.AUTHORITY_POSTFIX + "/sticker"; private static final Uri PART_CONTENT_URI = Uri.parse(PART_URI_STRING); private static final Uri THUMB_CONTENT_URI = Uri.parse(THUMB_URI_STRING); private static final Uri STICKER_CONTENT_URI = Uri.parse(STICKER_URI_STRING); @@ -36,10 +35,10 @@ public class PartAuthority { static { uriMatcher = new UriMatcher(UriMatcher.NO_MATCH); - uriMatcher.addURI("network.loki.provider.securesms", "part/*/#", PART_ROW); - uriMatcher.addURI("network.loki.provider.securesms", "thumb/*/#", THUMB_ROW); - uriMatcher.addURI("network.loki.provider.securesms", "sticker/#", STICKER_ROW); - uriMatcher.addURI(BlobProvider.AUTHORITY, BlobProvider.PATH, BLOB_ROW); + uriMatcher.addURI("network.loki.provider.securesms" + BuildConfig.AUTHORITY_POSTFIX, "part/*/#", PART_ROW); + uriMatcher.addURI("network.loki.provider.securesms" + BuildConfig.AUTHORITY_POSTFIX, "thumb/*/#", THUMB_ROW); + uriMatcher.addURI("network.loki.provider.securesms" + BuildConfig.AUTHORITY_POSTFIX, "sticker/#", STICKER_ROW); + uriMatcher.addURI(BlobUtils.AUTHORITY, BlobUtils.PATH, BLOB_ROW); } public static InputStream getAttachmentStream(@NonNull Context context, @NonNull Uri uri) @@ -50,7 +49,7 @@ public static InputStream getAttachmentStream(@NonNull Context context, @NonNull switch (match) { case PART_ROW: return DatabaseComponent.get(context).attachmentDatabase().getAttachmentStream(new PartUriParser(uri).getPartId(), 0); case THUMB_ROW: return DatabaseComponent.get(context).attachmentDatabase().getThumbnailStream(new PartUriParser(uri).getPartId()); - case BLOB_ROW: return BlobProvider.getInstance().getStream(context, uri); + case BLOB_ROW: return BlobUtils.getInstance().getStream(context, uri); default: return context.getContentResolver().openInputStream(uri); } } catch (SecurityException se) { @@ -66,10 +65,10 @@ public static InputStream getAttachmentStream(@NonNull Context context, @NonNull case PART_ROW: Attachment attachment = DatabaseComponent.get(context).attachmentDatabase().getAttachment(new PartUriParser(uri).getPartId()); - if (attachment != null) return attachment.getFileName(); + if (attachment != null) return attachment.getFilename(); else return null; case BLOB_ROW: - return BlobProvider.getFileName(uri); + return BlobUtils.getFileName(uri); default: return null; } @@ -86,7 +85,7 @@ public static InputStream getAttachmentStream(@NonNull Context context, @NonNull if (attachment != null) return attachment.getSize(); else return null; case BLOB_ROW: - return BlobProvider.getFileSize(uri); + return BlobUtils.getFileSize(uri); default: return null; } @@ -103,7 +102,7 @@ public static InputStream getAttachmentStream(@NonNull Context context, @NonNull if (attachment != null) return attachment.getContentType(); else return null; case BLOB_ROW: - return BlobProvider.getMimeType(uri); + return BlobUtils.getMimeType(uri); default: return null; } @@ -111,7 +110,7 @@ public static InputStream getAttachmentStream(@NonNull Context context, @NonNull public static Uri getAttachmentPublicUri(Uri uri) { PartUriParser partUri = new PartUriParser(uri); - return PartProvider.getContentUri(partUri.getPartId()); + return PartAndBlobProvider.getContentUri(partUri.getPartId()); } public static Uri getAttachmentDataUri(AttachmentId attachmentId) { 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 22af450aa8..2db9ea596b 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 int getImageMaxHeight(Context context) { @Override public int getImageMaxSize(Context context) { - return FileServerApi.maxFileSize; + return FileServerApi.MAX_FILE_SIZE; } @Override public int getGifMaxSize(Context context) { - return FileServerApi.maxFileSize; + return FileServerApi.MAX_FILE_SIZE; } @Override public int getVideoMaxSize(Context context) { - return FileServerApi.maxFileSize; + return FileServerApi.MAX_FILE_SIZE; } @Override public int getAudioMaxSize(Context context) { - return FileServerApi.maxFileSize; + return FileServerApi.MAX_FILE_SIZE; } @Override public int getDocumentMaxSize(Context context) { - return FileServerApi.maxFileSize; + return FileServerApi.MAX_FILE_SIZE; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/QuoteId.java b/app/src/main/java/org/thoughtcrime/securesms/mms/QuoteId.java index 06cb3175b6..9d1b33996b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/QuoteId.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/QuoteId.java @@ -39,7 +39,7 @@ public long getId() { try { JSONObject object = new JSONObject(); object.put(ID, id); - object.put(AUTHOR, author.serialize()); + object.put(AUTHOR, author.toString()); return object.toString(); } catch (JSONException e) { Log.e(TAG, "Failed to serialize to json", e); 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 02172b7248..0a24c26fad 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 void registerComponents(@NonNull Context context, @NonNull Glide glide, @ 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(context)); + registry.append(PlaceholderAvatarPhoto.class, BitmapDrawable.class, new PlaceholderAvatarLoader.Factory()); registry.replace(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt index a26871831d..5361b10e5e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt @@ -23,14 +23,14 @@ import androidx.annotation.DrawableRes import com.squareup.phrase.Phrase import network.loki.messenger.R 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.AttachmentState import org.session.libsession.messaging.sending_receiving.attachments.UriAttachment import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY import org.session.libsession.utilities.Util.equals import org.session.libsession.utilities.Util.hashCode import org.session.libsignal.utilities.Util.SECURE_RANDOM import org.session.libsignal.utilities.guava.Optional -import org.thoughtcrime.securesms.conversation.v2.Util +import org.thoughtcrime.securesms.util.FilenameUtils import org.thoughtcrime.securesms.util.MediaUtil abstract class Slide(@JvmField protected val context: Context, protected val attachment: Attachment) { @@ -45,45 +45,41 @@ abstract class Slide(@JvmField protected val context: Context, protected val att val body: Optional get() { - if (MediaUtil.isAudio(attachment)) { - // A missing file name is the legacy way to determine if an audio attachment is - // a voice note vs. other arbitrary audio attachments. - if (attachment.isVoiceNote) { - val voiceTxt = Phrase.from(context, R.string.messageVoiceSnippet) - .put(EMOJI_KEY, "🎙") - .format().toString() - - return Optional.fromNullable(voiceTxt) - } + return if (MediaUtil.isAudio(attachment) && attachment.isVoiceNote) { + val voiceTxt = Phrase.from(context, R.string.messageVoiceSnippet) + .put(EMOJI_KEY, "🎙") + .format().toString() + Optional.fromNullable(voiceTxt) + } else { + val txt = Phrase.from(context, R.string.attachmentsNotification) + .put(EMOJI_KEY, emojiForMimeType()) + .format().toString() + Optional.fromNullable(txt) } - val txt = Phrase.from(context, R.string.attachmentsNotification) - .put(EMOJI_KEY, emojiForMimeType()) - .format().toString() - return Optional.fromNullable(txt) } - private fun emojiForMimeType(): String { - return when{ - MediaUtil.isGif(attachment) -> "🎡" - + private fun emojiForMimeType(): String = + when { + MediaUtil.isGif(attachment) -> "🎡" MediaUtil.isImage(attachment) -> "📷" - MediaUtil.isVideo(attachment) -> "🎥" - MediaUtil.isAudio(attachment) -> "🎧" - - MediaUtil.isFile(attachment) -> "📎" - - // We don't provide emojis for other mime-types such as VCARD - else -> "" + MediaUtil.isFile(attachment) -> "📎" + else -> "" // We don't provide emojis for other mime-types such as VCARD } - } val caption: Optional get() = Optional.fromNullable(attachment.caption) - val fileName: Optional - get() = Optional.fromNullable(attachment.fileName) + val filename: String by lazy { + if (attachment.filename.isNullOrEmpty()) generateSuitableFilenameFromUri(context, attachment.dataUri) else attachment.filename + } + + // Note: All slide types EXCEPT AudioSlide use this technique to synthesize a filename from a Uri - however AudioSlide has + // its own custom version to handle legacy voice messages which lack filenames. + open fun generateSuitableFilenameFromUri(context: Context, uri: Uri?): String { + return FilenameUtils.getFilenameFromUri(context, attachment.dataUri, attachment.contentType) + } val fastPreflightId: String? get() = attachment.fastPreflightId @@ -108,10 +104,19 @@ abstract class Slide(@JvmField protected val context: Context, protected val att get() = attachment.isInProgress val isPendingDownload: Boolean - get() = transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED || - transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING + get() = transferState == AttachmentState.FAILED.value || + transferState == AttachmentState.PENDING.value + + val isDone: Boolean + get() = transferState == AttachmentState.DONE.value - val transferState: Int + val isFailed: Boolean + get() = transferState == AttachmentState.FAILED.value + + val isExpired: Boolean + get() = transferState == AttachmentState.EXPIRED.value + + private val transferState: Int get() = attachment.transferState @DrawableRes @@ -142,6 +147,7 @@ abstract class Slide(@JvmField protected val context: Context, protected val att companion object { @JvmStatic + @JvmOverloads protected fun constructAttachmentFromUri( context: Context, uri: Uri, @@ -153,16 +159,17 @@ abstract class Slide(@JvmField protected val context: Context, protected val att fileName: String?, caption: String?, voiceNote: Boolean, - quote: Boolean + quote: Boolean, + audioDurationMills: Long = -1L, ): Attachment { - val resolvedType = - Optional.fromNullable(MediaUtil.getMimeType(context, uri)).or(defaultMime) + val resolvedType = Optional.fromNullable(MediaUtil.getMimeType(context, uri)).or(defaultMime) val fastPreflightId = SECURE_RANDOM.nextLong().toString() + return UriAttachment( uri, if (hasThumbnail) uri else null, resolvedType!!, - AttachmentTransferProgress.TRANSFER_PROGRESS_STARTED, + AttachmentState.DOWNLOADING.value, size, width, height, @@ -170,7 +177,8 @@ abstract class Slide(@JvmField protected val context: Context, protected val att fastPreflightId, voiceNote, quote, - caption + caption, + audioDurationMills ) } } 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 a559417e31..7c91fdcce6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/SlideDeck.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/SlideDeck.java @@ -125,6 +125,16 @@ public boolean containsMediaSlide() { return null; } + public boolean hasVideo() { + for (Slide slide : slides) { + if (slide.hasVideo()) { + return true; + } + } + + return false; + } + public @Nullable TextSlide getTextSlide() { for (Slide slide: slides) { if (MediaUtil.isLongTextType(slide.getContentType())) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java index 8bd3afb4fd..8497fdd467 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java @@ -22,21 +22,19 @@ import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; - import network.loki.messenger.R; import org.session.libsession.messaging.sending_receiving.attachments.Attachment; import org.session.libsession.utilities.MediaTypes; import org.thoughtcrime.securesms.util.MediaUtil; -import org.thoughtcrime.securesms.util.ResUtil; public class VideoSlide extends Slide { - public VideoSlide(Context context, Uri uri, long dataSize) { - this(context, uri, dataSize, null); + public VideoSlide(Context context, Uri uri, String filename, long dataSize) { + this(context, uri, filename, dataSize, null); } - public VideoSlide(Context context, Uri uri, long dataSize, @Nullable String caption) { - super(context, constructAttachmentFromUri(context, uri, MediaTypes.VIDEO_UNSPECIFIED, dataSize, 0, 0, MediaUtil.hasVideoThumbnail(uri), null, caption, false, false)); + public VideoSlide(Context context, Uri uri, String filename, long dataSize, @Nullable String caption) { + super(context, constructAttachmentFromUri(context, uri, MediaTypes.VIDEO_UNSPECIFIED, dataSize, 0, 0, MediaUtil.hasVideoThumbnail(uri), filename, caption, false, false)); } public VideoSlide(Context context, Attachment attachment) { @@ -55,7 +53,7 @@ public boolean hasPlayOverlay() { @Override public @DrawableRes int getPlaceholderRes(Theme theme) { - return ResUtil.getDrawableRes(theme, R.attr.conversation_icon_attach_video); + return R.drawable.ic_square_play; } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java index 2e2440d6a2..57f9eab984 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java @@ -5,7 +5,9 @@ import android.net.Uri; import android.os.Bundle; import android.text.SpannableStringBuilder; +import android.text.TextPaint; import android.text.TextUtils; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -24,7 +26,7 @@ public abstract class AbstractNotificationBuilder extends NotificationCompat.Bui @SuppressWarnings("unused") private static final String TAG = AbstractNotificationBuilder.class.getSimpleName(); - private static final int MAX_DISPLAY_LENGTH = 50; + private static final int MAX_DISPLAY_LENGTH = 80; protected Context context; protected NotificationPrivacyPreference privacy; @@ -42,7 +44,7 @@ public AbstractNotificationBuilder(Context context, NotificationPrivacyPreferenc protected CharSequence getStyledMessage(@NonNull Recipient recipient, @Nullable CharSequence message) { SpannableStringBuilder builder = new SpannableStringBuilder(); - builder.append(Util.getBoldedString(recipient.toShortString())); + builder.append(Util.getBoldedString(recipient.getName())); builder.append(": "); builder.append(message == null ? "" : message); @@ -82,7 +84,7 @@ public void setTicker(@NonNull Recipient recipient, @Nullable CharSequence messa text = text == null ? "" : text; return text.length() <= MAX_DISPLAY_LENGTH ? text - : text.subSequence(0, MAX_DISPLAY_LENGTH); + : text.subSequence(0, MAX_DISPLAY_LENGTH - 3) +"\u2026"; } @Override 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 31a368687c..b7051357e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java @@ -97,14 +97,14 @@ protected Void doInBackground(Void... params) { Log.w("AndroidAutoReplyReceiver", "GroupRecipient, Sending media message"); OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null, expiresInMillis, 0); try { - DatabaseComponent.get(context).mmsDatabase().insertMessageOutbox(reply, replyThreadId, false, null, true); + DatabaseComponent.get(context).mmsDatabase().insertMessageOutbox(reply, replyThreadId, false, true); } catch (MmsException e) { Log.w(TAG, e); } } else { Log.w("AndroidAutoReplyReceiver", "Sending regular message "); OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt); - DatabaseComponent.get(context).smsDatabase().insertMessageOutbox(replyThreadId, reply, false, SnodeAPI.getNowWithOffset(), null, true); + DatabaseComponent.get(context).smsDatabase().insertMessageOutbox(replyThreadId, reply, false, SnodeAPI.getNowWithOffset(), true); } List messageIds = DatabaseComponent.get(context).threadDatabase().setRead(replyThreadId, true); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollManager.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollManager.kt index 75d15eedfc..71124c6556 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollManager.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.util.AppVisibilityManager import javax.inject.Inject import javax.inject.Singleton @@ -28,7 +29,7 @@ class BackgroundPollManager @Inject constructor( application: Application, appVisibilityManager: AppVisibilityManager, textSecurePreferences: TextSecurePreferences, -) { +) : OnAppStartupComponent { init { @Suppress("OPT_IN_USAGE") GlobalScope.launch { 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 208c05c955..eda1a598a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt @@ -18,15 +18,9 @@ import kotlinx.coroutines.async import kotlinx.coroutines.supervisorScope import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth -import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager -import org.session.libsession.messaging.jobs.BatchMessageReceiveJob -import org.session.libsession.messaging.jobs.MessageReceiveParameters -import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2 -import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.utilities.await +import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerManager +import org.session.libsession.messaging.sending_receiving.pollers.PollerManager import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.groups.GroupPollerManager import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.minutes @@ -36,13 +30,12 @@ class BackgroundPollWorker @AssistedInject constructor( @Assisted val context: Context, @Assisted params: WorkerParameters, private val storage: StorageProtocol, - private val deprecationManager: LegacyGroupDeprecationManager, - private val lokiThreadDatabase: LokiThreadDatabase, private val groupPollerManager: GroupPollerManager, + private val openGroupPollerManager: OpenGroupPollerManager, + private val pollerManager: PollerManager, ) : CoroutineWorker(context, params) { enum class Target { ONE_TO_ONE, - LEGACY_GROUPS, GROUPS, OPEN_GROUPS } @@ -109,41 +102,16 @@ class BackgroundPollWorker @AssistedInject constructor( if (requestTargets.contains(Target.ONE_TO_ONE)) { tasks += async { Log.d(TAG, "Polling messages.") - val params = SnodeAPI.getMessages(userAuth).await().map { (envelope, serverHash) -> - MessageReceiveParameters(envelope.toByteArray(), serverHash, null) - } - - // FIXME: Using a job here seems like a bad idea... - BatchMessageReceiveJob(params).executeAsync("background") + pollerManager.pollOnce() } } - // Legacy groups - if (requestTargets.contains(Target.LEGACY_GROUPS)) { - val poller = LegacyClosedGroupPollerV2(storage, deprecationManager) - - storage.getAllLegacyGroupPublicKeys() - .mapTo(tasks) { key -> - async { - Log.d(TAG, "Polling legacy group ${key.substring(0, 8)}...") - poller.poll(key) - } - } - } - // Open groups if (requestTargets.contains(Target.OPEN_GROUPS)) { - lokiThreadDatabase.getAllOpenGroups() - .mapTo(hashSetOf()) { it.value.server } - .mapTo(tasks) { server -> - async { - Log.d(TAG, "Polling open group server $server.") - OpenGroupPoller(server, null) - .apply { hasStarted = true } - .poll() - .await() - } - } + tasks += async { + Log.d(TAG, "Polling open groups.") + openGroupPollerManager.pollAllOpenGroupsOnce() + } } // Close group @@ -160,7 +128,7 @@ class BackgroundPollWorker @AssistedInject constructor( result.await() acc } catch (ec: Exception) { - Log.e(TAG, "Failed to poll group due to error.", ec) + Log.e(TAG, "Failed to poll due to error.", ec) acc?.also { it.addSuppressed(ec) } ?: ec } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt index 01b50198a3..00b029371f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt @@ -26,7 +26,6 @@ import android.content.Intent import android.content.pm.PackageManager import android.database.Cursor import android.os.AsyncTask -import android.os.Build import android.text.TextUtils import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat @@ -38,14 +37,12 @@ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlin.concurrent.Volatile -import me.leolin.shortcutbadger.ShortcutBadger import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.BlindKeyAPI import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.session.libsession.messaging.utilities.SodiumUtilities.blindedKeyPair import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.ServiceUtil import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber import org.session.libsession.utilities.TextSecurePreferences.Companion.getNotificationPrivacy import org.session.libsession.utilities.TextSecurePreferences.Companion.getRepeatAlertsCount @@ -54,14 +51,21 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotifi import org.session.libsession.utilities.TextSecurePreferences.Companion.removeHasHiddenMessageRequests import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Hex 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.contacts.ContactUtil import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions import org.thoughtcrime.securesms.crypto.KeyPairUtilities.getUserED25519KeyPair +import org.thoughtcrime.securesms.database.MmsDatabase.Companion.MESSAGE_BOX +import org.thoughtcrime.securesms.database.MmsSmsColumns +import org.thoughtcrime.securesms.database.MmsSmsColumns.NOTIFIED +import org.thoughtcrime.securesms.database.MmsSmsColumns.READ +import org.thoughtcrime.securesms.database.MmsSmsDatabase.MMS_TRANSPORT +import org.thoughtcrime.securesms.database.MmsSmsDatabase.TRANSPORT import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord @@ -69,6 +73,8 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get import org.thoughtcrime.securesms.mms.SlideDeck import org.thoughtcrime.securesms.service.KeyCachingService +import org.thoughtcrime.securesms.util.AvatarUtils +import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.Companion.WEBRTC_NOTIFICATION import org.thoughtcrime.securesms.util.SessionMetaProtocol.canUserReplyToNotification import org.thoughtcrime.securesms.util.SpanUtil @@ -78,7 +84,10 @@ import org.thoughtcrime.securesms.util.SpanUtil * * @author Moxie Marlinspike */ -class DefaultMessageNotifier : MessageNotifier { +private const val CONTENT_SIGNATURE = "content_signature" +class DefaultMessageNotifier( + val avatarUtils: AvatarUtils +) : MessageNotifier { override fun setVisibleThread(threadId: Long) { visibleThread = threadId } @@ -108,11 +117,13 @@ class DefaultMessageNotifier : MessageNotifier { val activeNotifications = notifications.activeNotifications for (activeNotification in activeNotifications) { - notifications.cancel(activeNotification.id) + if(activeNotification.id != WEBRTC_NOTIFICATION) { + notifications.cancel(activeNotification.id) + } } } catch (e: Throwable) { // XXX Appears to be a ROM bug, see #6043 - Log.w(TAG, e) + Log.w(TAG, "cancel notification error: $e") notifications.cancelAll() } return hasNotifications @@ -135,7 +146,9 @@ class DefaultMessageNotifier : MessageNotifier { } if (!validNotification) { - notifications.cancel(notification.id) + if(notification.id != WEBRTC_NOTIFICATION) { + notifications.cancel(notification.id) + } } } } @@ -200,10 +213,9 @@ class DefaultMessageNotifier : MessageNotifier { var telcoCursor: Cursor? = null try { - telcoCursor = get(context).mmsSmsDatabase().unread // TODO: add a notification specific lighter query here + telcoCursor = get(context).mmsSmsDatabase().unreadOrUnseenReactions // TODO: add a notification specific lighter query here if ((telcoCursor == null || telcoCursor.isAfterLast) || getLocalNumber(context) == null) { - updateBadge(context, 0) cancelActiveNotifications(context) clearReminder(context) return @@ -230,7 +242,6 @@ class DefaultMessageNotifier : MessageNotifier { } cancelOrphanedNotifications(context, notificationState) - updateBadge(context, notificationState.notificationCount) if (playNotificationAudio) { scheduleReminder(context, reminderCount) @@ -255,26 +266,34 @@ class DefaultMessageNotifier : MessageNotifier { Log.i(TAG, "sendSingleThreadNotification() signal: $signal bundled: $bundled") if (notificationState.notifications.isEmpty()) { - if (!bundled) cancelActiveNotifications(context) + if (!bundled) { + cancelActiveNotifications(context) + } Log.i(TAG, "Empty notification state. Skipping.") return } - val builder = SingleRecipientNotificationBuilder(context, getNotificationPrivacy(context)) + // Bail early if the existing displayed notification has the same content as what we are trying to send now val notifications = notificationState.notifications - val messageOriginator = notifications[0].recipient val notificationId = (SUMMARY_NOTIFICATION_ID + (if (bundled) notifications[0].threadId else 0)).toInt() - val messageIdTag = notifications[0].timestamp.toString() + val contentSignature = notifications.map { + getNotificationSignature(it) + }.sorted().joinToString("|") - val notificationManager = ServiceUtil.getNotificationManager(context) - for (notification in notificationManager.activeNotifications) { - if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && notification.isAppGroup == bundled) - && (messageIdTag == notification.notification.extras.getString(LATEST_MESSAGE_ID_TAG)) - ) { - return - } + val existingNotifications = ServiceUtil.getNotificationManager(context).activeNotifications + val existingSignature = existingNotifications.find { it.id == notificationId }?.notification?.extras?.getString(CONTENT_SIGNATURE) + + if (existingSignature == contentSignature) { + Log.i(TAG, "Skipping duplicate single thread notification for ID $notificationId") + return } + val builder = SingleRecipientNotificationBuilder(context, getNotificationPrivacy(context), avatarUtils) + builder.putStringExtra(CONTENT_SIGNATURE, contentSignature) + + val messageOriginator = notifications[0].recipient + val messageIdTag = notifications[0].timestamp.toString() + val timestamp = notifications[0].timestamp if (timestamp != 0L) builder.setWhen(timestamp) @@ -367,6 +386,10 @@ class DefaultMessageNotifier : MessageNotifier { Log.i(TAG, "Posted notification. $notification") } + private fun getNotificationSignature(notification: NotificationItem): String { + return "${notification.id}_${notification.text}_${notification.timestamp}_${notification.threadId}" + } + // Note: The `signal` parameter means "play an audio signal for the notification". private fun sendMultipleThreadNotification( context: Context, @@ -375,8 +398,21 @@ class DefaultMessageNotifier : MessageNotifier { ) { Log.i(TAG, "sendMultiThreadNotification() signal: $signal") - val builder = MultipleRecipientNotificationBuilder(context, getNotificationPrivacy(context)) val notifications = notificationState.notifications + val contentSignature = notifications.map { + getNotificationSignature(it) + }.sorted().joinToString("|") + + val existingNotifications = ServiceUtil.getNotificationManager(context).activeNotifications + val existingSignature = existingNotifications.find { it.id == SUMMARY_NOTIFICATION_ID }?.notification?.extras?.getString(CONTENT_SIGNATURE) + + if (existingSignature == contentSignature) { + Log.i(TAG, "Skipping duplicate multi-thread notification") + return + } + + val builder = MultipleRecipientNotificationBuilder(context, getNotificationPrivacy(context)) + builder.putStringExtra(CONTENT_SIGNATURE, contentSignature) builder.setMessageCount(notificationState.notificationCount, notificationState.threadCount) builder.setMostRecentSender(notifications[0].individualRecipient, notifications[0].recipient) @@ -462,107 +498,178 @@ class DefaultMessageNotifier : MessageNotifier { val threadDatabase = get(context).threadDatabase() val cache: MutableMap = HashMap() - // CAREFUL: Do not put this loop back as `while ((reader.next.also { record = it }) != null) {` because it breaks with a Null Pointer Exception! var record: MessageRecord? = null do { record = reader.next if (record == null) break // Bail if there are no more MessageRecords - val id = record.getId() - val mms = record.isMms || record.isMmsNotification - val recipient = record.individualRecipient - val conversationRecipient = record.recipient val threadId = record.threadId - var body: CharSequence = record.getDisplayBody(context) - var threadRecipients: Recipient? = null - var slideDeck: SlideDeck? = null - val timestamp = record.timestamp - var messageRequest = false - - if (threadId != -1L) { - threadRecipients = threadDatabase.getRecipientForThreadId(threadId) - messageRequest = threadRecipients != null && !threadRecipients.isGroupOrCommunityRecipient && - !threadRecipients.isApproved && !threadDatabase.getLastSeenAndHasSent(threadId).second() - if (messageRequest && (threadDatabase.getMessageCount(threadId) > 1 || !hasHiddenMessageRequests(context))) { - continue - } - } + val threadRecipients = if (threadId != -1L) { + threadDatabase.getRecipientForThreadId(threadId) + } else null + + // Start by checking various scenario that we should skip + + // Skip if muted or calls + if (threadRecipients?.isMuted == true) continue + if (record.isIncomingCall || record.isOutgoingCall) continue + + // Handle message requests early + val isMessageRequest = threadRecipients != null && + !threadRecipients.isGroupOrCommunityRecipient && + !threadRecipients.isApproved && + !threadDatabase.getLastSeenAndHasSent(threadId).second() - // If this is a message request from an unknown user.. - if (messageRequest) { - body = SpanUtil.italic(context.getString(R.string.messageRequestsNew)) - - // If we received some manner of notification but Session is locked.. - } else if (KeyCachingService.isLocked(context)) { - // Note: We provide 1 because `messageNewYouveGot` is now a plurals string and we don't have a count yet, so just - // giving it 1 will result in "You got a new message". - body = SpanUtil.italic(context.resources.getQuantityString(R.plurals.messageNewYouveGot, 1, 1)) - - // ----- Note: All further cases assume we know the contact and that Session isn't locked ----- - - // If this is a notification about a multimedia message from a contact we know about.. - } else if (record.isMms && !(record as MmsMessageRecord).sharedContacts.isEmpty()) { - val contact = (record as MmsMessageRecord).sharedContacts[0] - body = ContactUtil.getStringSummary(context, contact) - - // If this is a notification about a multimedia message which contains no text but DOES contain a slide deck with at least one slide.. - } else if (record.isMms && TextUtils.isEmpty(body) && !(record as MmsMessageRecord).slideDeck.slides.isEmpty()) { - slideDeck = (record as MediaMmsMessageRecord).slideDeck - body = SpanUtil.italic(slideDeck.body) - - // If this is a notification about a multimedia message, but it's not ITSELF a multimedia notification AND it contains a slide deck with at least one slide.. - } else if (record.isMms && !record.isMmsNotification && !(record as MmsMessageRecord).slideDeck.slides.isEmpty()) { - slideDeck = (record as MediaMmsMessageRecord).slideDeck - val message = slideDeck.body + ": " + record.body - val italicLength = message.length - body.length - body = SpanUtil.italic(message, italicLength) - - // If this is a notification about an invitation to a community.. - } else if (record.isOpenGroupInvitation) { - body = SpanUtil.italic(context.getString(R.string.communityInvitation)) + if (isMessageRequest && (threadDatabase.getMessageCount(threadId) > 1 || !hasHiddenMessageRequests(context))) { + continue } + // Check notification settings + if (threadRecipients?.notifyType == RecipientDatabase.NOTIFY_TYPE_NONE) continue + val userPublicKey = getLocalNumber(context) - var blindedPublicKey = cache[threadId] - if (blindedPublicKey == null) { - blindedPublicKey = generateBlindedId(threadId, context) - cache[threadId] = blindedPublicKey - } - if (threadRecipients == null || !threadRecipients.isMuted) { - if (threadRecipients != null && threadRecipients.notifyType == RecipientDatabase.NOTIFY_TYPE_MENTIONS) { - // check if mentioned here - var isQuoteMentioned = false - if (record is MmsMessageRecord) { - val quote = (record as MmsMessageRecord).quote - val quoteAddress = quote?.author - val serializedAddress = quoteAddress?.serialize() - isQuoteMentioned = (serializedAddress != null && userPublicKey == serializedAddress) || - (blindedPublicKey != null && userPublicKey == blindedPublicKey) - } - if (body.toString().contains("@$userPublicKey") || body.toString().contains("@$blindedPublicKey") || isQuoteMentioned) { - notificationState.addNotification(NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, slideDeck)) + + // Check mentions-only setting + if (threadRecipients?.notifyType == RecipientDatabase.NOTIFY_TYPE_MENTIONS) { + var blindedPublicKey = cache[threadId] + if (blindedPublicKey == null) { + blindedPublicKey = generateBlindedId(threadId, context) + cache[threadId] = blindedPublicKey + } + + var isMentioned = false + val body = record.getDisplayBody(context).toString() + + // Check for @mentions + if (body.contains("@$userPublicKey") || + (blindedPublicKey != null && body.contains("@$blindedPublicKey"))) { + isMentioned = true + } + + // Check for quote mentions + if (record is MmsMessageRecord) { + val quote = record.quote + val quoteAuthor = quote?.author?.toString() + if ((quoteAuthor != null && userPublicKey == quoteAuthor) || + (blindedPublicKey != null && quoteAuthor == blindedPublicKey)) { + isMentioned = true } - } else if (threadRecipients != null && threadRecipients.notifyType == RecipientDatabase.NOTIFY_TYPE_NONE) { - // do nothing, no notifications + } + + if (!isMentioned) continue + } + + Log.w(TAG, "Processing: ID=${record.getId()}, outgoing=${record.isOutgoing}, read=${record.isRead}, hasReactions=${record.reactions.isNotEmpty()}") + + // Determine the reason this message was returned by the query + val isNotified = cursor.getInt(cursor.getColumnIndexOrThrow(NOTIFIED)) == 1 + val isUnreadIncoming = !record.isOutgoing && !record.isRead() && !isNotified // << Case 1 + val hasUnreadReactions = record.reactions.isNotEmpty() // << Case 2 + + Log.w(TAG, " -> isUnreadIncoming=$isUnreadIncoming, hasUnreadReactions=$hasUnreadReactions, isNotified=${isNotified}") + + // CASE 1: TRULY NEW UNREAD INCOMING MESSAGE + // Only show message notification if it's incoming, unread AND not yet notified + if (isUnreadIncoming) { + // Prepare message body + var body: CharSequence = record.getDisplayBody(context) + var slideDeck: SlideDeck? = null + + if (isMessageRequest) { + body = SpanUtil.italic(context.getString(R.string.messageRequestsNew)) + } else if (KeyCachingService.isLocked(context)) { + body = SpanUtil.italic(context.resources.getQuantityString(R.plurals.messageNewYouveGot, 1, 1)) } else { - notificationState.addNotification(NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, slideDeck)) + // Handle MMS content + if (record.isMms && TextUtils.isEmpty(body) && (record as MmsMessageRecord).slideDeck.slides.isNotEmpty()) { + slideDeck = (record as MediaMmsMessageRecord).slideDeck + body = SpanUtil.italic(slideDeck.body) + } else if (record.isMms && !record.isMmsNotification && (record as MmsMessageRecord).slideDeck.slides.isNotEmpty()) { + slideDeck = (record as MediaMmsMessageRecord).slideDeck + val message = slideDeck.body + ": " + record.body + val italicLength = message.length - body.length + body = SpanUtil.italic(message, italicLength) + } else if (record.isOpenGroupInvitation) { + body = SpanUtil.italic(context.getString(R.string.communityInvitation)) + } + } + + Log.w(TAG, "Adding incoming message notification: ${body}") + + // Add incoming message notification + notificationState.addNotification( + NotificationItem( + record.getId(), + record.isMms || record.isMmsNotification, + record.individualRecipient, + record.recipient, + threadRecipients, + threadId, + body, + record.timestamp, + slideDeck + ) + ) + } + // CASE 2: REACTIONS TO OUR OUTGOING MESSAGES + // Only if: it's OUR message AND it has reactions AND it's NOT an unread incoming message + else if (record.isOutgoing && + hasUnreadReactions && + threadRecipients != null && + !threadRecipients.isGroupOrCommunityRecipient) { + + var blindedPublicKey = cache[threadId] + if (blindedPublicKey == null) { + blindedPublicKey = generateBlindedId(threadId, context) + cache[threadId] = blindedPublicKey + } + + // Find reactions from others (not from us) + val reactionsFromOthers = record.reactions.filter { reaction -> + reaction.author != userPublicKey && + (blindedPublicKey == null || reaction.author != blindedPublicKey) } - val userBlindedPublicKey = blindedPublicKey - val lastReact = Stream.of(record.reactions) - .filter { r: ReactionRecord -> !(r.author == userPublicKey || r.author == userBlindedPublicKey) } - .findLast() - - if (lastReact.isPresent) { - if (threadRecipients != null && !threadRecipients.isGroupOrCommunityRecipient) { - val reaction = lastReact.get() - val reactor = Recipient.from(context, fromSerialized(reaction.author), false) - val emoji = Phrase.from(context, R.string.emojiReactsNotification).put(EMOJI_KEY, reaction.emoji).format().toString() - notificationState.addNotification(NotificationItem(id, mms, reactor, reactor, threadRecipients, threadId, emoji, reaction.dateSent, slideDeck)) + if (reactionsFromOthers.isNotEmpty()) { + // Get the most recent reaction from others + val latestReaction = reactionsFromOthers.maxByOrNull { it.dateSent } + + if (latestReaction != null) { + val reactor = Recipient.from(context, fromSerialized(latestReaction.author), false) + val emoji = Phrase.from(context, R.string.emojiReactsNotification) + .put(EMOJI_KEY, latestReaction.emoji).format().toString() + + // Use unique ID to avoid conflicts with message notifications + val reactionId = "reaction_${record.getId()}_${latestReaction.emoji}_${latestReaction.author}".hashCode().toLong() + + Log.w(TAG, "Adding reaction notification: ${emoji} to our message ID ${record.getId()}") + + notificationState.addNotification( + NotificationItem( + reactionId, + record.isMms || record.isMmsNotification, + reactor, + reactor, + threadRecipients, + threadId, + emoji, + latestReaction.dateSent, + null + ) + ) } } } - } while (record != null) // This will never hit because we break early if we get a null record at the start of the do..while loop + // CASE 3: IGNORED SCENARIOS + // This handles cases like: + // - Contact's message with reactions (hasUnreadReactions=true, but isOutgoing=false) + // - Already read messages that somehow got returned + // - etc. + else { + Log.w(TAG, "Ignoring message: not unread incoming and not our outgoing with reactions") + } + + } while (record != null) reader.close() return notificationState @@ -573,23 +680,17 @@ class DefaultMessageNotifier : MessageNotifier { val openGroup = lokiThreadDatabase.getOpenGroupChat(threadId) val edKeyPair = getUserED25519KeyPair(context) if (openGroup != null && edKeyPair != null) { - val blindedKeyPair = blindedKeyPair(openGroup.publicKey, edKeyPair) + val blindedKeyPair = BlindKeyAPI.blind15KeyPairOrNull( + ed25519SecretKey = edKeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(openGroup.publicKey), + ) if (blindedKeyPair != null) { - return AccountId(IdPrefix.BLINDED, blindedKeyPair.publicKey.asBytes).hexString + return AccountId(IdPrefix.BLINDED, blindedKeyPair.pubKey.data).hexString } } return null } - private fun updateBadge(context: Context, count: Int) { - try { - if (count == 0) ShortcutBadger.removeCount(context) - else ShortcutBadger.applyCount(context, count) - } catch (t: Throwable) { - Log.w("MessageNotifier", t) - } - } - private fun scheduleReminder(context: Context, count: Int) { if (count >= getRepeatAlertsCount(context)) { return @@ -631,9 +732,6 @@ class DefaultMessageNotifier : MessageNotifier { } } - // ACL: What is the concept behind delayed notifications? Why would we ever want this? To batch them up so - // that we get a bunch of notifications once per minute or something rather than a constant stream of them - // if that's what was incoming?!? private class DelayedNotification(private val context: Context, private val threadId: Long) : Runnable { private val canceled = AtomicBoolean(false) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java index 7fb29b9bd7..e95bbbb6f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java @@ -4,6 +4,7 @@ import android.content.Context; import android.content.Intent; import android.os.AsyncTask; + import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt index fee729cf42..9539b17ea7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt @@ -1,50 +1,54 @@ 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 dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth 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.snode.utilities.await -import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.snode.SnodeClock 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.database.model.content.DisappearingMessageUpdate import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.SessionMetaProtocol.shouldSendReadReceipt +import javax.inject.Inject @AndroidEntryPoint class MarkReadReceiver : BroadcastReceiver() { - @SuppressLint("StaticFieldLeak") + @Inject + lateinit var storage: StorageProtocol + + @Inject + lateinit var clock: SnodeClock + override fun onReceive(context: Context, intent: Intent) { if (CLEAR_ACTION != intent.action) return val threadIds = intent.getLongArrayExtra(THREAD_IDS_EXTRA) ?: return NotificationManagerCompat.from(context).cancel(intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)) - object : AsyncTask() { - override fun doInBackground(vararg params: Void?): Void? { - val currentTime = nowWithOffset - threadIds.forEach { - Log.i(TAG, "Marking as read: $it") - shared.storage.markConversationAsRead(it, currentTime, true) - } - return null + GlobalScope.launch { + val currentTime = clock.currentTimeMills() + threadIds.forEach { + Log.i(TAG, "Marking as read: $it") + storage.markConversationAsRead( + threadId = it, + lastSeenTime = currentTime, + force = true + ) } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } } companion object { @@ -53,8 +57,6 @@ class MarkReadReceiver : BroadcastReceiver() { const val THREAD_IDS_EXTRA = "thread_ids" const val NOTIFICATION_ID_EXTRA = "notification_id" - val messageExpirationManager = SSKEnvironment.shared.messageExpirationManager - @JvmStatic fun process( context: Context, @@ -70,17 +72,29 @@ class MarkReadReceiver : BroadcastReceiver() { // start disappear after read messages except TimerUpdates in groups. markedReadMessages + .asSequence() .filter { it.expiryType == ExpiryType.AFTER_READ } - .map { it.syncMessageId } - .filter { mmsSmsDatabase.getMessageForTimestamp(it.timetamp)?.run { - isExpirationTimerUpdate && threadDb.getRecipientForThreadId(threadId)?.isGroupOrCommunityRecipient == true } == false + .filter { mmsSmsDatabase.getMessageById(it.expirationInfo.id)?.run { + (messageContent is DisappearingMessageUpdate) + && threadDb.getRecipientForThreadId(threadId)?.isGroupOrCommunityRecipient == true } == false + } + .forEach { + val db = if (it.expirationInfo.id.mms) { + DatabaseComponent.get(context).mmsDatabase() + } else { + DatabaseComponent.get(context).smsDatabase() + } + + db.markExpireStarted(it.expirationInfo.id.id, nowWithOffset) } - .forEach { messageExpirationManager.startDisappearAfterRead(it.timetamp, it.address.serialize()) } hashToDisappearAfterReadMessage(context, markedReadMessages)?.let { hashToMessages -> GlobalScope.launch { - fetchUpdatedExpiriesAndScheduleDeletion(context, hashToMessages) - shortenExpiryOfDisappearingAfterRead(hashToMessages) + try { + shortenExpiryOfDisappearingAfterRead(hashToMessages) + } catch (e: Exception) { + Log.e(TAG, "Failed to fetch updated expiries and schedule deletion", e) + } } } } @@ -93,7 +107,7 @@ class MarkReadReceiver : BroadcastReceiver() { return markedReadMessages .filter { it.expiryType == ExpiryType.AFTER_READ } - .associateByNotNull { it.expirationInfo.run { loki.getMessageServerHash(id, isMms) } } + .associateByNotNull { it.expirationInfo.run { loki.getMessageServerHash(id) } } .takeIf { it.isNotEmpty() } } @@ -130,38 +144,5 @@ class MarkReadReceiver : BroadcastReceiver() { .let { send(it, address) } } } - - private suspend fun fetchUpdatedExpiriesAndScheduleDeletion( - context: Context, - hashToMessage: Map - ) { - @Suppress("UNCHECKED_CAST") - val expiries = SnodeAPI.getExpiries(hashToMessage.keys.toList(), shared.storage.userAuth!!).await()["expiries"] as Map - hashToMessage.forEach { (hash, info) -> expiries[hash]?.let { scheduleDeletion(context, info.expirationInfo, it - info.expirationInfo.expireStarted) } } - } - - private fun scheduleDeletion( - context: Context?, - expirationInfo: ExpirationInfo, - expiresIn: Long = expirationInfo.expiresIn - ) { - if (expiresIn == 0L) return - - val now = nowWithOffset - - val expireStarted = expirationInfo.expireStarted - - if (expirationInfo.isDisappearAfterRead() && expireStarted == 0L || now < expireStarted) { - val db = DatabaseComponent.get(context!!).run { if (expirationInfo.isMms) mmsDatabase() else smsDatabase() } - db.markExpireStarted(expirationInfo.id, now) - } - - ApplicationContext.getInstance(context).expiringMessageManager.scheduleDeletion( - expirationInfo.id, - expirationInfo.isMms, - now, - expiresIn - ) - } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.kt index 377c3e6237..e9da267dcd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.kt @@ -40,7 +40,7 @@ class MultipleRecipientNotificationBuilder(context: Context, privacy: Notificati } fun setMostRecentSender(recipient: Recipient, threadRecipient: Recipient) { - var displayName = recipient.toShortString() + var displayName = recipient.name if (threadRecipient.isGroupOrCommunityRecipient) { displayName = getGroupDisplayName(recipient, threadRecipient.isCommunityRecipient) } @@ -58,7 +58,7 @@ class MultipleRecipientNotificationBuilder(context: Context, privacy: Notificati fun addActions(markAsReadIntent: PendingIntent?) { val markAllAsReadAction = NotificationCompat.Action( - R.drawable.check, + R.drawable.ic_check, context.getString(R.string.messageMarkRead), markAsReadIntent ) @@ -69,7 +69,7 @@ class MultipleRecipientNotificationBuilder(context: Context, privacy: Notificati fun putStringExtra(key: String?, value: String?) { extras.putString(key, value) } fun addMessageBody(sender: Recipient, threadRecipient: Recipient, body: CharSequence?) { - var displayName = sender.toShortString() + var displayName = sender.name if (threadRecipient.isGroupOrCommunityRecipient) { displayName = getGroupDisplayName(sender, threadRecipient.isCommunityRecipient) } @@ -103,8 +103,8 @@ class MultipleRecipientNotificationBuilder(context: Context, privacy: Notificati * @param openGroupRecipient whether in an open group context */ private fun getGroupDisplayName(recipient: Recipient, openGroupRecipient: Boolean): String { - return MessagingModuleConfiguration.shared.storage.getContactNameWithAccountID( - accountID = recipient.address.serialize(), + return MessagingModuleConfiguration.shared.usernameUtils.getContactNameWithAccountID( + accountID = recipient.address.toString(), contactContext = if (openGroupRecipient) Contact.ContactContext.OPEN_GROUP else Contact.ContactContext.REGULAR ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java index 0c3422b757..8880bdc617 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java @@ -89,8 +89,8 @@ public static synchronized void create(@NonNull Context context) { return systemName; } else if (!TextUtils.isEmpty(profileName)) { return profileName; - } else if (!TextUtils.isEmpty(address.serialize())) { - return address.serialize(); + } else if (!TextUtils.isEmpty(address.toString())) { + return address.toString(); } else { return context.getString(R.string.unknown); } @@ -233,7 +233,7 @@ private static void setLedPreference(@NonNull NotificationChannel channel, @NonN private static @NonNull String generateChannelIdFor(@NonNull Address address) { - return CONTACT_PREFIX + address.serialize() + "_" + System.currentTimeMillis(); + return CONTACT_PREFIX + address.toString() + "_" + System.currentTimeMillis(); } private static @NonNull NotificationChannel copyChannel(@NonNull NotificationChannel original, @NonNull String id) { 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 bc85ff0874..aa704daa97 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationState.java @@ -33,15 +33,20 @@ public NotificationState(@NonNull List items) { } } - public void addNotification(NotificationItem item) { - // Add this new notification at the beginning of the list - notifications.addFirst(item); + public void addNotification(@NonNull NotificationItem item) { + // find the index to insert the message based on their timestamp + int i = 0; + while (i < notifications.size() && + notifications.get(i).getTimestamp() > item.getTimestamp()) { + i++; + } + notifications.add(i, item); // Put a notification at the front by removing it then re-adding it? threads.remove(item.getThreadId()); threads.add(item.getThreadId()); - notificationCount++; + notificationCount = notifications.size(); } public @Nullable Uri getRingtone(@NonNull Context context) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java index 5f712e3210..cdd87d155c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java @@ -3,29 +3,40 @@ import android.content.Context; import android.os.Looper; -import androidx.annotation.MainThread; import androidx.annotation.NonNull; import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; +import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerManager; import org.session.libsession.messaging.sending_receiving.pollers.Poller; -import org.session.libsession.utilities.recipients.Recipient; +import org.session.libsession.messaging.sending_receiving.pollers.PollerManager; import org.session.libsession.utilities.Debouncer; +import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.ThreadUtils; import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.groups.OpenGroupManager; +import org.thoughtcrime.securesms.util.AvatarUtils; import java.util.concurrent.TimeUnit; +import javax.inject.Inject; +import javax.inject.Singleton; + import kotlin.Unit; +@Singleton public class OptimizedMessageNotifier implements MessageNotifier { private final MessageNotifier wrapped; private final Debouncer debouncer; - @MainThread - public OptimizedMessageNotifier(@NonNull MessageNotifier wrapped) { - this.wrapped = wrapped; + private final OpenGroupPollerManager openGroupPollerManager; + + private final PollerManager pollerManager; + + @Inject + public OptimizedMessageNotifier(AvatarUtils avatarUtils, OpenGroupPollerManager openGroupPollerManager, PollerManager pollerManager) { + this.wrapped = new DefaultMessageNotifier(avatarUtils); + this.openGroupPollerManager = openGroupPollerManager; this.debouncer = new Debouncer(TimeUnit.SECONDS.toMillis(2)); + this.pollerManager = pollerManager; } @Override @@ -49,13 +60,10 @@ public void notifyMessageDeliveryFailed(Context context, Recipient recipient, lo @Override public void updateNotification(@NonNull Context context) { - Poller poller = ApplicationContext.getInstance(context).poller; boolean isCaughtUp = true; - if (poller != null) { - isCaughtUp = isCaughtUp && !poller.isPolling(); - } + isCaughtUp = isCaughtUp && !pollerManager.isPolling(); - isCaughtUp = isCaughtUp && OpenGroupManager.INSTANCE.isAllCaughtUp(); + isCaughtUp = isCaughtUp && openGroupPollerManager.isAllCaughtUp(); if (isCaughtUp) { performOnBackgroundThreadIfNeeded(() -> wrapped.updateNotification(context)); @@ -66,13 +74,10 @@ public void updateNotification(@NonNull Context context) { @Override public void updateNotification(@NonNull Context context, long threadId) { - Poller lokiPoller = ApplicationContext.getInstance(context).poller; boolean isCaughtUp = true; - if (lokiPoller != null) { - isCaughtUp = isCaughtUp && !lokiPoller.isPolling(); - } + isCaughtUp = isCaughtUp && !pollerManager.isPolling(); - isCaughtUp = isCaughtUp && OpenGroupManager.INSTANCE.isAllCaughtUp(); + isCaughtUp = isCaughtUp && openGroupPollerManager.isAllCaughtUp(); if (isCaughtUp) { performOnBackgroundThreadIfNeeded(() -> wrapped.updateNotification(context, threadId)); @@ -83,13 +88,10 @@ public void updateNotification(@NonNull Context context, long threadId) { @Override public void updateNotification(@NonNull Context context, long threadId, boolean signal) { - Poller lokiPoller = ApplicationContext.getInstance(context).poller; boolean isCaughtUp = true; - if (lokiPoller != null) { - isCaughtUp = isCaughtUp && !lokiPoller.isPolling(); - } + isCaughtUp = isCaughtUp && !pollerManager.isPolling(); - isCaughtUp = isCaughtUp && OpenGroupManager.INSTANCE.isAllCaughtUp(); + isCaughtUp = isCaughtUp && openGroupPollerManager.isAllCaughtUp(); if (isCaughtUp) { performOnBackgroundThreadIfNeeded(() -> wrapped.updateNotification(context, threadId, signal)); @@ -100,13 +102,10 @@ public void updateNotification(@NonNull Context context, long threadId, boolean @Override public void updateNotification(@androidx.annotation.NonNull Context context, boolean signal, int reminderCount) { - Poller lokiPoller = ApplicationContext.getInstance(context).poller; boolean isCaughtUp = true; - if (lokiPoller != null) { - isCaughtUp = isCaughtUp && !lokiPoller.isPolling(); - } + isCaughtUp = isCaughtUp && !pollerManager.isPolling(); - isCaughtUp = isCaughtUp && OpenGroupManager.INSTANCE.isAllCaughtUp(); + isCaughtUp = isCaughtUp && openGroupPollerManager.isAllCaughtUp(); if (isCaughtUp) { performOnBackgroundThreadIfNeeded(() -> wrapped.updateNotification(context, signal, reminderCount)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt index b8474b05b8..1ff63492e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt @@ -1,27 +1,28 @@ package org.thoughtcrime.securesms.notifications import android.Manifest +import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.content.pm.PackageManager import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat.getString -import com.goterl.lazysodium.interfaces.AEAD -import com.goterl.lazysodium.utils.Key import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import network.loki.messenger.R +import network.loki.messenger.libsession_util.Namespace +import network.loki.messenger.libsession_util.SessionEncrypt +import okio.ByteString.Companion.decodeHex 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.Destination 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.ConfigMessage import org.session.libsession.utilities.bencode.Bencode import org.session.libsession.utilities.bencode.BencodeList @@ -31,26 +32,30 @@ import org.session.libsignal.protos.SignalServiceProtos.Envelope import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Namespace +import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.groups.GroupRevokedMessageHandler +import org.thoughtcrime.securesms.home.HomeActivity +import java.security.SecureRandom import javax.inject.Inject private const val TAG = "PushHandler" class PushReceiver @Inject constructor( - @ApplicationContext private val context: Context, + @param:ApplicationContext private val context: Context, private val configFactory: ConfigFactory, private val groupRevokedMessageHandler: GroupRevokedMessageHandler, + private val json: Json, + private val batchJobFactory: BatchMessageReceiveJob.Factory, ) { - private val json = Json { ignoreUnknownKeys = true } /** * Both push services should hit this method once they receive notification data * As long as it is properly formatted */ fun onPushDataReceived(dataMap: Map?) { + Log.d(TAG, "Push data received: $dataMap") addMessageReceiveJob(dataMap?.asPushData()) } @@ -132,9 +137,12 @@ class PushReceiver @Inject constructor( } namespace == Namespace.DEFAULT() || pushData?.metadata == null -> { - // send a generic notification if we have no data if (pushData?.data == null) { - sendGenericNotification() + Log.d(TAG, "Push data is null") + if(pushData?.metadata?.data_too_long != true) { + Log.d(TAG, "Sending a generic notification (data_too_long was false)") + sendGenericNotification() + } return } @@ -152,7 +160,7 @@ class PushReceiver @Inject constructor( } if (params != null) { - JobQueue.shared.add(BatchMessageReceiveJob(listOf(params), null)) + JobQueue.shared.add(batchJobFactory.create(listOf(params))) } } catch (e: Exception) { Log.d(TAG, "Failed to unwrap data for message due to error.", e) @@ -172,13 +180,11 @@ class PushReceiver @Inject constructor( Log.d(TAG, "Successfully decrypted group message from $sender") return Envelope.parseFrom(envelopBytes) .toBuilder() - .setSource(sender.hexString) + .setSource(sender) .build() } private fun sendGenericNotification() { - Log.d(TAG, "Failed to decode data for message.") - // no need to do anything if notification permissions are not granted if (ActivityCompat.checkSelfPermission( context, @@ -198,6 +204,7 @@ class PushReceiver @Inject constructor( .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setAutoCancel(true) + .setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, HomeActivity::class.java), PendingIntent.FLAG_IMMUTABLE)) NotificationManagerCompat.from(context).notify(11111, builder.build()) } @@ -221,18 +228,16 @@ class PushReceiver @Inject constructor( Log.d(TAG, "decrypt() called") val encKey = getOrCreateNotificationKey() - val nonce = encPayload.sliceArray(0 until AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES) - val payload = - encPayload.sliceArray(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES until encPayload.size) - val padded = SodiumUtilities.decrypt(payload, encKey.asBytes, nonce) - ?: error("Failed to decrypt push notification") - val contentEndedAt = padded.indexOfLast { it.toInt() != 0 } - val decrypted = if (contentEndedAt >= 0) padded.sliceArray(0..contentEndedAt) else padded + val decrypted = SessionEncrypt.decryptPushNotification( + message = encPayload, + secretKey = encKey + ).data + 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 metadataJson = (expectedList.getOrNull(0) as? BencodeString)?.value ?: error("no metadata") val metadata: PushNotificationMetadata = json.decodeFromString(String(metadataJson)) return PushData( @@ -245,15 +250,15 @@ class PushReceiver @Inject constructor( } } - fun getOrCreateNotificationKey(): Key { + fun getOrCreateNotificationKey(): ByteArray { val keyHex = IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY) if (keyHex != null) { - return Key.fromHexString(keyHex) + return keyHex.decodeHex().toByteArray() } // generate the key and store it - val key = sodium.keygen(AEAD.Method.XCHACHA20_POLY1305_IETF) - IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString) + val key = ByteArray(32).also { SecureRandom().nextBytes(it) } + IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.toHexString()) return key } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt index 6c68e9c26d..dcce84bcd2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt @@ -1,33 +1,32 @@ package org.thoughtcrime.securesms.notifications +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.scan import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope import org.session.libsession.database.userAuth import org.session.libsession.messaging.notifications.TokenFetcher -import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.snode.SwarmAuth import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Namespace -import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import javax.inject.Inject +import javax.inject.Singleton private const val TAG = "PushRegistrationHandler" @@ -37,22 +36,23 @@ private const val TAG = "PushRegistrationHandler" * * This class DOES NOT handle the legacy groups push notification. */ +@Singleton class PushRegistrationHandler @Inject constructor( - private val pushRegistry: PushRegistryV2, private val configFactory: ConfigFactory, private val preferences: TextSecurePreferences, - private val storage: Storage, private val tokenFetcher: TokenFetcher, -) { - @OptIn(DelicateCoroutinesApi::class) - private val scope: CoroutineScope = GlobalScope + @param:ApplicationContext private val context: Context, + private val registry: PushRegistryV2, + private val storage: Storage, + @param:ManagerScope private val scope: CoroutineScope +) : OnAppStartupComponent { private var job: Job? = null @OptIn(FlowPreview::class) - fun run() { + override fun onPostAppStarted() { require(job == null) { "Job is already running" } job = scope.launch(Dispatchers.Default) { @@ -60,122 +60,85 @@ constructor( (configFactory.configUpdateNotifications as Flow) .debounce(500L) .onStart { emit(Unit) }, - IdentityKeyUtil.CHANGES.onStart { emit(Unit) }, + preferences.watchLocalNumber(), preferences.pushEnabled, tokenFetcher.token, - ) { _, _, enabled, token -> - if (!enabled || token.isNullOrEmpty()) { - return@combine emptyMap() + ) { _, myAccountId, enabled, token -> + if (!enabled || myAccountId == null || storage.getUserED25519KeyPair() == null || token.isNullOrEmpty()) { + return@combine emptySet() } - val userAuth = - storage.userAuth ?: return@combine emptyMap() - getGroupSubscriptions( - token = token - ) + mapOf( - SubscriptionKey(userAuth.accountId, token) to Subscription(userAuth, listOf(Namespace.DEFAULT())) - ) + setOf(SubscriptionKey(AccountId(myAccountId), token)) + getGroupSubscriptions(token) } - .scan, Pair, Map>?>( - null - ) { acc, current -> - val prev = acc?.second.orEmpty() - prev to current + .scan(emptySet() to emptySet()) { acc, current -> + acc.second to current } - .filterNotNull() .collect { (prev, current) -> - val addedAccountIds = current.keys - prev.keys - val removedAccountIDs = prev.keys - current.keys - if (addedAccountIds.isNotEmpty()) { - Log.d(TAG, "Adding ${addedAccountIds.size} new subscriptions") + val added = current - prev + val removed = prev - current + if (added.isNotEmpty()) { + Log.d(TAG, "Adding ${added.size} new subscriptions") } - if (removedAccountIDs.isNotEmpty()) { - Log.d(TAG, "Removing ${removedAccountIDs.size} subscriptions") + if (removed.isNotEmpty()) { + Log.d(TAG, "Removing ${removed.size} subscriptions") } - val deferred = mutableListOf>() - - addedAccountIds.mapTo(deferred) { key -> - val subscription = current.getValue(key) - async { - try { - pushRegistry.register( - token = key.token, - swarmAuth = subscription.auth, - namespaces = subscription.namespaces.toList() - ) - } catch (e: Exception) { - Log.e(TAG, "Failed to register for push notification", e) - } - } + for (key in added) { + PushRegistrationWorker.schedule( + context = context, + token = key.token, + accountId = key.accountId, + ) } - removedAccountIDs.mapTo(deferred) { key -> - val subscription = prev.getValue(key) - async { - try { - pushRegistry.unregister( - token = key.token, - swarmAuth = subscription.auth, - ) - } catch (e: Exception) { - Log.e(TAG, "Failed to unregister for push notification", e) + supervisorScope { + for (key in removed) { + PushRegistrationWorker.cancelRegistration( + context = context, + accountId = key.accountId, + ) + + launch { + Log.d(TAG, "Unregistering push token for account: ${key.accountId}") + try { + val swarmAuth = swarmAuthForAccount(key.accountId) + ?: throw IllegalStateException("No SwarmAuth found for account: ${key.accountId}") + + registry.unregister( + token = key.token, + swarmAuth = swarmAuth, + ) + + Log.d(TAG, "Successfully unregistered push token for account: ${key.accountId}") + } catch (e: Exception) { + if (e !is CancellationException) { + Log.e(TAG, "Failed to unregister push token for account: ${key.accountId}", e) + } + } } } } - - deferred.awaitAll() } } } + private fun swarmAuthForAccount(accountId: AccountId): SwarmAuth? { + return when (accountId.prefix) { + IdPrefix.STANDARD -> storage.userAuth?.takeIf { it.accountId == accountId } + IdPrefix.GROUP -> configFactory.getGroupAuth(accountId) + else -> null // Unsupported account ID prefix + } + } + private fun getGroupSubscriptions( token: String - ): Map { - return buildMap { - val groups = configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() } - .filter { it.shouldPoll } - - val namespaces = listOf( - Namespace.GROUP_MESSAGES(), - Namespace.GROUP_INFO(), - Namespace.GROUP_MEMBERS(), - Namespace.GROUP_KEYS(), - Namespace.REVOKED_GROUP_MESSAGES(), - ) - - for (group in groups) { - val adminKey = group.adminKey - if (adminKey != null && adminKey.isNotEmpty()) { - put( - SubscriptionKey(group.groupAccountId, token), - Subscription( - auth = OwnedSwarmAuth.ofClosedGroup(group.groupAccountId, adminKey), - namespaces = namespaces - ) - ) - continue - } - - val authData = group.authData - if (authData != null && authData.isNotEmpty()) { - val subscription = configFactory.getGroupAuth(group.groupAccountId) - ?.let { - Subscription( - auth = it, - namespaces = namespaces - ) - } - - if (subscription != null) { - put(SubscriptionKey(group.groupAccountId, token), subscription) - } - } - } - } + ): Set { + return configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() } + .asSequence() + .filter { it.shouldPoll } + .mapTo(hashSetOf()) { SubscriptionKey(accountId = AccountId(it.groupAccountId), token = token) } } private data class SubscriptionKey(val accountId: AccountId, val token: String) - private data class Subscription(val auth: SwarmAuth, val namespaces: List) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt new file mode 100644 index 0000000000..f9c3810bbd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt @@ -0,0 +1,131 @@ +package org.thoughtcrime.securesms.notifications + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.Operation +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.await +import androidx.work.impl.background.systemjob.setRequiredNetworkRequest +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException +import network.loki.messenger.libsession_util.Namespace +import org.session.libsession.database.userAuth +import org.session.libsignal.exceptions.NonRetryableException +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import java.time.Duration + +@HiltWorker +class PushRegistrationWorker @AssistedInject constructor( + @Assisted val context: Context, + @Assisted val params: WorkerParameters, + val registry: PushRegistryV2, + val storage: Storage, + val configFactory: ConfigFactory, +) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + val accountId = checkNotNull(inputData.getString(ARG_ACCOUNT_ID) + ?.let(AccountId::fromStringOrNull)) { + "PushRegistrationWorker requires a valid account ID" + } + + val token = checkNotNull(inputData.getString(ARG_TOKEN)) { + "PushRegistrationWorker requires a valid FCM token" + } + + Log.d(TAG, "Registering push token for account: $accountId with token: ${token.substring(0..10)}") + + val (swarmAuth, namespaces) = when (accountId.prefix) { + IdPrefix.STANDARD -> { + val auth = requireNotNull(storage.userAuth) { + "PushRegistrationWorker requires user authentication to register push notifications" + } + + // A standard account ID means ourselves, so we use the local auth. + require(accountId == auth.accountId) { + "PushRegistrationWorker can only register the local account ID" + } + + auth to REGULAR_PUSH_NAMESPACES + } + IdPrefix.GROUP -> { + requireNotNull(configFactory.getGroupAuth(accountId)) to GROUP_PUSH_NAMESPACES + } + else -> { + throw IllegalArgumentException("Unsupported account ID prefix: ${accountId.prefix}") + } + } + + try { + registry.register(token = token, swarmAuth = swarmAuth, namespaces = namespaces) + Log.d(TAG, "Successfully registered push token for account: $accountId") + return Result.success() + } catch (e: CancellationException) { + Log.d(TAG, "Push registration cancelled for account: $accountId") + throw e + } catch (e: Exception) { + Log.e(TAG, "Unexpected error while registering push token for account: $accountId", e) + return if (e is NonRetryableException) Result.failure() else Result.retry() + } + } + + companion object { + private const val ARG_TOKEN = "token" + private const val ARG_ACCOUNT_ID = "account_id" + + private const val TAG = "PushRegistrationWorker" + + private val GROUP_PUSH_NAMESPACES = listOf( + Namespace.GROUP_MESSAGES(), + Namespace.GROUP_INFO(), + Namespace.GROUP_MEMBERS(), + Namespace.GROUP_KEYS(), + Namespace.REVOKED_GROUP_MESSAGES(), + ) + + private val REGULAR_PUSH_NAMESPACES = listOf(Namespace.DEFAULT()) + + private fun uniqueWorkName(accountId: AccountId): String { + return "push-registration-${accountId.hexString}" + } + + fun schedule( + context: Context, + token: String, + accountId: AccountId, + ) { + val request = OneTimeWorkRequestBuilder() + .setInputData( + Data.Builder().putString(ARG_TOKEN, token) + .putString(ARG_ACCOUNT_ID, accountId.hexString).build() + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofSeconds(10)) + .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED)) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + uniqueWorkName = uniqueWorkName(accountId), + existingWorkPolicy = ExistingWorkPolicy.REPLACE, + request = request + ) + } + + suspend fun cancelRegistration(context: Context, accountId: AccountId) { + WorkManager.getInstance(context) + .cancelUniqueWork(uniqueWorkName(accountId)) + .await() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt index aeb128093d..17db072713 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt @@ -26,6 +26,7 @@ import org.session.libsession.snode.Version import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Device import org.session.libsignal.utilities.retryWithUniformInterval +import org.session.libsignal.utilities.toHexString import javax.inject.Inject import javax.inject.Singleton @@ -58,10 +59,10 @@ class PushRegistryV2 @Inject constructor( service = device.service, sig_ts = timestamp, service_info = mapOf("token" to token), - enc_key = pnKey.asHexString, + enc_key = pnKey.toHexString(), ).let(Json::encodeToJsonElement).jsonObject + signed - val response = retryResponseBody( + val response = getResponseBody( "subscribe", Json.encodeToString(requestParameters) ) @@ -90,7 +91,7 @@ class PushRegistryV2 @Inject constructor( service_info = mapOf("token" to token), ).let(Json::encodeToJsonElement).jsonObject + signature - val response: UnsubscribeResponse = retryResponseBody("unsubscribe", Json.encodeToString(requestParameters)) + val response: UnsubscribeResponse = getResponseBody("unsubscribe", Json.encodeToString(requestParameters)) check(response.isSuccess()) { "Error unsubscribing to push notifications: ${response.message}" @@ -106,9 +107,6 @@ class PushRegistryV2 @Inject constructor( }) } - private suspend inline fun retryResponseBody(path: String, requestParameters: String): T = - retryWithUniformInterval(maxRetryCount = maxRetryCount) { getResponseBody(path, requestParameters) } - @OptIn(ExperimentalSerializationApi::class) private suspend inline fun getResponseBody(path: String, requestParameters: String): T { val server = Server.LATEST 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 c1391b9a8f..3144bd33f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -41,6 +41,7 @@ import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.Storage; import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.mms.MmsException; import java.util.Collections; @@ -109,7 +110,7 @@ protected Void doInBackground(Void... params) { case GroupMessage: { OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null, expiresInMillis, 0); try { - mmsDatabase.insertMessageOutbox(reply, threadId, false, null, true); + message.setId(new MessageId(mmsDatabase.insertMessageOutbox(reply, threadId, false, true), true)); MessageSender.send(message, address); } catch (MmsException e) { Log.w(TAG, e); @@ -118,7 +119,7 @@ protected Void doInBackground(Void... params) { } case SecureMessage: { OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt); - smsDatabase.insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), null, true); + message.setId(new MessageId(smsDatabase.insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), true), false)); 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 9f252ad8e1..0c079d73a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -33,12 +33,10 @@ import org.session.libsession.utilities.Util; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.database.SessionContactDatabase; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; -import org.thoughtcrime.securesms.util.AvatarPlaceholderGenerator; +import org.thoughtcrime.securesms.util.AvatarUtils; import org.thoughtcrime.securesms.util.BitmapUtil; import java.util.LinkedList; @@ -56,13 +54,18 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil private SlideDeck slideDeck; private CharSequence contentTitle; private CharSequence contentText; + private AvatarUtils avatarUtils; private static final Integer ICON_SIZE = 128; - public SingleRecipientNotificationBuilder(@NonNull Context context, @NonNull NotificationPrivacyPreference privacy) - { + public SingleRecipientNotificationBuilder( + @NonNull Context context, + @NonNull NotificationPrivacyPreference privacy, + @NonNull AvatarUtils avatarUtils + ) { super(context, privacy); + this.avatarUtils = avatarUtils; setSmallIcon(R.drawable.ic_notification); setColor(ContextCompat.getColor(context, R.color.accent_green)); setCategory(NotificationCompat.CATEGORY_MESSAGE); @@ -73,7 +76,7 @@ public void setThread(@NonNull Recipient recipient) { setChannelId(channelId != null ? channelId : NotificationChannels.getMessagesChannel(context)); if (privacy.isDisplayContact()) { - setContentTitle(recipient.toShortString()); + setContentTitle(recipient.getName()); if (recipient.getContactUri() != null) { addPerson(recipient.getContactUri().toString()); @@ -95,15 +98,15 @@ public void setThread(@NonNull Recipient recipient) { setLargeIcon(iconBitmap); } catch (InterruptedException | ExecutionException e) { Log.w(TAG, "get iconBitmap in getThread failed", e); - setLargeIcon(getPlaceholderDrawable(context, recipient)); + setLargeIcon(getPlaceholderDrawable(avatarUtils, recipient)); } } else { - setLargeIcon(getPlaceholderDrawable(context, recipient)); + setLargeIcon(getPlaceholderDrawable(avatarUtils, recipient)); } } else { setContentTitle(context.getString(R.string.app_name)); - setLargeIcon(AvatarPlaceholderGenerator.generate(context, ICON_SIZE, "", "Unknown")); + setLargeIcon(avatarUtils.generateTextBitmap(ICON_SIZE, "", "Unknown")); } } @@ -158,7 +161,7 @@ public void addActions(@NonNull PendingIntent markReadIntent, @Nullable PendingIntent wearableReplyIntent, @NonNull ReplyMethod replyMethod) { - Action markAsReadAction = new Action(R.drawable.check, + Action markAsReadAction = new Action(R.drawable.ic_check, context.getString(R.string.messageMarkRead), markReadIntent); @@ -170,9 +173,9 @@ public void addActions(@NonNull PendingIntent markReadIntent, String actionName = context.getString(R.string.reply); String label = context.getString(replyMethodLongDescription(replyMethod)); - Action replyAction = new Action(R.drawable.ic_reply_white_36dp, actionName, quickReplyIntent); + Action replyAction = new Action(R.drawable.ic_reply, actionName, quickReplyIntent); - replyAction = new Action.Builder(R.drawable.ic_reply_white_36dp, + replyAction = new Action.Builder(R.drawable.ic_reply, actionName, wearableReplyIntent) .addRemoteInput(new RemoteInput.Builder(DefaultMessageNotifier.EXTRA_REMOTE_REPLY).setLabel(label).build()) @@ -320,10 +323,10 @@ private CharSequence getBigText(List messageBodies) { return content; } - private static Drawable getPlaceholderDrawable(Context context, Recipient recipient) { - String publicKey = recipient.getAddress().serialize(); + private static Drawable getPlaceholderDrawable(AvatarUtils avatarUtils, Recipient recipient) { + String publicKey = recipient.getAddress().toString(); String displayName = recipient.getName(); - return AvatarPlaceholderGenerator.generate(context, ICON_SIZE, publicKey, displayName); + return avatarUtils.generateTextBitmap(ICON_SIZE, publicKey, displayName); } /** @@ -331,8 +334,8 @@ private static Drawable getPlaceholderDrawable(Context context, Recipient recipi * @param openGroupRecipient whether in an open group context */ private String getGroupDisplayName(Recipient recipient, boolean openGroupRecipient) { - return MessagingModuleConfiguration.getShared().getStorage().getContactNameWithAccountID( - recipient.getAddress().serialize(), + return MessagingModuleConfiguration.getShared().getUsernameUtils().getContactNameWithAccountID( + recipient.getAddress().toString(), null, openGroupRecipient ? Contact.ContactContext.OPEN_GROUP : Contact.ContactContext.REGULAR ); diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/OnboardingBackPressAlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/OnboardingBackPressAlertDialog.kt index 64d91c58af..b985490675 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/OnboardingBackPressAlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/OnboardingBackPressAlertDialog.kt @@ -6,12 +6,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.squareup.phrase.Phrase import network.loki.messenger.R -import org.session.libsession.utilities.NonTranslatableStringConstants.APP_NAME import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.thoughtcrime.securesms.ui.AlertDialog -import org.thoughtcrime.securesms.ui.DialogButtonModel +import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.getSubbedString import org.thoughtcrime.securesms.ui.theme.LocalColors @Composable @@ -29,12 +27,12 @@ fun OnboardingBackPressAlertDialog( Phrase.from(txt).put(APP_NAME_KEY, c.getString(R.string.app_name)).format().toString() }, buttons = listOf( - DialogButtonModel( + DialogButtonData( text = GetString(stringResource(id = R.string.quitButton)), color = LocalColors.current.danger, onClick = quit ), - DialogButtonModel( + DialogButtonData( GetString(stringResource(R.string.cancel)) ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt index 14eb2dbe06..d66338ca3f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt @@ -40,12 +40,12 @@ import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY import org.thoughtcrime.securesms.ui.AlertDialog -import org.thoughtcrime.securesms.ui.DialogButtonModel +import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.components.BorderlessHtmlButton -import org.thoughtcrime.securesms.ui.components.PrimaryFillButton -import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton -import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.components.AccentFillButton +import org.thoughtcrime.securesms.ui.components.AccentOutlineButton +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -83,11 +83,11 @@ internal fun LandingScreen( text = stringResource(R.string.urlOpenBrowser), showCloseButton = true, // display the 'x' button buttons = listOf( - DialogButtonModel( + DialogButtonData( text = GetString(R.string.onboardingTos), onClick = openTerms ), - DialogButtonModel( + DialogButtonData( text = GetString(R.string.onboardingPrivacy), onClick = openPrivacyPolicy ) @@ -164,21 +164,21 @@ internal fun LandingScreen( } Column(modifier = Modifier.padding(horizontal = LocalDimensions.current.xlargeSpacing)) { - PrimaryFillButton( + AccentFillButton( text = stringResource(R.string.onboardingAccountCreate), modifier = Modifier .fillMaxWidth() .align(Alignment.CenterHorizontally) - .contentDescription(R.string.AccessibilityId_onboardingAccountCreate), + .qaTag(R.string.AccessibilityId_onboardingAccountCreate), onClick = createAccount ) Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - PrimaryOutlineButton( + AccentOutlineButton( stringResource(R.string.onboardingAccountExists), modifier = Modifier .fillMaxWidth() .align(Alignment.CenterHorizontally) - .contentDescription(R.string.AccessibilityId_onboardingAccountExists), + .qaTag(R.string.AccessibilityId_onboardingAccountExists), onClick = loadAccount ) BorderlessHtmlButton( @@ -186,7 +186,7 @@ internal fun LandingScreen( modifier = Modifier .fillMaxWidth() .align(Alignment.CenterHorizontally) - .contentDescription(R.string.AccessibilityId_urlOpenBrowser), + .qaTag(R.string.AccessibilityId_urlOpenBrowser), onClick = { isUrlDialogVisible = true } ) Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) @@ -200,7 +200,6 @@ private fun AnimateMessageText(text: String, isOutgoing: Boolean, modifier: Modi LaunchedEffect(Unit) { visible = true } Box { - // TODO [SES-2077] Use LazyList itemAnimation when we update to compose 1.7 or so. MessageText(text, isOutgoing, Modifier.alpha(0f)) AnimatedVisibility( @@ -218,7 +217,7 @@ private fun MessageText(text: String, isOutgoing: Boolean, modifier: Modifier) { Box(modifier = modifier then Modifier.fillMaxWidth()) { MessageText( text, - color = if (isOutgoing) LocalColors.current.primary else LocalColors.current.backgroundBubbleReceived, + color = if (isOutgoing) LocalColors.current.accent else LocalColors.current.backgroundBubbleReceived, textColor = if (isOutgoing) LocalColors.current.textBubbleSent else LocalColors.current.textBubbleReceived, modifier = Modifier.align(if (isOutgoing) Alignment.TopEnd else Alignment.TopStart) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt index 57cfb1dcab..ba2154d0c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt @@ -1,9 +1,9 @@ package org.thoughtcrime.securesms.onboarding.loadaccount -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -14,6 +14,7 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -23,11 +24,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import kotlinx.coroutines.flow.Flow import network.loki.messenger.R -import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton +import org.thoughtcrime.securesms.onboarding.ui.ContinueAccentOutlineButton import org.thoughtcrime.securesms.ui.components.QRScannerScreen import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.SessionTabRow -import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -45,15 +45,24 @@ internal fun LoadAccountScreen( ) { val pagerState = rememberPagerState { TITLES.size } - Column { - SessionTabRow(pagerState, TITLES) - HorizontalPager( - state = pagerState, - modifier = Modifier.weight(1f) - ) { page -> - when (TITLES[page]) { - R.string.sessionRecoveryPassword -> RecoveryPassword(state, onChange, onContinue) - R.string.qrScan -> QRScannerScreen(qrErrors, onScan = onScan) + Scaffold { paddingValues -> + Column { + SessionTabRow(pagerState, TITLES) + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f) + ) { page -> + when (TITLES[page]) { + R.string.sessionRecoveryPassword -> RecoveryPassword( + modifier = Modifier.padding(bottom = paddingValues.calculateBottomPadding()) + .consumeWindowInsets(paddingValues), + state = state, + onChange = onChange, + onContinue = onContinue + ) + + R.string.qrScan -> QRScannerScreen(qrErrors, onScan = onScan) + } } } } @@ -68,9 +77,14 @@ private fun PreviewRecoveryPassword() { } @Composable -private fun RecoveryPassword(state: State, onChange: (String) -> Unit = {}, onContinue: () -> Unit = {}) { +private fun RecoveryPassword( + state: State, + modifier: Modifier = Modifier, + onChange: (String) -> Unit = {}, + onContinue: () -> Unit = {} +) { Column( - modifier = Modifier + modifier = modifier .fillMaxSize() .verticalScroll(rememberScrollState()) ) { @@ -88,7 +102,7 @@ private fun RecoveryPassword(state: State, onChange: (String) -> Unit = {}, onCo Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) Icon( modifier = Modifier.align(Alignment.CenterVertically), - painter = painterResource(id = R.drawable.ic_shield_outline), + painter = painterResource(id = R.drawable.ic_recovery_password_custom), contentDescription = null, ) } @@ -102,7 +116,7 @@ private fun RecoveryPassword(state: State, onChange: (String) -> Unit = {}, onCo SessionOutlinedTextField( text = state.recoveryPhrase, modifier = Modifier.fillMaxWidth() - .qaTag(stringResource(R.string.AccessibilityId_recoveryPasswordEnter)), + .qaTag(R.string.AccessibilityId_recoveryPasswordEnter), placeholder = stringResource(R.string.recoveryPasswordEnter), onChange = onChange, onContinue = onContinue, @@ -114,6 +128,6 @@ private fun RecoveryPassword(state: State, onChange: (String) -> Unit = {}, onCo Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) Spacer(Modifier.weight(2f)) - ContinuePrimaryOutlineButton(modifier = Modifier.align(Alignment.CenterHorizontally), onContinue) + ContinueAccentOutlineButton(modifier = Modifier.align(Alignment.CenterHorizontally), onContinue) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt index 39b119e5b6..c698f3b062 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccountActivity.kt @@ -1,9 +1,14 @@ package org.thoughtcrime.securesms.onboarding.loadaccount import android.os.Bundle +import android.view.View import androidx.activity.viewModels import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -15,6 +20,7 @@ import org.thoughtcrime.securesms.onboarding.manager.LoadAccountManager import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsActivity import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.ui.setComposeContent +import org.thoughtcrime.securesms.util.applySafeInsetsPaddings import org.thoughtcrime.securesms.util.start @AndroidEntryPoint @@ -27,8 +33,18 @@ class LoadAccountActivity : BaseActionBarActivity() { private val viewModel: LoadAccountViewModel by viewModels() + override val applyDefaultWindowInsets: Boolean + get() = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + // only apply inset padding at the top, so the compose children can choose how to handle the bottom + findViewById(android.R.id.content).applySafeInsetsPaddings( + consumeInsets = false, + applyBottom = false, + ) + supportActionBar?.setTitle(R.string.loadAccount) prefs.setConfigurationMessageSynced(false) prefs.setRestorationTime(System.currentTimeMillis()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/Loading.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/Loading.kt index 001562c7ff..0a0ae1355f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/Loading.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/Loading.kt @@ -9,9 +9,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import network.loki.messenger.R -import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.ProgressArc -import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @Composable @@ -20,7 +20,7 @@ internal fun LoadingScreen(progress: Float) { Spacer(modifier = Modifier.weight(1f)) ProgressArc( progress, - modifier = Modifier.contentDescription(R.string.AccessibilityId_loadAccountProgressMessage) + modifier = Modifier.qaTag(R.string.AccessibilityId_loadAccountProgressMessage) ) Text( stringResource(R.string.waitOneMoment), diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt index d6f8c99256..9e39c0bfb8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt @@ -3,12 +3,11 @@ package org.thoughtcrime.securesms.onboarding.manager import android.app.Application import org.session.libsession.snode.SnodeModule import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.UsernameUtils import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.hexEncodedPublicKey import org.thoughtcrime.securesms.crypto.KeyPairUtilities -import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.VersionDataFetcher import javax.inject.Inject import javax.inject.Singleton @@ -17,7 +16,7 @@ import javax.inject.Singleton class CreateAccountManager @Inject constructor( private val application: Application, private val prefs: TextSecurePreferences, - private val configFactory: ConfigFactory, + private val usernameUtils: UsernameUtils, private val versionDataFetcher: VersionDataFetcher ) { private val database: LokiAPIDatabaseProtocol @@ -43,9 +42,7 @@ class CreateAccountManager @Inject constructor( prefs.setLocalNumber(userHexEncodedPublicKey) prefs.setRestorationTime(0) - configFactory.withMutableUserConfigs { - it.userProfile.setName(displayName) - } + usernameUtils.saveCurrentUserName(displayName) versionDataFetcher.startTimedVersionCheck() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt index ad56c93922..489742a9a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt @@ -53,8 +53,6 @@ class LoadAccountManager @Inject constructor( } versionDataFetcher.startTimedVersionCheck() - - ApplicationContext.getInstance(context).retrieveUserProfile() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt index 436cb890be..3c5bb0eb75 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt @@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -19,20 +19,21 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import com.squareup.phrase.Phrase +import network.loki.messenger.BuildConfig import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.thoughtcrime.securesms.onboarding.OnboardingBackPressAlertDialog import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsViewModel.UiState -import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton +import org.thoughtcrime.securesms.onboarding.ui.ContinueAccentOutlineButton +import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator +import org.thoughtcrime.securesms.ui.components.RadioButton +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors -import org.thoughtcrime.securesms.ui.theme.LocalColors -import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator -import org.thoughtcrime.securesms.ui.components.RadioButton -import org.thoughtcrime.securesms.ui.contentDescription -import org.thoughtcrime.securesms.ui.theme.LocalType @Composable internal fun MessageNotificationsScreen( @@ -47,7 +48,7 @@ internal fun MessageNotificationsScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator(color = LocalColors.current.primary) + CircularProgressIndicator(color = LocalColors.current.accent) } return @@ -76,8 +77,9 @@ internal fun MessageNotificationsScreen( NotificationRadioButton( R.string.notificationsFastMode, - R.string.notificationsFastModeDescription, - modifier = Modifier.contentDescription(R.string.AccessibilityId_notificationsFastMode), + if(BuildConfig.FLAVOR == "huawei") R.string.notificationsFastModeDescriptionHuawei + else R.string.notificationsFastModeDescription, + modifier = Modifier.qaTag(R.string.AccessibilityId_notificationsFastMode), tag = R.string.recommended, checked = state.pushEnabled, onClick = { setEnabled(true) } @@ -92,14 +94,14 @@ internal fun MessageNotificationsScreen( NotificationRadioButton( stringResource(R.string.notificationsSlowMode), explanationTxt, - modifier = Modifier.contentDescription(R.string.AccessibilityId_notificationsSlowMode), + modifier = Modifier.qaTag(R.string.AccessibilityId_notificationsSlowMode), checked = state.pushDisabled, onClick = { setEnabled(false) } ) Spacer(Modifier.weight(1f)) - ContinuePrimaryOutlineButton(Modifier.align(Alignment.CenterHorizontally), onContinue) + ContinueAccentOutlineButton(Modifier.align(Alignment.CenterHorizontally), onContinue) } } @@ -144,7 +146,7 @@ private fun NotificationRadioButton( .border( LocalDimensions.current.borderStroke, LocalColors.current.borders, - RoundedCornerShape(8.dp) + MaterialTheme.shapes.extraSmall ), ) { Column( @@ -164,7 +166,7 @@ private fun NotificationRadioButton( Text( stringResource(it), modifier = Modifier.padding(top = LocalDimensions.current.xxsSpacing), - color = LocalColors.current.primary, + color = LocalColors.current.accent, style = LocalType.current.h9 ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt index bdb66346e0..7fbb4ef68a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayName.kt @@ -16,13 +16,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import network.loki.messenger.R import org.thoughtcrime.securesms.onboarding.OnboardingBackPressAlertDialog -import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton -import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.onboarding.ui.ContinueAccentOutlineButton import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField -import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme @Preview @Composable @@ -68,7 +67,7 @@ internal fun PickDisplayName( SessionOutlinedTextField( text = state.displayName, - modifier = Modifier.fillMaxWidth().qaTag(stringResource(R.string.AccessibilityId_displayNameEnter)), + modifier = Modifier.fillMaxWidth().qaTag(R.string.AccessibilityId_displayNameEnter), placeholder = stringResource(R.string.displayNameEnter), onChange = onChange, onContinue = onContinue, @@ -80,6 +79,6 @@ internal fun PickDisplayName( Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) Spacer(Modifier.weight(2f)) - ContinuePrimaryOutlineButton(modifier = Modifier.align(Alignment.CenterHorizontally), onContinue) + ContinueAccentOutlineButton(modifier = Modifier.align(Alignment.CenterHorizontally), onContinue) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameViewModel.kt index 9bb14d41d3..dc67a0e735 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameViewModel.kt @@ -16,14 +16,12 @@ import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol.Companion.NAME_PADDED_LENGTH import org.session.libsession.utilities.TextSecurePreferences -import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsViewModel +import org.session.libsession.utilities.UsernameUtils internal class PickDisplayNameViewModel( private val loadFailed: Boolean, private val prefs: TextSecurePreferences, - private val configFactory: ConfigFactory + private val usernameUtils: UsernameUtils, ): ViewModel() { private val isCreateAccount = !loadFailed @@ -49,9 +47,7 @@ internal class PickDisplayNameViewModel( viewModelScope.launch(Dispatchers.IO) { if (loadFailed) { prefs.setProfileName(displayName) - configFactory.withMutableUserConfigs { - it.userProfile.setName(displayName) - } + usernameUtils.saveCurrentUserName(displayName) _events.emit(Event.LoadAccountComplete) } else _events.emit(Event.CreateAccount(displayName)) } @@ -88,11 +84,11 @@ internal class PickDisplayNameViewModel( class Factory @AssistedInject constructor( @Assisted private val loadFailed: Boolean, private val prefs: TextSecurePreferences, - private val configFactory: ConfigFactory + private val usernameUtils: UsernameUtils, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return PickDisplayNameViewModel(loadFailed, prefs, configFactory) as T + return PickDisplayNameViewModel(loadFailed, prefs, usernameUtils) as T } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/ui/ContinueButton.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/ui/ContinueButton.kt index 51a5d0f35b..349bc1093f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/ui/ContinueButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/ui/ContinueButton.kt @@ -6,16 +6,16 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.components.AccentOutlineButton +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton -import org.thoughtcrime.securesms.ui.contentDescription @Composable -fun ContinuePrimaryOutlineButton(modifier: Modifier, onContinue: () -> Unit) { - PrimaryOutlineButton( +fun ContinueAccentOutlineButton(modifier: Modifier, onContinue: () -> Unit) { + AccentOutlineButton( stringResource(R.string.theContinue), modifier = modifier - .contentDescription(R.string.AccessibilityId_theContinue) + .qaTag(R.string.AccessibilityId_theContinue) .fillMaxWidth() .padding(horizontal = LocalDimensions.current.xlargeSpacing) .padding(bottom = LocalDimensions.current.smallSpacing), 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 d01990fe05..0141baab2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt @@ -1,47 +1,20 @@ package org.thoughtcrime.securesms.preferences -import android.os.Bundle -import androidx.activity.viewModels -import androidx.core.view.isVisible +import androidx.compose.runtime.Composable +import androidx.hilt.navigation.compose.hiltViewModel 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 +import org.thoughtcrime.securesms.FullComposeScreenLockActivity @AndroidEntryPoint -class BlockedContactsActivity: PassphraseRequiredActionBarActivity() { +class BlockedContactsActivity: FullComposeScreenLockActivity() { - lateinit var binding: ActivityBlockedContactsBinding + @Composable + override fun ComposeContent() { + val viewModel: BlockedContactsViewModel = hiltViewModel() - val viewModel: BlockedContactsViewModel by viewModels() - - val adapter: BlockedContactsAdapter by lazy { BlockedContactsAdapter(viewModel) } - - private fun unblock() { - showSessionDialog { - title(viewModel.getTitle(this@BlockedContactsActivity)) - text(viewModel.getText(context, viewModel.state.selectedItems)) - dangerButton(R.string.blockUnblock, R.string.AccessibilityId_unblockConfirm) { viewModel.unblock() } - cancelButton() - } - } - - override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { - super.onCreate(savedInstanceState, ready) - binding = ActivityBlockedContactsBinding.inflate(layoutInflater) - setContentView(binding.root) - - binding.recyclerView.adapter = adapter - - viewModel.subscribe(this) - .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 { unblock() } + BlockedContactsScreen( + viewModel = viewModel, + onBack = { finish() }, + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt deleted file mode 100644 index e59d86c912..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt +++ /dev/null @@ -1,62 +0,0 @@ -package org.thoughtcrime.securesms.preferences - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -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.BlockedContactLayoutBinding -import org.session.libsession.utilities.recipients.Recipient -import com.bumptech.glide.Glide -import org.thoughtcrime.securesms.util.adapter.SelectableItem - -typealias SelectableRecipient = SelectableItem - -class BlockedContactsAdapter(val viewModel: BlockedContactsViewModel) : ListAdapter(RecipientDiffer()) { - - class RecipientDiffer: DiffUtil.ItemCallback() { - 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 - } - - 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) { - holder.bind(getItem(position), viewModel::toggle) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList) { - 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.recycle() - } - - class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { - - val glide = Glide.with(itemView) - val binding = BlockedContactLayoutBinding.bind(itemView) - - fun bind(selectable: SelectableRecipient, toggle: (SelectableRecipient) -> Unit) { - binding.recipientName.text = selectable.item.name - with (binding.profilePictureView) { - update(selectable.item) - } - binding.root.setOnClickListener { toggle(selectable) } - binding.selectButton.isSelected = selectable.isSelected - } - - fun select(isSelected: Boolean) { - binding.selectButton.isSelected = isSelected - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsPreference.kt deleted file mode 100644 index 48c7cc6dc8..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsPreference.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.thoughtcrime.securesms.preferences - -import android.content.Context -import android.content.Intent -import android.util.AttributeSet -import androidx.preference.PreferenceCategory -import androidx.preference.PreferenceViewHolder - -class BlockedContactsPreference @JvmOverloads constructor( - context: Context, - attributeSet: AttributeSet? = null -) : PreferenceCategory(context, attributeSet) { - - override fun onBindViewHolder(holder: PreferenceViewHolder) { - super.onBindViewHolder(holder) - - holder.itemView.setOnClickListener { - Intent(context, BlockedContactsActivity::class.java).let(context::startActivity) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsScreen.kt new file mode 100644 index 0000000000..306e9e7856 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsScreen.kt @@ -0,0 +1,235 @@ +package org.thoughtcrime.securesms.preferences + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import network.loki.messenger.R +import org.session.libsession.utilities.Address +import org.thoughtcrime.securesms.groups.ContactItem +import org.thoughtcrime.securesms.groups.compose.multiSelectMemberList +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox +import org.thoughtcrime.securesms.ui.DialogButtonData +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.SearchBar +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.OutlineButton +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.primaryBlue +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUIElement + + +@Composable +fun BlockedContactsScreen( + viewModel: BlockedContactsViewModel, + onBack: () -> Unit, +) { + BlockedContacts( + contacts = viewModel.contacts.collectAsState().value, + onContactItemClicked = viewModel::onContactItemClicked, + searchQuery = viewModel.searchQuery.collectAsState().value, + onSearchQueryChanged = viewModel::onSearchQueryChanged, + onSearchQueryClear = {viewModel.onSearchQueryChanged("") }, + onDoneClicked = viewModel::onUnblockClicked, + onBack = onBack, + ) + + // dialogs + val showConfirmDialog by viewModel.unblockDialog.collectAsState() + + if(showConfirmDialog) { + AlertDialog( + onDismissRequest = viewModel::hideUnblockDialog, + title = annotatedStringResource(R.string.blockUnblock), + text = annotatedStringResource(viewModel.getDialogText()), + buttons = listOf( + DialogButtonData( + GetString(R.string.blockUnblock), + color = LocalColors.current.danger, + onClick = viewModel::unblock + ), + DialogButtonData(GetString(android.R.string.cancel), dismissOnClick = true) + ) + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BlockedContacts( + contacts: List, + onContactItemClicked: (address: Address) -> Unit, + searchQuery: String, + onSearchQueryChanged: (String) -> Unit, + onSearchQueryClear: () -> Unit, + onDoneClicked: () -> Unit, + onBack: () -> Unit, +) { + Scaffold( + topBar = { + BackAppBar( + title = stringResource(id = R.string.conversationsBlockedContacts), + onBack = onBack, + ) + }, + ) { paddings -> + Column( + modifier = Modifier + .padding(paddings) + .consumeWindowInsets(paddings), + ) { + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + SearchBar( + query = searchQuery, + onValueChanged = onSearchQueryChanged, + onClear = onSearchQueryClear, + placeholder = stringResource(R.string.searchContacts), + modifier = Modifier + .padding(horizontal = LocalDimensions.current.smallSpacing) + .qaTag(R.string.AccessibilityId_groupNameSearch), + backgroundColor = LocalColors.current.backgroundSecondary, + ) + + val scrollState = rememberLazyListState() + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + BottomFadingEdgeBox(modifier = Modifier.weight(1f)) { bottomContentPadding -> + if(contacts.isEmpty() && searchQuery.isEmpty()){ + Text( + text = stringResource(id = R.string.blockBlockedNone), + modifier = Modifier.padding(top = LocalDimensions.current.spacing) + .align(Alignment.TopCenter), + style = LocalType.current.base.copy(color = LocalColors.current.textSecondary) + ) + } else { + LazyColumn( + state = scrollState, + contentPadding = PaddingValues(bottom = bottomContentPadding), + ) { + multiSelectMemberList( + contacts = contacts, + onContactItemClicked = onContactItemClicked, + ) + } + } + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth() + ) { + OutlineButton( + onClick = onDoneClicked, + color = LocalColors.current.danger, + enabled = contacts.any { it.selected }, + modifier = Modifier + .padding(vertical = LocalDimensions.current.spacing) + .qaTag(R.string.qa_unblock_button), + ) { + Text( + stringResource(id = R.string.blockUnblock) + ) + } + } + } + + } +} + +@Preview +@Composable +private fun PreviewSelectContacts() { + val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + val contacts = List(20) { + ContactItem( + address = Address.fromSerialized(random), + name = "User $it", + selected = it % 3 == 0, + showProBadge = true, + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), + ) + } + + PreviewTheme { + BlockedContacts( + contacts = contacts, + onContactItemClicked = {}, + searchQuery = "", + onSearchQueryChanged = {}, + onSearchQueryClear = {}, + onDoneClicked = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewSelectEmptyContacts() { + val contacts = emptyList() + + PreviewTheme { + BlockedContacts( + contacts = contacts, + onContactItemClicked = {}, + searchQuery = "", + onSearchQueryChanged = {}, + onSearchQueryClear = {}, + onDoneClicked = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewSelectEmptyContactsWithSearch() { + val contacts = emptyList() + + PreviewTheme { + BlockedContacts( + contacts = contacts, + onContactItemClicked = {}, + searchQuery = "Test", + onSearchQueryChanged = {}, + onSearchQueryClear = {}, + onDoneClicked = {}, + onBack = {}, + ) + } +} + 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 612cb69af7..f6074e798a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt @@ -1,120 +1,83 @@ package org.thoughtcrime.securesms.preferences import android.content.Context -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.cash.copper.flow.observeQuery import com.squareup.phrase.Phrase import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.Dispatchers.Main -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.plus -import kotlinx.coroutines.withContext import network.loki.messenger.R +import org.session.libsession.database.StorageProtocol import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY -import org.session.libsession.database.StorageProtocol import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.database.DatabaseContentProviders -import org.thoughtcrime.securesms.database.Storage -import org.thoughtcrime.securesms.util.adapter.SelectableItem +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.groups.SelectContactsViewModel +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.util.AvatarUtils import javax.inject.Inject @HiltViewModel -class BlockedContactsViewModel @Inject constructor(private val storage: StorageProtocol): ViewModel() { - - private val executor = viewModelScope + SupervisorJob() - - private val listUpdateChannel = Channel(capacity = Channel.CONFLATED) - - private val _state = MutableLiveData(BlockedContactsViewState()) +class BlockedContactsViewModel @Inject constructor( + configFactory: ConfigFactory, + @ApplicationContext private val context: Context, + private val storage: StorageProtocol, + avatarUtils: AvatarUtils, + proStatusManager: ProStatusManager, +): SelectContactsViewModel( + configFactory = configFactory, + excludingAccountIDs = emptySet(), + contactFiltering = { it.isBlocked }, + appContext = context, + avatarUtils = avatarUtils, + proStatusManager = proStatusManager +) { + private val _unblockDialog = MutableStateFlow(false) + val unblockDialog: StateFlow = _unblockDialog - val state get() = _state.value!! + fun unblock() { + viewModelScope.launch { + storage.setBlocked( + recipients = currentSelected.map { + Recipient.from(context, it, false) + }, + isBlocked = false + ) - fun subscribe(context: Context): LiveData { - executor.launch(IO) { - context.contentResolver - .observeQuery(DatabaseContentProviders.Recipient.CONTENT_URI) - .onStart { - listUpdateChannel.trySend(Unit) - } - .onEach { - listUpdateChannel.trySend(Unit) - } - .collect() - } - executor.launch(IO) { - for (update in listUpdateChannel) { - val blockedContactState = state.copy( - blockedContacts = storage.blockedContacts().sortedBy { it.name } - ) - withContext(Main) { - _state.value = blockedContactState - } - } + clearSelection() } - return _state } - fun unblock() { - storage.setBlocked(state.selectedItems, false) - _state.value = state.copy(selectedItems = emptySet()) + fun onUnblockClicked(){ + _unblockDialog.value = true } - fun select(selectedItem: Recipient, isSelected: Boolean) { - _state.value = state.run { - if (isSelected) copy(selectedItems = selectedItems + selectedItem) - else copy(selectedItems = selectedItems - selectedItem) - } + fun hideUnblockDialog(){ + _unblockDialog.value = false } - fun getTitle(context: Context): String = context.getString(R.string.blockUnblock) - // Method to get the appropriate text to display when unblocking 1, 2, or several contacts - fun getText(context: Context, contactsToUnblock: Set): CharSequence { - return when (contactsToUnblock.size) { + fun getDialogText(): CharSequence { + val contactsList = contacts.value + val firstSelected = contactsList.first { it.address == currentSelected.elementAt(0) } + + return when (currentSelected.size) { // Note: We do not have to handle 0 because if no contacts are chosen then the unblock button is deactivated 1 -> Phrase.from(context, R.string.blockUnblockName) - .put(NAME_KEY, contactsToUnblock.elementAt(0).name) + .put(NAME_KEY, firstSelected.name) .format() 2 -> Phrase.from(context, R.string.blockUnblockNameTwo) - .put(NAME_KEY, contactsToUnblock.elementAt(0).name) + .put(NAME_KEY, firstSelected.name) .format() else -> { - val othersCount = contactsToUnblock.size - 1 + val othersCount = currentSelected.size - 1 Phrase.from(context, R.string.blockUnblockNameMultiple) - .put(NAME_KEY, contactsToUnblock.elementAt(0).name) + .put(NAME_KEY, firstSelected.name) .put(COUNT_KEY, othersCount) .format() } } } - - fun getMessage(context: Context): String = context.getString(R.string.blockUnblock) - - fun toggle(selectable: SelectableItem) { - _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 = emptyList(), - val selectedItems: Set = emptySet() - ) { - val items = blockedContacts.map { SelectableItem(it, it in selectedItems) } - - 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/ChatSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatSettingsActivity.kt index f2217b66e8..883052a211 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatSettingsActivity.kt @@ -2,12 +2,15 @@ package org.thoughtcrime.securesms.preferences import android.os.Bundle import network.loki.messenger.R -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.ScreenLockActionBarActivity -class ChatSettingsActivity : PassphraseRequiredActionBarActivity() { +class ChatSettingsActivity : ScreenLockActionBarActivity() { - override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { - super.onCreate(savedInstanceState, isReady) + override val applyDefaultWindowInsets: Boolean + get() = false + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + super.onCreate(savedInstanceState, ready) setContentView(R.layout.activity_fragment_wrapper) supportActionBar!!.title = resources.getString(R.string.sessionConversations) val fragment = ChatsPreferenceFragment() diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java index 2c23188429..39b54e2385 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java @@ -1,9 +1,11 @@ package org.thoughtcrime.securesms.preferences; +import android.content.Intent; import android.os.Bundle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.preference.Preference; import org.thoughtcrime.securesms.permissions.Permissions; @@ -20,6 +22,14 @@ public void onCreate(Bundle paramBundle) { @Override public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { addPreferencesFromResource(R.xml.preferences_chats); + + Preference blockedContactsPreference = findPreference("blocked_contacts"); + if (blockedContactsPreference != null) { + blockedContactsPreference.setOnPreferenceClickListener(preference -> { + startActivity(new Intent(requireContext(), BlockedContactsActivity.class)); + return true; + }); + } } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt deleted file mode 100644 index 743b60109a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt +++ /dev/null @@ -1,175 +0,0 @@ -package org.thoughtcrime.securesms.preferences - -import android.app.Dialog -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.widget.Toast -import androidx.core.view.isVisible -import androidx.fragment.app.DialogFragment -import androidx.lifecycle.lifecycleScope -import dagger.hilt.android.AndroidEntryPoint -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.R -import network.loki.messenger.databinding.DialogClearAllDataBinding -import org.session.libsession.database.StorageProtocol -import org.session.libsession.database.userAuth -import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.utilities.await -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.createSessionDialog -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.ClearDataUtils -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities -import javax.inject.Inject - -@AndroidEntryPoint -class ClearAllDataDialog : DialogFragment() { - private val TAG = "ClearAllDataDialog" - - private lateinit var binding: DialogClearAllDataBinding - - @Inject - lateinit var storage: StorageProtocol - - @Inject - lateinit var clearDataUtils: ClearDataUtils - - private enum class Steps { - INFO_PROMPT, - NETWORK_PROMPT, - DELETING, - RETRY_LOCAL_DELETE_ONLY_PROMPT - } - - // Rather than passing a bool around we'll use an enum to clarify our intent - private enum class DeletionScope { - DeleteLocalDataOnly, - DeleteBothLocalAndNetworkData - } - - private var clearJob: Job? = null - - private var step = Steps.INFO_PROMPT - set(value) { - field = value - updateUI() - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { - view(createView()) - } - - private fun createView(): View { - binding = DialogClearAllDataBinding.inflate(LayoutInflater.from(requireContext())) - val device = radioOption("deviceOnly", R.string.clearDeviceOnly) - val network = radioOption("deviceAndNetwork", R.string.clearDeviceAndNetwork) - var selectedOption: RadioOption = device - val optionAdapter = RadioOptionAdapter { selectedOption = it } - binding.recyclerView.apply { - itemAnimator = null - adapter = optionAdapter - setHasFixedSize(true) - } - optionAdapter.submitList(listOf(device, network)) - - binding.cancelButton.setOnClickListener { - dismiss() - } - - binding.clearAllDataButton.setOnClickListener { - when (step) { - Steps.INFO_PROMPT -> if (selectedOption == network) { - step = Steps.NETWORK_PROMPT - } else { - clearAllData(DeletionScope.DeleteLocalDataOnly) - } - Steps.NETWORK_PROMPT -> clearAllData(DeletionScope.DeleteBothLocalAndNetworkData) - Steps.DELETING -> { /* do nothing intentionally */ } - Steps.RETRY_LOCAL_DELETE_ONLY_PROMPT -> clearAllData(DeletionScope.DeleteLocalDataOnly) - } - } - return binding.root - } - - private fun updateUI() { - dialog?.let { - val isLoading = step == Steps.DELETING - - when (step) { - Steps.INFO_PROMPT -> { - binding.dialogDescriptionText.setText(R.string.clearDataAllDescription) - } - Steps.NETWORK_PROMPT -> { - binding.dialogDescriptionText.text = getString(R.string.clearDeviceAndNetworkConfirm) - } - Steps.DELETING -> { /* do nothing intentionally */ } - Steps.RETRY_LOCAL_DELETE_ONLY_PROMPT -> { - binding.dialogDescriptionText.setText(R.string.clearDataErrorDescriptionGeneric) - binding.clearAllDataButton.text = getString(R.string.clearDevice) - } - } - - binding.recyclerView.isVisible = step == Steps.INFO_PROMPT - binding.cancelButton.isVisible = !isLoading - binding.clearAllDataButton.isVisible = !isLoading - binding.progressBar.isVisible = isLoading - - it.setCanceledOnTouchOutside(!isLoading) - isCancelable = !isLoading - } - } - - private suspend fun performDeleteLocalDataOnlyStep() { - val result = runCatching { - clearDataUtils.clearAllDataAndRestart() - } - - withContext(Main) { - if (result.isSuccess) { - dismissAllowingStateLoss() - } else { - Toast.makeText(ApplicationContext.getInstance(requireContext()), R.string.errorUnknown, Toast.LENGTH_LONG).show() - } - } - } - - private fun clearAllData(deletionScope: DeletionScope) { - step = Steps.DELETING - - clearJob = lifecycleScope.launch(Dispatchers.IO) { - when (deletionScope) { - DeletionScope.DeleteLocalDataOnly -> { - performDeleteLocalDataOnlyStep() - } - DeletionScope.DeleteBothLocalAndNetworkData -> { - val deletionResultMap: Map? = try { - val openGroups = DatabaseComponent.get(requireContext()).lokiThreadDatabase().getAllOpenGroups() - openGroups.map { it.value.server }.toSet().forEach { server -> - OpenGroupApi.deleteAllInboxMessages(server).await() - } - SnodeAPI.deleteAllMessages(checkNotNull(storage.userAuth)).await() - } catch (e: Exception) { - Log.e(TAG, "Failed to delete network messages - offering user option to delete local data only.", e) - null - } - - // If one or more deletions failed then inform the user and allow them to clear the device only if they wish.. - if (deletionResultMap == null || deletionResultMap.values.any { !it } || deletionResultMap.isEmpty()) { - withContext(Main) { step = Steps.RETRY_LOCAL_DELETE_ONLY_PROMPT } - } - else if (deletionResultMap.values.all { it }) { - // ..otherwise if the network data deletion was successful proceed to delete the local data as well. - performDeleteLocalDataOnlyStep() - } - } - } - } - } -} \ No newline at end of file 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 d626b9ce5f..931d5e43aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.preferences; - import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Typeface; @@ -9,11 +8,10 @@ import android.view.View; import android.view.ViewGroup; import android.widget.TextView; - import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import androidx.core.view.ViewCompat; -import androidx.fragment.app.DialogFragment; import androidx.preference.Preference; import androidx.preference.PreferenceCategory; import androidx.preference.PreferenceFragmentCompat; @@ -22,6 +20,7 @@ import androidx.preference.PreferenceViewHolder; import androidx.recyclerview.widget.RecyclerView; import org.thoughtcrime.securesms.conversation.v2.ViewUtil; +import org.thoughtcrime.securesms.util.ViewUtilitiesKt; import network.loki.messenger.R; public abstract class CorrectedPreferenceFragment extends PreferenceFragmentCompat { @@ -43,11 +42,15 @@ public void onCreate(Bundle icicle) { } @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + RecyclerView lv = getListView(); + if (lv != null) { + lv.setPadding(0, 0, 0, 0); + ViewUtilitiesKt.applyCommonWindowInsetsOnViews(lv); + } - View lv = getView().findViewById(android.R.id.list); - if (lv != null) lv.setPadding(0, 0, 0, 0); setDivider(null); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt index 2ae5604c06..87d0b46fd6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt @@ -10,15 +10,20 @@ import android.widget.TextView import android.widget.Toast import androidx.core.view.isInvisible import androidx.preference.Preference +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.ui.getSubbedCharSequence import org.thoughtcrime.securesms.ui.getSubbedString -class HelpSettingsActivity: PassphraseRequiredActionBarActivity() { +@AndroidEntryPoint +class HelpSettingsActivity: ScreenLockActionBarActivity() { + + override val applyDefaultWindowInsets: Boolean + get() = false override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { super.onCreate(savedInstanceState, ready) @@ -29,6 +34,7 @@ class HelpSettingsActivity: PassphraseRequiredActionBarActivity() { } } +@AndroidEntryPoint class HelpSettingsFragment: CorrectedPreferenceFragment() { companion object { 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 0e32cc4335..d38561e0a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationSettingsActivity.kt @@ -3,10 +3,13 @@ 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.ScreenLockActionBarActivity @AndroidEntryPoint -class NotificationSettingsActivity : PassphraseRequiredActionBarActivity() { +class NotificationSettingsActivity : ScreenLockActionBarActivity() { + + override val applyDefaultWindowInsets: Boolean + get() = false override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt index d8aed33e2c..61f3af1fd1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt @@ -12,6 +12,7 @@ import android.text.TextUtils import androidx.preference.ListPreference import androidx.preference.Preference import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.BuildConfig import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.ApplicationContext @@ -38,6 +39,11 @@ class NotificationsPreferenceFragment : CorrectedPreferenceFragment() { true } + fcmPreference.summary = when (BuildConfig.FLAVOR) { + "huawei" -> getString(R.string.notificationsFastModeDescriptionHuawei) + else -> getString(R.string.notificationsFastModeDescription) + } + prefs.setNotificationRingtone( NotificationChannels.getMessageRingtone(requireContext()).toString() ) @@ -160,7 +166,7 @@ class NotificationsPreferenceFragment : CorrectedPreferenceFragment() { // update notification object : AsyncTask() { override fun doInBackground(vararg params: Void?): Void? { - ApplicationContext.getInstance(activity).messageNotifier.updateNotification( + ApplicationContext.getInstance(requireContext()).messageNotifier.updateNotification( activity!! ) return null 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 6d499e9f20..329817901b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsActivity.kt @@ -3,18 +3,21 @@ package org.thoughtcrime.securesms.preferences import android.os.Bundle import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R -import org.session.libsession.utilities.TextSecurePreferences.Companion.CALL_NOTIFICATIONS_ENABLED -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.ScreenLockActionBarActivity @AndroidEntryPoint -class PrivacySettingsActivity : PassphraseRequiredActionBarActivity() { +class PrivacySettingsActivity : ScreenLockActionBarActivity() { companion object{ const val SCROLL_KEY = "privacy_scroll_key" + const val SCROLL_AND_TOGGLE_KEY = "privacy_scroll_and_toggle_key" } - override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { - super.onCreate(savedInstanceState, isReady) + override val applyDefaultWindowInsets: Boolean + get() = false + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + super.onCreate(savedInstanceState, ready) setContentView(R.layout.activity_fragment_wrapper) val fragment = PrivacySettingsPreferenceFragment() val transaction = supportFragmentManager.beginTransaction() @@ -23,6 +26,9 @@ class PrivacySettingsActivity : PassphraseRequiredActionBarActivity() { if(intent.hasExtra(SCROLL_KEY)) { fragment.scrollToKey(intent.getStringExtra(SCROLL_KEY)!!) + } else if(intent.hasExtra(SCROLL_AND_TOGGLE_KEY)) { + fragment.scrollAndAutoToggle(intent.getStringExtra(SCROLL_AND_TOGGLE_KEY)!!) + } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt index a153a6b30d..f0a75557ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt @@ -5,29 +5,39 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.provider.Settings +import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import androidx.preference.PreferenceCategory import androidx.preference.PreferenceDataStore import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import javax.inject.Inject 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.sskenvironment.TypingStatusRepository +import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.Companion.areNotificationsEnabled import org.thoughtcrime.securesms.util.IntentUtils @AndroidEntryPoint class PrivacySettingsPreferenceFragment : CorrectedPreferenceFragment() { - @Inject lateinit var configFactory: ConfigFactory + @Inject + lateinit var configFactory: ConfigFactory + + @Inject + lateinit var textSecurePreferences: TextSecurePreferences + + @Inject + lateinit var typingStatusRepository: TypingStatusRepository override fun onCreate(paramBundle: Bundle?) { super.onCreate(paramBundle) @@ -73,6 +83,25 @@ class PrivacySettingsPreferenceFragment : CorrectedPreferenceFragment() { scrollToPreference(key) } + fun scrollAndAutoToggle(key: String){ + lifecycleScope.launch { + scrollToKey(key) + delay(500) // slight delay to make the transition less jarring + // Find the preference based on the provided key. + val pref = findPreference(key) + // auto toggle for prefs that are switches + pref?.let { + // Check if it's a switch preference so we can toggle its checked state. + if (it is SwitchPreferenceCompat) { + // force set to true here, and call the onPreferenceChangeListener + // defined further up so that custom behaviours are still applied + // Invoke the onPreferenceChangeListener with the new value. + it.onPreferenceChangeListener?.onPreferenceChange(it, true) + } + } + } + } + private fun setCall(isEnabled: Boolean) { (findPreference(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED) as SwitchPreferenceCompat?)!!.isChecked = isEnabled @@ -117,8 +146,6 @@ class PrivacySettingsPreferenceFragment : CorrectedPreferenceFragment() { requireContext().getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager if (!keyguardManager.isKeyguardSecure) { findPreference(TextSecurePreferences.SCREEN_LOCK)!!.isChecked = false - - // TODO: Ticket SES-2182 raised to investigate & fix app lock / unlock functionality -ACL 2024/06/20 findPreference(TextSecurePreferences.SCREEN_LOCK)!!.isEnabled = false } } else { @@ -142,7 +169,7 @@ class PrivacySettingsPreferenceFragment : CorrectedPreferenceFragment() { override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean { val enabled = newValue as Boolean if (!enabled) { - ApplicationContext.getInstance(requireContext()).typingStatusRepository.clear() + typingStatusRepository.clear() } return true } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt index 045b08fcda..98d4ef0e0b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/QRCodeActivity.kt @@ -1,7 +1,7 @@ package org.thoughtcrime.securesms.preferences import android.os.Bundle -import androidx.compose.foundation.ExperimentalFoundationApi +import android.view.View import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -13,6 +13,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -22,28 +25,39 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.PublicKeyValidation -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.ScreenLockActionBarActivity import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.threadDatabase import org.thoughtcrime.securesms.permissions.Permissions -import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.components.QRScannerScreen import org.thoughtcrime.securesms.ui.components.QrImage import org.thoughtcrime.securesms.ui.components.SessionTabRow -import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.setComposeContent +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.util.applySafeInsetsPaddings import org.thoughtcrime.securesms.util.start private val TITLES = listOf(R.string.view, R.string.scan) -class QRCodeActivity : PassphraseRequiredActionBarActivity() { +class QRCodeActivity : ScreenLockActionBarActivity() { + + override val applyDefaultWindowInsets: Boolean + get() = false private val errors = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) + + // only apply inset padding at the top so that the bottom qr scanning can go all the way + findViewById(android.R.id.content).applySafeInsetsPaddings( + consumeInsets = false, + applyBottom = false, + ) + supportActionBar!!.title = resources.getString(R.string.qrCode) setComposeContent { @@ -76,7 +90,6 @@ class QRCodeActivity : PassphraseRequiredActionBarActivity() { } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun Tabs(accountId: String, errors: Flow, onScan: (String) -> Unit) { val pagerState = rememberPagerState { TITLES.size } @@ -107,7 +120,7 @@ fun QrPage(string: String) { string = string, modifier = Modifier .padding(top = LocalDimensions.current.mediumSpacing, bottom = LocalDimensions.current.xsSpacing) - .contentDescription(R.string.AccessibilityId_qrCode), + .qaTag(R.string.AccessibilityId_qrCode), icon = R.drawable.session ) 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 b3e1ee3fd2..8af4fc620a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -1,104 +1,31 @@ package org.thoughtcrime.securesms.preferences import android.Manifest -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.graphics.BitmapFactory +import android.content.ActivityNotFoundException import android.net.Uri -import android.os.Bundle -import android.os.Parcelable -import android.util.SparseArray -import android.view.ActionMode -import android.view.Menu -import android.view.MenuItem -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels -import androidx.compose.animation.Crossfade -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -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.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope +import androidx.core.content.ContextCompat import com.canhub.cropper.CropImageContract -import com.squareup.phrase.Phrase +import com.canhub.cropper.CropImageContractOptions +import com.canhub.cropper.CropImageOptions +import com.canhub.cropper.CropImageView import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch -import network.loki.messenger.BuildConfig import network.loki.messenger.R -import network.loki.messenger.databinding.ActivitySettingsBinding -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol -import org.session.libsession.utilities.StringSubstitutionConstants.VERSION_KEY import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.avatar.AvatarSelection -import org.thoughtcrime.securesms.debugmenu.DebugActivity -import org.thoughtcrime.securesms.home.PathActivity -import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity +import org.session.libsession.utilities.getColorFromAttr +import org.thoughtcrime.securesms.FullComposeScreenLockActivity import org.thoughtcrime.securesms.permissions.Permissions -import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.NoAvatar -import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.TempAvatar -import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.UserAvatar -import org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity -import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity -import org.thoughtcrime.securesms.ui.AlertDialog -import org.thoughtcrime.securesms.ui.Avatar -import org.thoughtcrime.securesms.ui.Cell -import org.thoughtcrime.securesms.ui.DialogButtonModel -import org.thoughtcrime.securesms.ui.Divider -import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.LargeItemButton -import org.thoughtcrime.securesms.ui.LargeItemButtonWithDrawable -import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton -import org.thoughtcrime.securesms.ui.components.PrimaryOutlineCopyButton -import org.thoughtcrime.securesms.ui.contentDescription -import org.thoughtcrime.securesms.ui.qaTag -import org.thoughtcrime.securesms.ui.setThemedContent -import org.thoughtcrime.securesms.ui.theme.LocalColors -import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.PreviewTheme -import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider -import org.thoughtcrime.securesms.ui.theme.ThemeColors -import org.thoughtcrime.securesms.ui.theme.dangerButtonColors -import org.thoughtcrime.securesms.util.push +import org.thoughtcrime.securesms.util.FileProviderUtil import java.io.File import javax.inject.Inject -@AndroidEntryPoint -class SettingsActivity : PassphraseRequiredActionBarActivity() { + @AndroidEntryPoint +class SettingsActivity : FullComposeScreenLockActivity() { private val TAG = "SettingsActivity" @Inject @@ -106,505 +33,115 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { private val viewModel: SettingsViewModel by viewModels() - private lateinit var binding: ActivitySettingsBinding - private var displayNameEditActionMode: ActionMode? = null - set(value) { field = value; handleDisplayNameEditActionModeChanged() } - private val onAvatarCropped = registerForActivityResult(CropImageContract()) { result -> viewModel.onAvatarPicked(result) } - private val onPickImage = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ){ result -> - if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult + private val pickPhotoLauncher: ActivityResultLauncher = + registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? -> + uri?.let { + viewModel.hideAvatarPickerOptions() // close the bottom sheet - val outputFile = Uri.fromFile(File(cacheDir, "cropped")) - val inputFile: Uri? = result.data?.data ?: viewModel.getTempFile()?.let(Uri::fromFile) - cropImage(inputFile, outputFile) - } + // Handle the selected image URI + if(viewModel.isAnimated(uri)){ // no cropping for animated images + viewModel.onAvatarPicked(uri) + } else { + val outputFile = Uri.fromFile(File(cacheDir, "cropped")) + cropImage(it, outputFile) + } - private val hideRecoveryLauncher = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult + } + } - if(result.data?.getBooleanExtra(RecoveryPasswordActivity.RESULT_RECOVERY_HIDDEN, false) == true){ - viewModel.permanentlyHidePassword() - } - } + // Launcher for capturing a photo using the camera. + private val takePhotoLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { success: Boolean -> + if (success) { + viewModel.hideAvatarPickerOptions() // close the bottom sheet - private val avatarSelection = AvatarSelection(this, onAvatarCropped, onPickImage) + val outputFile = Uri.fromFile(File(cacheDir, "cropped")) + cropImage(viewModel.getTempFile()?.let(Uri::fromFile), outputFile) + } else { + Toast.makeText(this, R.string.errorUnknown, Toast.LENGTH_SHORT).show() + } + } - private var showAvatarDialog: Boolean by mutableStateOf(false) + private val bgColor by lazy { getColorFromAttr(android.R.attr.colorPrimary) } + private val txtColor by lazy { getColorFromAttr(android.R.attr.textColorPrimary) } + private val imageScrim by lazy { ContextCompat.getColor(this, R.color.avatar_background) } + private val activityTitle by lazy { getString(R.string.image) } companion object { private const val SCROLL_STATE = "SCROLL_STATE" } - // region Lifecycle - override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { - super.onCreate(savedInstanceState, isReady) - binding = ActivitySettingsBinding.inflate(layoutInflater) - setContentView(binding.root) - - // set the toolbar icon to a close icon - supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_baseline_close_24) - - // set the compose dialog content - binding.avatarDialog.setThemedContent { - if(showAvatarDialog){ - AvatarDialogContainer( - saveAvatar = viewModel::saveAvatar, - removeAvatar = viewModel::removeAvatar, - startAvatarSelection = ::startAvatarSelection - ) - } - } - - binding.run { - profilePictureView.setOnClickListener { - binding.avatarDialog.isVisible = true - showAvatarDialog = true - } - ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } - btnGroupNameDisplay.text = viewModel.getDisplayName() - publicKeyTextView.text = viewModel.hexEncodedPublicKey - val gitCommitFirstSixChars = BuildConfig.GIT_HASH.take(6) - val environment: String = if(BuildConfig.BUILD_TYPE == "release") "" else " - ${prefs.getEnvironment().label}" - val versionDetails = " ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - $gitCommitFirstSixChars) $environment" - val versionString = Phrase.from(applicationContext, R.string.updateVersion).put(VERSION_KEY, versionDetails).format() - versionTextView.text = versionString - } - - binding.composeView.setThemedContent { - val recoveryHidden by viewModel.recoveryHidden.collectAsState() - Buttons(recoveryHidden = recoveryHidden) - } - - lifecycleScope.launch { - viewModel.showLoader.collect { - binding.loader.isVisible = it - } - } - - lifecycleScope.launch { - viewModel.refreshAvatar.collect { - binding.profilePictureView.recycle() - binding.profilePictureView.update() - } - } - - lifecycleScope.launch { - viewModel.avatarData.collect { - if(it == null) return@collect - - binding.profilePictureView.apply { - publicKey = it.publicKey - displayName = it.displayName - update(it.recipient) - } - } - } - } - - override fun onStart() { - super.onStart() - - binding.profilePictureView.update() - } + @Composable + override fun ComposeContent() { + SettingsScreen( + viewModel = viewModel, + onGalleryPicked = { + try { + pickPhotoLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } catch (e: ActivityNotFoundException) { + Toast.makeText(this, R.string.errorUnknown, Toast.LENGTH_SHORT).show() + } + }, + onCameraPicked = { + viewModel.createTempFile()?.let{ + takePhotoLauncher.launch(FileProviderUtil.getUriFor(this, it)) + } + }, + startAvatarSelection = this::startAvatarSelection, + onBack = this::finish + ) + } override fun finish() { super.finish() overridePendingTransition(R.anim.fade_scale_in, R.anim.slide_to_bottom) } - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - val scrollBundle = SparseArray() - binding.scrollView.saveHierarchyState(scrollBundle) - outState.putSparseParcelableArray(SCROLL_STATE, scrollBundle) - } - - override fun onRestoreInstanceState(savedInstanceState: Bundle) { - super.onRestoreInstanceState(savedInstanceState) - savedInstanceState.getSparseParcelableArray(SCROLL_STATE)?.let { scrollBundle -> - binding.scrollView.restoreHierarchyState(scrollBundle) - } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.settings_general, menu) - if (BuildConfig.DEBUG) { - menu.findItem(R.id.action_qr_code)?.contentDescription = resources.getString(R.string.AccessibilityId_qrView) - } - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - R.id.action_qr_code -> { - push() - true - } - else -> super.onOptionsItemSelected(item) - } - } - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) } - // endregion - - // region Updating - private fun handleDisplayNameEditActionModeChanged() { - val isEditingDisplayName = this.displayNameEditActionMode != null - - binding.btnGroupNameDisplay.isInvisible = isEditingDisplayName - binding.displayNameEditText.isInvisible = !isEditingDisplayName - - val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - if (isEditingDisplayName) { - binding.displayNameEditText.setText(binding.btnGroupNameDisplay.text) - 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 updateDisplayName(displayName: String): Boolean { - binding.loader.isVisible = true - - // We'll assume we fail & flip the flag on success - var updateWasSuccessful = false - - val haveNetworkConnection = viewModel.hasNetworkConnection() - if (!haveNetworkConnection) { - Log.w(TAG, "Cannot update display name - no network connection.") - } else { - // if we have a network connection then attempt to update the display name - TextSecurePreferences.setProfileName(this, displayName) - viewModel.updateName(displayName) - binding.btnGroupNameDisplay.text = displayName - updateWasSuccessful = true - } - - // Inform the user if we failed to update the display name - if (!updateWasSuccessful) { - Toast.makeText(this@SettingsActivity, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show() - } - - binding.loader.isVisible = false - return updateWasSuccessful - } - // endregion - - // region Interaction - - /** - * @return true if the update was successful. - */ - private fun saveDisplayName(): Boolean { - val displayName = binding.displayNameEditText.text.toString().trim() - - if (displayName.isEmpty()) { - Toast.makeText(this, R.string.displayNameErrorDescription, Toast.LENGTH_SHORT).show() - return false - } - - if (displayName.toByteArray().size > ProfileManagerProtocol.NAME_PADDED_LENGTH) { - Toast.makeText(this, R.string.displayNameErrorDescriptionShorter, Toast.LENGTH_SHORT).show() - return false - } - - return updateDisplayName(displayName) - } private fun startAvatarSelection() { // Ask for an optional camera permission. Permissions.with(this) .request(Manifest.permission.CAMERA) - .onAnyResult { - avatarSelection.startAvatarSelection( - includeClear = false, - attemptToIncludeCamera = true, - createTempFile = viewModel::createTempFile - ) + .onAnyDenied { + viewModel.showAvatarPickerOptions(showCamera = false) + } + .onAllGranted { + viewModel.showAvatarPickerOptions(showCamera = true) } .execute() } private fun cropImage(inputFile: Uri?, outputFile: Uri?){ - avatarSelection.circularCropImage( - inputFile = inputFile, - outputFile = outputFile, - ) - } - // endregion - - private inner class DisplayNameEditActionModeCallback: ActionMode.Callback { - - override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - mode.title = getString(R.string.displayNameEnter) - mode.menuInflater.inflate(R.menu.menu_apply, menu) - this@SettingsActivity.displayNameEditActionMode = mode - return true - } - - override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - return false - } - - override fun onDestroyActionMode(mode: ActionMode) { - this@SettingsActivity.displayNameEditActionMode = null - } - - override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - when (item.itemId) { - R.id.applyButton -> { - if (this@SettingsActivity.saveDisplayName()) { - mode.finish() - } - return true - } - } - return false - } - } - - @Composable - fun Buttons( - recoveryHidden: Boolean - ) { - Column( - modifier = Modifier - .padding(horizontal = LocalDimensions.current.spacing) - ) { - Row( - modifier = Modifier - .padding(top = LocalDimensions.current.xxsSpacing), - horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing), - ) { - PrimaryOutlineButton( - stringResource(R.string.share), - modifier = Modifier.weight(1f), - onClick = ::sendInvitationToUseSession - ) - - PrimaryOutlineCopyButton( - modifier = Modifier.weight(1f), - onClick = ::copyPublicKey, - ) - } - - Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - - val hasPaths by OnionRequestAPI.hasPath.collectAsState() - - Cell { - Column { - // add the debug menu in non release builds - if (BuildConfig.BUILD_TYPE != "release") { - LargeItemButton("Debug Menu", R.drawable.ic_settings) { push() } - Divider() - } - - Crossfade(if (hasPaths) R.drawable.ic_status else R.drawable.ic_path_yellow, label = "path") { - LargeItemButtonWithDrawable(R.string.onionRoutingPath, it) { push() } - } - Divider() - - LargeItemButton(R.string.sessionPrivacy, R.drawable.ic_privacy_icon) { push() } - Divider() - - LargeItemButton(R.string.sessionNotifications, R.drawable.ic_speaker, Modifier.contentDescription(R.string.AccessibilityId_notifications)) { push() } - Divider() - - LargeItemButton(R.string.sessionConversations, R.drawable.ic_conversations, Modifier.contentDescription(R.string.AccessibilityId_sessionConversations)) { push() } - Divider() - - LargeItemButton(R.string.sessionMessageRequests, R.drawable.ic_message_requests, Modifier.contentDescription(R.string.AccessibilityId_sessionMessageRequests)) { push() } - Divider() - - LargeItemButton(R.string.sessionAppearance, R.drawable.ic_appearance, Modifier.contentDescription(R.string.AccessibilityId_sessionAppearance)) { push() } - Divider() - - LargeItemButton( - R.string.sessionInviteAFriend, - R.drawable.ic_invite_friend, - Modifier.contentDescription(R.string.AccessibilityId_sessionInviteAFriend) - ) { sendInvitationToUseSession() } - Divider() - - // Only show the recovery password option if the user has not chosen to permanently hide it - if (!recoveryHidden) { - LargeItemButton( - R.string.sessionRecoveryPassword, - R.drawable.ic_shield_outline, - Modifier.contentDescription(R.string.AccessibilityId_sessionRecoveryPasswordMenuItem) - ) { - hideRecoveryLauncher.launch(Intent(baseContext, RecoveryPasswordActivity::class.java)) - overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) - } - Divider() - } - - LargeItemButton(R.string.sessionHelp, R.drawable.ic_help, Modifier.contentDescription(R.string.AccessibilityId_help)) { push() } - Divider() - - LargeItemButton(R.string.sessionClearData, - R.drawable.ic_delete, - Modifier.contentDescription(R.string.AccessibilityId_sessionClearData), - dangerButtonColors() - ) { ClearAllDataDialog().show(supportFragmentManager, "Clear All Data Dialog") } - } - } - } - } - - @Composable - fun AvatarDialogContainer( - startAvatarSelection: ()->Unit, - saveAvatar: ()->Unit, - removeAvatar: ()->Unit - ){ - val state by viewModel.avatarDialogState.collectAsState() - - AvatarDialog( - state = state, - startAvatarSelection = startAvatarSelection, - saveAvatar = saveAvatar, - removeAvatar = removeAvatar - ) - } - - @Composable - fun AvatarDialog( - state: SettingsViewModel.AvatarDialogState, - startAvatarSelection: ()->Unit, - saveAvatar: ()->Unit, - removeAvatar: ()->Unit - ){ - AlertDialog( - onDismissRequest = { - viewModel.onAvatarDialogDismissed() - showAvatarDialog = false - }, - title = stringResource(R.string.profileDisplayPictureSet), - content = { - // custom content that has the displayed images - - // main container that control the overall size and adds the rounded bg - Box( - modifier = Modifier - .padding(top = LocalDimensions.current.smallSpacing) - .size(dimensionResource(id = R.dimen.large_profile_picture_size)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null // the ripple doesn't look nice as a square with the plus icon on top too - ) { - startAvatarSelection() - } - .qaTag(stringResource(R.string.AccessibilityId_avatarPicker)) - .background( - shape = CircleShape, - color = LocalColors.current.backgroundBubbleReceived, - ), - contentAlignment = Alignment.Center - ) { - // the image content will depend on state type - when(val s = state){ - // user avatar - is UserAvatar -> { - Avatar(userAddress = s.address) - } - - // temporary image - is TempAvatar -> { - Image( - modifier = Modifier.size(dimensionResource(id = R.dimen.large_profile_picture_size)) - .clip(shape = CircleShape,), - bitmap = BitmapFactory.decodeByteArray(s.data, 0, s.data.size).asImageBitmap(), - contentDescription = null - ) - } - - // empty state - else -> { - Image( - modifier = Modifier.align(Alignment.Center), - painter = painterResource(id = R.drawable.ic_pictures), - contentDescription = null, - colorFilter = ColorFilter.tint(LocalColors.current.textSecondary) - ) - } - } - - // '+' button that sits atop the custom content - Image( - modifier = Modifier - .size(LocalDimensions.current.spacing) - .background( - shape = CircleShape, - color = LocalColors.current.primary - ) - .padding(LocalDimensions.current.xxxsSpacing) - .align(Alignment.BottomEnd) - , - painter = painterResource(id = R.drawable.ic_plus), - contentDescription = null, - colorFilter = ColorFilter.tint(Color.Black) - ) - } - }, - showCloseButton = true, // display the 'x' button - buttons = listOf( - DialogButtonModel( - text = GetString(R.string.save), - enabled = state is TempAvatar, - onClick = saveAvatar - ), - DialogButtonModel( - text = GetString(R.string.remove), - color = LocalColors.current.danger, - enabled = state is UserAvatar || // can remove is the user has an avatar set - (state is TempAvatar && state.hasAvatar), - onClick = removeAvatar + onAvatarCropped.launch( + CropImageContractOptions( + uri = inputFile, + cropImageOptions = CropImageOptions( + guidelines = CropImageView.Guidelines.ON, + aspectRatioX = 1, + aspectRatioY = 1, + fixAspectRatio = true, + cropShape = CropImageView.CropShape.OVAL, + customOutputUri = outputFile, + allowRotation = true, + allowFlipping = true, + backgroundColor = imageScrim, + toolbarColor = bgColor, + activityBackgroundColor = bgColor, + toolbarTintColor = txtColor, + toolbarBackButtonColor = txtColor, + toolbarTitleColor = txtColor, + activityMenuIconColor = txtColor, + activityMenuTextColor = txtColor, + activityTitle = activityTitle ) ) ) } - - @Preview - @Composable - fun PreviewAvatarDialog( - @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors - ){ - PreviewTheme(colors) { - AvatarDialog( - state = NoAvatar, - startAvatarSelection = {}, - saveAvatar = {}, - removeAvatar = {} - ) - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt new file mode 100644 index 0000000000..5ca34222b7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt @@ -0,0 +1,994 @@ +package org.thoughtcrime.securesms.preferences + +import android.annotation.SuppressLint +import androidx.activity.compose.LocalActivity +import androidx.annotation.DrawableRes +import androidx.compose.animation.Crossfade +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import network.loki.messenger.BuildConfig +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.NonTranslatableStringConstants.NETWORK_NAME +import org.thoughtcrime.securesms.debugmenu.DebugActivity +import org.thoughtcrime.securesms.home.PathActivity +import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity +import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.TempAvatar +import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.UserAvatar +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.ClearData +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.HideAnimatedProCTA +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.HideAvatarPickerOptions +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.HideClearDataDialog +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.HideUrlDialog +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.HideUsernameDialog +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.OnAvatarDialogDismissed +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.OnDonateClicked +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.RemoveAvatar +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.SaveAvatar +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.SetUsername +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.ShowAnimatedProCTA +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.ShowAvatarDialog +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.ShowClearDataDialog +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.ShowUrlDialog +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.ShowUsernameDialog +import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.UpdateUsername +import org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsActivity +import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity +import org.thoughtcrime.securesms.tokenpage.TokenPageActivity +import org.thoughtcrime.securesms.ui.AccountIdHeader +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.AnimatedProfilePicProCTA +import org.thoughtcrime.securesms.ui.AnimatedSessionProActivatedCTA +import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.DialogButtonData +import org.thoughtcrime.securesms.ui.Divider +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.LargeItemButton +import org.thoughtcrime.securesms.ui.LargeItemButtonWithDrawable +import org.thoughtcrime.securesms.ui.LoadingDialog +import org.thoughtcrime.securesms.ui.OpenURLAlertDialog +import org.thoughtcrime.securesms.ui.PathDot +import org.thoughtcrime.securesms.ui.ProBadgeText +import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.components.AcccentOutlineCopyButton +import org.thoughtcrime.securesms.ui.components.AccentOutlineButton +import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon +import org.thoughtcrime.securesms.ui.components.Avatar +import org.thoughtcrime.securesms.ui.components.BaseBottomSheet +import org.thoughtcrime.securesms.ui.components.BasicAppBar +import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton +import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField +import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.accentTextButtonColors +import org.thoughtcrime.securesms.ui.theme.dangerButtonColors +import org.thoughtcrime.securesms.ui.theme.monospace +import org.thoughtcrime.securesms.ui.theme.primaryBlue +import org.thoughtcrime.securesms.ui.theme.primaryGreen +import org.thoughtcrime.securesms.ui.theme.primaryYellow +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUIElement +import org.thoughtcrime.securesms.util.push + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun SettingsScreen( + viewModel: SettingsViewModel, + onGalleryPicked: () -> Unit, + onCameraPicked: () -> Unit, + startAvatarSelection: () -> Unit, + onBack: () -> Unit, +) { + val data by viewModel.uiState.collectAsState() + + Settings( + uiState = data, + sendCommand = viewModel::onCommand, + onGalleryPicked = onGalleryPicked, + onCameraPicked = onCameraPicked, + startAvatarSelection = startAvatarSelection, + onBack = onBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@Composable +fun Settings( + uiState: SettingsViewModel.UIState, + onGalleryPicked: () -> Unit, + onCameraPicked: () -> Unit, + startAvatarSelection: () -> Unit, + sendCommand: (SettingsViewModel.Commands) -> Unit, + onBack: () -> Unit, +) { + Scaffold( + topBar = { + BasicAppBar( + title = stringResource(R.string.sessionSettings), + backgroundColor = Color.Transparent, + navigationIcon = { + AppBarCloseIcon(onClose = onBack) + }, + actions = { + val activity = LocalActivity.current + + IconButton(onClick = { + sendCommand(ShowUsernameDialog) + }) { + Icon( + painter = painterResource(id = R.drawable.ic_pencil), + contentDescription = stringResource(id = R.string.edit) + ) + } + + IconButton(onClick = { + activity?.push() + }) { + Icon( + painter = painterResource(id = R.drawable.ic_qr_code), + contentDescription = stringResource(id = R.string.qrCode) + ) + } + } + ) + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + ) { paddings -> + // MAIN SCREEN CONTENT + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddings) + .consumeWindowInsets(paddings) + .padding( + horizontal = LocalDimensions.current.spacing, + ) + .verticalScroll(rememberScrollState()), + horizontalAlignment = CenterHorizontally + ) { + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // avatar + val avatarData = uiState.avatarData + if(avatarData != null) { + Box( + modifier = Modifier + .size(LocalDimensions.current.iconXXLarge) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null // the ripple doesn't look nice as a square with the plus icon on top too + ) { + sendCommand(ShowAvatarDialog) + } + .qaTag(R.string.AccessibilityId_profilePicture), + contentAlignment = Alignment.Center + ) { + Avatar( + size = LocalDimensions.current.iconXXLarge, + data = avatarData + ) + + // '+' button that sits atop the custom content + Image( + modifier = Modifier + .size(LocalDimensions.current.spacing) + .background( + shape = CircleShape, + color = LocalColors.current.accent + ) + .padding(LocalDimensions.current.xxxsSpacing) + .align(Alignment.BottomEnd) + , + painter = painterResource(id = if(avatarData.elements.first().contactPhoto == null) R.drawable.ic_plus + else R.drawable.ic_pencil), + contentDescription = null, + colorFilter = ColorFilter.tint(Color.Black) + ) + } + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + + // name + ProBadgeText( + modifier = Modifier.qaTag(R.string.AccessibilityId_displayName) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + sendCommand(ShowUsernameDialog) + }, + text = uiState.username, + showBadge = uiState.showProBadge, + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // Account ID + AccountIdHeader( + text = stringResource(R.string.accountIdYours) + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + Text( + modifier = Modifier.qaTag(R.string.AccessibilityId_shareAccountId), + text = uiState.accountID, + textAlign = TextAlign.Center, + style = LocalType.current.xl.monospace(), + color = LocalColors.current.text + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // Buttons + Buttons( + recoveryHidden = uiState.recoveryHidden, + hasPaths = uiState.hasPath, + postPro = uiState.isPostPro, + sendCommand = sendCommand + ) + + // Footer + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + + Image( + modifier = Modifier.qaTag(R.string.sessionNetworkLearnAboutStaking) + .height(24.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null // the ripple doesn't look nice as a square with the plus icon on top too + ) { + sendCommand(ShowUrlDialog("https://token.getsession.org")) + }, + painter = painterResource(id = R.drawable.ses_logo), + colorFilter = ColorFilter.tint(LocalColors.current.textSecondary), + contentDescription = stringResource(R.string.sessionNetworkLearnAboutStaking), + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + Text( + text = annotatedStringResource(uiState.version), + style = LocalType.current.small.copy(color = LocalColors.current.textSecondary), + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) + } + + // DIALOGS AND SHEETS + + // loading + if(uiState.showLoader) { + LoadingDialog() + } + + // dialog for the avatar + if(uiState.showAvatarDialog) { + AvatarDialog( + state = uiState.avatarDialogState, + isPro = uiState.isPro, + isPostPro = uiState.isPostPro, + sendCommand = sendCommand, + startAvatarSelection = startAvatarSelection + ) + } + + // Animated avatar CTA + if(uiState.showAnimatedProCTA){ + AnimatedProCTA( + isPro = uiState.isPro, + sendCommand = sendCommand + ) + } + + // donate confirmation + if(uiState.showUrlDialog != null){ + OpenURLAlertDialog( + url = uiState.showUrlDialog, + onDismissRequest = { sendCommand(HideUrlDialog) } + ) + } + + // bottom sheets with options for avatar: Gallery or photo + if(uiState.showAvatarPickerOptions) { + AvatarBottomSheet( + showCamera = uiState.showAvatarPickerOptionCamera, + onDismissRequest = { sendCommand(HideAvatarPickerOptions) }, + onGalleryPicked = onGalleryPicked, + onCameraPicked = onCameraPicked + ) + } + + // username + if(uiState.usernameDialog != null){ + + val focusRequester = remember { FocusRequester() } + LaunchedEffect (Unit) { + focusRequester.requestFocus() + } + + AlertDialog( + onDismissRequest = { + // hide dialog + sendCommand(HideUsernameDialog) + }, + title = stringResource(R.string.displayNameSet), + text = stringResource(R.string.displayNameVisible), + showCloseButton = true, + content = { + SessionOutlinedTextField( + text = uiState.usernameDialog.inputName ?: "", + modifier = Modifier + .focusRequester(focusRequester) + .padding(top = LocalDimensions.current.smallSpacing), + placeholder = stringResource(R.string.displayNameEnter), + innerPadding = PaddingValues(LocalDimensions.current.smallSpacing), + onChange = { updatedText -> + sendCommand(UpdateUsername(updatedText)) + }, + showClear = true, + singleLine = true, + onContinue = { sendCommand(SetUsername) }, + error = uiState.usernameDialog.error, + ) + }, + buttons = listOf( + DialogButtonData( + text = GetString(stringResource(id = R.string.save)), + enabled = uiState.usernameDialog.setEnabled, + onClick = { sendCommand(SetUsername) }, + qaTag = stringResource(R.string.qa_settings_dialog_username_save), + ), + DialogButtonData( + text = GetString(stringResource(R.string.cancel)), + qaTag = stringResource(R.string.qa_settings_dialog_username_cancel), + ) + ) + ) + } + + // clear data + if(uiState.clearDataDialog != SettingsViewModel.ClearDataState.Hidden) { + ShowClearDataDialog( + state = uiState.clearDataDialog, + sendCommand = sendCommand + ) + } + + } +} + +@Composable +fun Buttons( + recoveryHidden: Boolean, + hasPaths: Boolean, + postPro: Boolean, + sendCommand: (SettingsViewModel.Commands) -> Unit, +) { + Column( + modifier = Modifier + ) { + val context = LocalContext.current + val activity = LocalActivity.current + + Row( + modifier = Modifier + .padding(top = LocalDimensions.current.xxsSpacing), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing), + ) { + AccentOutlineButton( + stringResource(R.string.share), + modifier = Modifier.weight(1f), + onClick = context::sendInvitationToUseSession + ) + + AcccentOutlineCopyButton( + modifier = Modifier.weight(1f), + onClick = context::copyPublicKey, + ) + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + + // Add the debug menu in non release builds + if (BuildConfig.BUILD_TYPE != "release") { + Cell{ + LargeItemButton( + "Debug Menu", + R.drawable.ic_settings, + ) { activity?.push() } + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + } + + Cell { + Column { + if(postPro){ + LargeItemButtonWithDrawable( + text = GetString(NonTranslatableStringConstants.APP_PRO), + icon = R.drawable.ic_pro_badge, + iconSize = LocalDimensions.current.iconLargeAvatar, + modifier = Modifier.qaTag(R.string.qa_settings_item_pro), + colors = accentTextButtonColors() + ) { + activity?.push() + } + Divider() + } + + + // Invite a friend + LargeItemButton( + textId = R.string.sessionInviteAFriend, + icon = R.drawable.ic_user_round_plus, + modifier = Modifier.qaTag(R.string.AccessibilityId_sessionInviteAFriend) + ) { context.sendInvitationToUseSession() } + } + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + Cell { + Column { + // Donate + LargeItemButtonWithDrawable( + text = GetString(R.string.donate), + icon = R.drawable.ic_heart, + iconTint = LocalColors.current.accent, + modifier = Modifier.qaTag(R.string.qa_settings_item_donate), + ) { + sendCommand(OnDonateClicked) + } + Divider() + + Crossfade(if (hasPaths) primaryGreen else primaryYellow, label = "path") { + LargeItemButton( + modifier = Modifier.qaTag(R.string.qa_settings_item_path), + annotatedStringText = AnnotatedString(stringResource(R.string.onionRoutingPath)), + icon = { + PathDot( + modifier = Modifier.align(Alignment.Center), + dotSize = LocalDimensions.current.iconSmall, + color = it + ) + }, + ) { activity?.push() } + } + Divider() + + // Add the token page option. + LargeItemButton( + modifier = Modifier.qaTag(R.string.qa_settings_item_session_network), + text = NETWORK_NAME, + icon = R.drawable.session_network_logo + ) { activity?.push() } + } + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + Cell { + Column { + LargeItemButton(R.string.sessionPrivacy, R.drawable.ic_lock_keyhole, Modifier.qaTag(R.string.AccessibilityId_sessionPrivacy)) { activity?.push() } + Divider() + + LargeItemButton(R.string.sessionNotifications, R.drawable.ic_volume_2, Modifier.qaTag(R.string.AccessibilityId_notifications)) { activity?.push() } + Divider() + + LargeItemButton(R.string.sessionConversations, R.drawable.ic_users_round, Modifier.qaTag(R.string.AccessibilityId_sessionConversations)) { activity?.push() } + Divider() + + LargeItemButton(R.string.sessionAppearance, R.drawable.ic_paintbrush_vertical, Modifier.qaTag(R.string.AccessibilityId_sessionAppearance)) { activity?.push() } + Divider() + + LargeItemButton(R.string.sessionMessageRequests, R.drawable.ic_message_square_warning, Modifier.qaTag(R.string.AccessibilityId_sessionMessageRequests)) { activity?.push() } + } + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + Cell { + Column { + // Only show the recovery password option if the user has not chosen to permanently hide it + if (!recoveryHidden) { + LargeItemButton( + R.string.sessionRecoveryPassword, + R.drawable.ic_recovery_password_custom, + Modifier.qaTag(R.string.AccessibilityId_sessionRecoveryPasswordMenuItem) + ) { activity?.push() } + Divider() + } + + LargeItemButton(R.string.sessionHelp, R.drawable.ic_question_custom, Modifier.qaTag(R.string.AccessibilityId_help)) { activity?.push() } + Divider() + + LargeItemButton( + textId = R.string.sessionClearData, + icon = R.drawable.ic_trash_2, + modifier = Modifier.qaTag(R.string.AccessibilityId_sessionClearData), + colors = dangerButtonColors(), + ) { + sendCommand(ShowClearDataDialog) + } + } + } + } +} + +@Composable +fun ShowClearDataDialog( + state: SettingsViewModel.ClearDataState, + modifier: Modifier = Modifier, + sendCommand: (SettingsViewModel.Commands) -> Unit +) { + var deleteOnNetwork by remember { mutableStateOf(false)} + + AlertDialog( + modifier = modifier, + onDismissRequest = { + // hide dialog + sendCommand(HideClearDataDialog) + }, + title = stringResource(R.string.clearDataAll), + text = when(state){ + is SettingsViewModel.ClearDataState.Error -> stringResource(R.string.clearDataErrorDescriptionGeneric) + is SettingsViewModel.ClearDataState.ConfirmNetwork -> stringResource(R.string.clearDeviceAndNetworkConfirm) + else -> stringResource(R.string.clearDataAllDescription) + }, + content = { + when(state) { + is SettingsViewModel.ClearDataState.Clearing -> { + SmallCircularProgressIndicator( + modifier = Modifier.padding(top = LocalDimensions.current.xsSpacing) + ) + } + + is SettingsViewModel.ClearDataState.Default -> { + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(stringResource(R.string.clearDeviceOnly)), + selected = !deleteOnNetwork + ) + ) { + deleteOnNetwork = false + } + + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(stringResource(R.string.clearDeviceAndNetwork)), + selected = deleteOnNetwork, + ) + ) { + deleteOnNetwork = true + } + } + + else -> {} + } + }, + buttons = when(state){ + is SettingsViewModel.ClearDataState.Default, + is SettingsViewModel.ClearDataState.ConfirmNetwork -> { + listOf( + DialogButtonData( + text = GetString(stringResource(id = R.string.clear)), + color = LocalColors.current.danger, + dismissOnClick = false, + onClick = { + // clear data based on chosen option + sendCommand(ClearData(deleteOnNetwork)) + } + ), + DialogButtonData( + GetString(stringResource(R.string.cancel)) + ) + ) + } + + is SettingsViewModel.ClearDataState.Error -> { + listOf( + DialogButtonData( + text = GetString(stringResource(id = R.string.clearDevice)), + color = LocalColors.current.danger, + dismissOnClick = false, + onClick = { + // clear data based on chosen option + sendCommand(ClearData(deleteOnNetwork)) + } + ), + DialogButtonData( + GetString(stringResource(R.string.cancel)) + ) + ) + } + + else -> { emptyList() } + } + ) +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AvatarBottomSheet( + showCamera: Boolean, + onDismissRequest: () -> Unit, + onGalleryPicked: () -> Unit, + onCameraPicked: () -> Unit +){ + BaseBottomSheet( + sheetState = rememberModalBottomSheetState(), + onDismissRequest = onDismissRequest + ){ + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = LocalDimensions.current.spacing) + .padding(bottom = LocalDimensions.current.spacing), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.spacing) + ) { + AvatarOption( + modifier = Modifier.qaTag(R.string.AccessibilityId_imageButton), + title = stringResource(R.string.image), + iconRes = R.drawable.ic_image, + onClick = onGalleryPicked + ) + + if(showCamera) { + AvatarOption( + modifier = Modifier.qaTag(R.string.AccessibilityId_cameraButton), + title = stringResource(R.string.contentDescriptionCamera), + iconRes = R.drawable.ic_camera, + onClick = onCameraPicked + ) + } + } + } +} + +@Composable +fun AvatarOption( + modifier: Modifier = Modifier, + title: String, + @DrawableRes iconRes: Int, + onClick: () -> Unit +){ + Column( + modifier = modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false), + onClick = onClick + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + modifier = Modifier + .size(LocalDimensions.current.iconXLarge) + .background( + shape = CircleShape, + color = LocalColors.current.backgroundBubbleReceived, + ) + .padding(LocalDimensions.current.smallSpacing), + painter = painterResource(id = iconRes), + contentScale = ContentScale.Fit, + contentDescription = null, + colorFilter = ColorFilter.tint(LocalColors.current.textSecondary) + ) + + Text( + modifier = Modifier.padding(top = LocalDimensions.current.xxsSpacing), + text = title, + style = LocalType.current.base, + color = LocalColors.current.text + ) + } +} + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +fun AvatarDialog( + state: SettingsViewModel.AvatarDialogState, + isPro: Boolean, + isPostPro: Boolean, + sendCommand: (SettingsViewModel.Commands) -> Unit, + startAvatarSelection: () -> Unit, +){ + AlertDialog( + onDismissRequest = { + sendCommand(OnAvatarDialogDismissed) + }, + title = stringResource(R.string.profileDisplayPictureSet), + content = { + // custom content that has the displayed images + + // animated Pro title + if(isPostPro){ + ProBadgeText( + modifier = Modifier + .padding( + top = LocalDimensions.current.xxxsSpacing, + bottom = LocalDimensions.current.xsSpacing, + ) + .clickable { + sendCommand(ShowAnimatedProCTA) + }, + text = stringResource(if(isPro) R.string.proAnimatedDisplayPictureModalDescription + else R.string.proAnimatedDisplayPicturesNonProModalDescription), + textStyle = LocalType.current.base.copy(color = LocalColors.current.textSecondary), + badgeAtStart = isPro + ) + } + + // main container that control the overall size and adds the rounded bg + Box( + modifier = Modifier + .padding(vertical = LocalDimensions.current.smallSpacing) + .size(LocalDimensions.current.iconXXLarge) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null // the ripple doesn't look nice as a square with the plus icon on top too + ) { + startAvatarSelection() + } + .qaTag(R.string.AccessibilityId_avatarPicker) + .background( + shape = CircleShape, + color = LocalColors.current.backgroundBubbleReceived, + ), + contentAlignment = Alignment.Center + ) { + // the image content will depend on state type + when(val s = state){ + // user avatar + is UserAvatar -> { + Avatar( + size = LocalDimensions.current.iconXXLarge, + data = s.data + ) + } + + // temporary image + is TempAvatar -> { + GlideImage( + modifier = Modifier + .size(LocalDimensions.current.iconXXLarge) + .clip(shape = CircleShape,), + contentScale = ContentScale.Crop, + model = s.data, + contentDescription = stringResource(R.string.profileDisplayPicture) + ) + } + + // empty state + else -> { + Image( + modifier = Modifier + .fillMaxSize() + .padding(LocalDimensions.current.iconSmall) + .align(Alignment.Center), + painter = painterResource(id = R.drawable.ic_image), + contentScale = ContentScale.Fit, + contentDescription = null, + colorFilter = ColorFilter.tint(LocalColors.current.textSecondary) + ) + } + } + + // '+' button that sits atop the custom content + Image( + modifier = Modifier + .size(LocalDimensions.current.spacing) + .background( + shape = CircleShape, + color = LocalColors.current.accent + ) + .padding(LocalDimensions.current.xxxsSpacing) + .align(Alignment.BottomEnd) + , + painter = painterResource(id = + if(state is SettingsViewModel.AvatarDialogState.NoAvatar) R.drawable.ic_plus + else R.drawable.ic_pencil), + contentDescription = null, + colorFilter = ColorFilter.tint(Color.Black) + ) + } + }, + showCloseButton = true, // display the 'x' button + buttons = listOf( + DialogButtonData( + text = GetString(R.string.save), + enabled = state is TempAvatar, + dismissOnClick = false, + onClick = { sendCommand(SaveAvatar) } + ), + DialogButtonData( + text = GetString(if(state is TempAvatar) R.string.clear else R.string.remove), + color = LocalColors.current.danger, + enabled = state is UserAvatar || // can remove is the user has an avatar set + state is TempAvatar, // can clear a temp avatar + dismissOnClick = false, + onClick = { sendCommand(RemoveAvatar) } + ) + ) + ) +} + +@Composable +fun AnimatedProCTA( + isPro: Boolean, + sendCommand: (SettingsViewModel.Commands) -> Unit, +){ + if(isPro) { + AnimatedSessionProActivatedCTA ( + heroImageBg = R.drawable.cta_hero_animated_bg, + heroImageAnimatedFg = R.drawable.cta_hero_animated_fg, + title = stringResource(R.string.proActivated), + textContent = { + ProBadgeText( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(R.string.proAlreadyPurchased), + textStyle = LocalType.current.base.copy(color = LocalColors.current.textSecondary) + ) + + Spacer(Modifier.height(2.dp)) + + // main message + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(R.string.proAnimatedDisplayPicture), + textAlign = TextAlign.Center, + style = LocalType.current.base.copy( + color = LocalColors.current.textSecondary + ) + ) + }, + onCancel = { sendCommand(HideAnimatedProCTA) } + ) + } else { + AnimatedProfilePicProCTA( + onDismissRequest = { sendCommand(HideAnimatedProCTA) }, + ) + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@SuppressLint("UnusedContentLambdaTargetStateParameter") +@Preview +@Composable +private fun SettingsScreenPreview() { + PreviewTheme { + Settings ( + uiState = SettingsViewModel.UIState( + showLoader = false, + avatarDialogState = SettingsViewModel.AvatarDialogState.NoAvatar, + recoveryHidden = false, + showUrlDialog = null, + showAvatarDialog = false, + showAvatarPickerOptionCamera = false, + showAvatarPickerOptions = false, + showAnimatedProCTA = false, + avatarData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TO", + color = primaryBlue + ) + ) + ), + isPro = false, + isPostPro = true, + showProBadge = true, + username = "Atreyu", + accountID = "053d30141d0d35d9c4b30a8f8880f8464e221ee71a8aff9f0dcefb1e60145cea5144", + hasPath = true, + version = "1.26.0", + ), + sendCommand = {}, + onGalleryPicked = {}, + onCameraPicked = {}, + startAvatarSelection = {}, + onBack = {}, + + ) + } +} + +@Preview +@Composable +fun PreviewAvatarDialog( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +){ + PreviewTheme(colors) { + AvatarDialog( + state = SettingsViewModel.AvatarDialogState.NoAvatar, + isPro = false, + isPostPro = false, + sendCommand = {}, + startAvatarSelection = {} + ) + } +} + +@Preview +@Composable +fun PreviewAvatarSheet( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +){ + PreviewTheme(colors) { + AvatarBottomSheet( + showCamera = true, + onDismissRequest = {}, + onGalleryPicked = {}, + onCameraPicked = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt index 676905313e..758c0233eb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -1,41 +1,56 @@ package org.thoughtcrime.securesms.preferences import android.content.Context +import android.net.Uri import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.canhub.cropper.CropImage import com.canhub.cropper.CropImageView +import com.squareup.phrase.Phrase import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.avatars.AvatarHelper +import org.session.libsession.database.StorageProtocol +import org.session.libsession.database.userAuth import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.ProfileKeyUtil import org.session.libsession.utilities.ProfilePictureUtilities +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.StringSubstitutionConstants.VERSION_KEY import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.UsernameUtils import org.session.libsession.utilities.recipients.Recipient -import org.session.libsession.utilities.truncateIdForDisplay import org.session.libsignal.utilities.ExternalStorageUtil.getImageDir import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.NoExternalStorageException import org.session.libsignal.utilities.Util.SECURE_RANDOM +import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.textSizeInBytes import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.TempAvatar +import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints +import org.thoughtcrime.securesms.reviews.InAppReviewManager +import org.thoughtcrime.securesms.util.AnimatedImageUtils +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUtils import org.thoughtcrime.securesms.util.BitmapDecodingException import org.thoughtcrime.securesms.util.BitmapUtil +import org.thoughtcrime.securesms.util.ClearDataUtils import org.thoughtcrime.securesms.util.NetworkConnectivity import java.io.File import java.io.IOException @@ -47,6 +62,12 @@ class SettingsViewModel @Inject constructor( private val prefs: TextSecurePreferences, private val configFactory: ConfigFactory, private val connectivity: NetworkConnectivity, + private val usernameUtils: UsernameUtils, + private val avatarUtils: AvatarUtils, + private val proStatusManager: ProStatusManager, + private val clearDataUtils: ClearDataUtils, + private val storage: StorageProtocol, + private val inAppReviewManager: InAppReviewManager ) : ViewModel() { private val TAG = "SettingsViewModel" @@ -54,48 +75,68 @@ class SettingsViewModel @Inject constructor( val hexEncodedPublicKey: String = prefs.getLocalNumber() ?: "" - private val userAddress = Address.fromSerialized(hexEncodedPublicKey) + private val userRecipient by lazy { + Recipient.from(context, Address.fromSerialized(hexEncodedPublicKey), false) + } - private val _avatarDialogState: MutableStateFlow = MutableStateFlow( - getDefaultAvatarDialogState() - ) - val avatarDialogState: StateFlow - get() = _avatarDialogState + private val _uiState = MutableStateFlow(UIState( + username = usernameUtils.getCurrentUsernameWithAccountIdFallback(), + accountID = hexEncodedPublicKey, + hasPath = true, + version = getVersionNumber(), + recoveryHidden = prefs.getHidePassword(), + isPro = proStatusManager.isCurrentUserPro(), + isPostPro = proStatusManager.isPostPro(), + showProBadge = proStatusManager.shouldShowProBadge(userRecipient.address), + )) + val uiState: StateFlow + get() = _uiState - private val _showLoader: MutableStateFlow = MutableStateFlow(false) - val showLoader: StateFlow - get() = _showLoader + init { + updateAvatar() - private val _recoveryHidden: MutableStateFlow = MutableStateFlow(prefs.getHidePassword()) - val recoveryHidden: StateFlow - get() = _recoveryHidden + // set default dialog ui + viewModelScope.launch { + _uiState.update { it.copy(avatarDialogState = getDefaultAvatarDialogState()) } + } - private val _avatarData: MutableStateFlow = MutableStateFlow(null) - val avatarData: StateFlow - get() = _avatarData + viewModelScope.launch { + proStatusManager.proStatus.collect { isPro -> + _uiState.update { it.copy(isPro = isPro) } + } + } - /** - * Refreshes the avatar on the main settings page - */ - private val _refreshAvatar: MutableSharedFlow = MutableSharedFlow() - val refreshAvatar: SharedFlow - get() = _refreshAvatar.asSharedFlow() + viewModelScope.launch { + proStatusManager.postProLaunchStatus.collect { postPro -> + _uiState.update { it.copy(isPostPro = postPro) } + } + } - init { - viewModelScope.launch(Dispatchers.Default) { - val recipient = Recipient.from(context, Address.fromSerialized(hexEncodedPublicKey), false) - _avatarData.update { - AvatarData( - publicKey = hexEncodedPublicKey, - displayName = getDisplayName(), - recipient = recipient - ) + viewModelScope.launch { + prefs.watchHidePassword().collect { hidden -> + _uiState.update { it.copy(recoveryHidden = hidden) } + } + } + + viewModelScope.launch { + OnionRequestAPI.hasPath.collect { + _uiState.update { it.copy(hasPath = it.hasPath) } } } } - fun getDisplayName(): String = - prefs.getProfileName() ?: truncateIdForDisplay(hexEncodedPublicKey) + private fun getVersionNumber(): CharSequence { + val gitCommitFirstSixChars = BuildConfig.GIT_HASH.take(6) + val environment: String = if(BuildConfig.BUILD_TYPE == "release") "" else " - ${prefs.getEnvironment().label}" + val versionDetails = " ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - $gitCommitFirstSixChars) $environment" + return Phrase.from(context, R.string.updateVersion).put(VERSION_KEY, versionDetails).format() + } + + private fun updateAvatar(){ + viewModelScope.launch(Dispatchers.Default) { + _uiState.update { it.copy(avatarData = avatarUtils.getUIDataFromRecipient(userRecipient)) } + } + } fun hasAvatar() = prefs.getProfileAvatarId() != 0 @@ -128,8 +169,13 @@ class SettingsViewModel @Inject constructor( ).bitmap // update dialog with temporary avatar (has not been saved/uploaded yet) - _avatarDialogState.value = - AvatarDialogState.TempAvatar(profilePictureToBeUploaded, hasAvatar()) + _uiState.update { + it.copy(avatarDialogState = AvatarDialogState.TempAvatar( + data = profilePictureToBeUploaded, + isAnimated = false, // cropped avatars can't be animated + hasAvatar = hasAvatar() + )) + } } catch (e: BitmapDecodingException) { Log.e(TAG, e) } @@ -146,17 +192,61 @@ class SettingsViewModel @Inject constructor( } } + fun onAvatarPicked(uri: Uri) { + Log.i(TAG, "Picked a new avatar: $uri") + + viewModelScope.launch(Dispatchers.IO) { + try { + val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + + if(bytes == null){ + Log.e(TAG, "Error reading avatar bytes") + Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show() + } else { + _uiState.update { + it.copy( + avatarDialogState = AvatarDialogState.TempAvatar( + data = bytes, + isAnimated = isAnimated(uri), + hasAvatar = hasAvatar() + ) + ) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error reading avatar bytes", e) + } + } + } + fun onAvatarDialogDismissed() { - _avatarDialogState.value = getDefaultAvatarDialogState() + viewModelScope.launch { + _uiState.update { it.copy( + avatarDialogState = getDefaultAvatarDialogState(), + showAvatarDialog = false + ) } } } - fun getDefaultAvatarDialogState() = if (hasAvatar()) AvatarDialogState.UserAvatar(userAddress) + private suspend fun getDefaultAvatarDialogState() = if (hasAvatar()) AvatarDialogState.UserAvatar( + avatarUtils.getUIDataFromRecipient(userRecipient) + ) else AvatarDialogState.NoAvatar - fun saveAvatar() { - val tempAvatar = (avatarDialogState.value as? TempAvatar)?.data + private fun saveAvatar() { + val tempAvatar = (uiState.value.avatarDialogState as? AvatarDialogState.TempAvatar) ?: return Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show() + // if the selected avatar is animated but the user isn't pro, show the animated pro CTA + if (tempAvatar.isAnimated && !proStatusManager.isCurrentUserPro() && proStatusManager.isPostPro()) { + showAnimatedProCTA() + return + } + + // dismiss avatar dialog + // we don't want to do it earlier as the animated / pro case above should not close the dialog + // to give the user a chance ti pick something else + onAvatarDialogDismissed() + if (!hasNetworkConnection()) { Log.w(TAG, "Cannot update profile picture - no network connection.") Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show() @@ -168,11 +258,22 @@ class SettingsViewModel @Inject constructor( Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show() } - syncProfilePicture(tempAvatar, onFail) + syncProfilePicture(tempAvatar.data, onFail) } - fun removeAvatar() { + private fun removeAvatar() { + // if the user has a temporary avatar selected, clear that and redisplay the default avatar instead + if (uiState.value.avatarDialogState is AvatarDialogState.TempAvatar) { + viewModelScope.launch { + _uiState.update { it.copy(avatarDialogState = getDefaultAvatarDialogState()) } + } + return + } + + onAvatarDialogDismissed() + + // otherwise this action is for removing the existing avatar val haveNetworkConnection = connectivity.networkAvailable.value if (!haveNetworkConnection) { Log.w(TAG, "Cannot remove profile picture - no network connection.") @@ -192,7 +293,7 @@ class SettingsViewModel @Inject constructor( // Helper method used by updateProfilePicture and removeProfilePicture to sync it online private fun syncProfilePicture(profilePicture: ByteArray, onFail: () -> Unit) { viewModelScope.launch(Dispatchers.IO) { - _showLoader.value = true + _uiState.update { it.copy(showLoader = true) } try { // Grab the profile key and kick of the promise to update the profile picture @@ -211,7 +312,7 @@ class SettingsViewModel @Inject constructor( MessagingModuleConfiguration.shared.storage.clearUserPic() // update dialog state - _avatarDialogState.value = AvatarDialogState.NoAvatar + _uiState.update { it.copy(avatarDialogState = AvatarDialogState.NoAvatar) } } else { prefs.setProfileAvatarId(SECURE_RANDOM.nextInt()) ProfileKeyUtil.setEncodedProfileKey(context, encodedProfileKey) @@ -227,7 +328,7 @@ class SettingsViewModel @Inject constructor( } // update dialog state - _avatarDialogState.value = AvatarDialogState.UserAvatar(userAddress) + _uiState.update { it.copy(avatarDialogState = AvatarDialogState.UserAvatar(avatarUtils.getUIDataFromRecipient(userRecipient))) } } } catch (e: Exception){ // If the sync failed then inform the user @@ -238,38 +339,291 @@ class SettingsViewModel @Inject constructor( } // Finally update the main avatar - _refreshAvatar.emit(Unit) + updateAvatar() // And remove the loader animation after we've waited for the attempt to succeed or fail - _showLoader.value = false + _uiState.update { it.copy(showLoader = false) } } } - fun updateName(displayName: String) { - configFactory.withMutableUserConfigs { - it.userProfile.setName(displayName) + fun hasNetworkConnection(): Boolean = connectivity.networkAvailable.value + + fun isAnimated(uri: Uri) = proStatusManager.isPostPro() // block animated avatars prior to pro + && AnimatedImageUtils.isAnimated(context, uri) + + private fun showAnimatedProCTA() { + _uiState.update { it.copy(showAnimatedProCTA = true) } + } + + private fun hideAnimatedProCTA() { + _uiState.update { it.copy(showAnimatedProCTA = false) } + } + + fun showAvatarDialog() { + _uiState.update { it.copy(showAvatarDialog = true) } + } + + fun hideAvatarPickerOptions() { + _uiState.update { it.copy(showAvatarPickerOptions = false) } + + } + + fun showUrlDialog(url: String) { + _uiState.update { it.copy(showUrlDialog = url) } + } + + fun showAvatarPickerOptions(showCamera: Boolean) { + _uiState.update { it.copy( + showAvatarPickerOptions = true, + showAvatarPickerOptionCamera = showCamera + ) } + } + + private fun clearData(clearNetwork: Boolean) { + val currentClearState = uiState.value.clearDataDialog + // show loading + _uiState.update { it.copy(clearDataDialog = ClearDataState.Clearing) } + + // only clear locally is clearNetwork is false or we are in an error state + viewModelScope.launch(Dispatchers.Default) { + if (!clearNetwork || currentClearState == ClearDataState.Error) { + clearDataDeviceOnly() + } else if(currentClearState == ClearDataState.Default){ + _uiState.update { it.copy(clearDataDialog = ClearDataState.ConfirmNetwork) } + } else { // clear device and network + clearDataDeviceAndNetwork() + } } } - fun permanentlyHidePassword() { - //todo we can simplify this once we expose all our sharedPrefs as flows - prefs.setHidePassword(true) - _recoveryHidden.update { true } + private suspend fun clearDataDeviceOnly() { + val result = runCatching { + clearDataUtils.clearAllDataAndRestart() + } + + withContext(Main) { + if (result.isSuccess) { + _uiState.update { it.copy(clearDataDialog = ClearDataState.Hidden) } + } else { + Toast.makeText(context, R.string.errorUnknown, Toast.LENGTH_LONG).show() + } + } } - fun hasNetworkConnection(): Boolean = connectivity.networkAvailable.value + private suspend fun clearDataDeviceAndNetwork() { + val deletionResultMap: Map? = try { + val openGroups = storage.getAllOpenGroups() + openGroups.map { it.value.server }.toSet().forEach { server -> + OpenGroupApi.deleteAllInboxMessages(server).await() + } + SnodeAPI.deleteAllMessages(checkNotNull(storage.userAuth)).await() + } catch (e: Exception) { + Log.e(TAG, "Failed to delete network messages - offering user option to delete local data only.", e) + null + } + + // If one or more deletions failed then inform the user and allow them to clear the device only if they wish.. + if (deletionResultMap == null || deletionResultMap.values.any { !it } || deletionResultMap.isEmpty()) { + withContext(Main) { + _uiState.update { it.copy(clearDataDialog = ClearDataState.Error) } + } + } + else if (deletionResultMap.values.all { it }) { + // ..otherwise if the network data deletion was successful proceed to delete the local data as well. + clearDataDeviceOnly() + } + } + + private fun setUsername(name: String){ + if(!hasNetworkConnection()){ + Log.w(TAG, "Cannot update display name - no network connection.") + Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show() + return + } + + // save username + _uiState.update { it.copy(username = name) } + prefs.setProfileName(name) + usernameUtils.saveCurrentUserName(name) + } + + fun onCommand(command: Commands) { + when (command) { + is Commands.ShowClearDataDialog -> { + _uiState.update { it.copy(clearDataDialog = ClearDataState.Default) } + } + + is Commands.HideClearDataDialog -> { + _uiState.update { it.copy(clearDataDialog = ClearDataState.Hidden) } + } + + is Commands.ShowUrlDialog -> { + showUrlDialog(command.url) + } + + is Commands.HideUrlDialog -> { + _uiState.update { it.copy(showUrlDialog = null) } + } + + is Commands.ShowAvatarDialog -> { + showAvatarDialog() + } + + is Commands.ShowAvatarPickerOptions -> { + showAvatarPickerOptions(command.showCamera) + } + + is Commands.HideAvatarPickerOptions -> { + hideAvatarPickerOptions() + } + + is Commands.OnAvatarDialogDismissed -> { + onAvatarDialogDismissed() + } + + is Commands.ShowAnimatedProCTA -> { + showAnimatedProCTA() + } + + is Commands.HideAnimatedProCTA -> { + hideAnimatedProCTA() + } + + is Commands.SaveAvatar -> { + saveAvatar() + } + + is Commands.RemoveAvatar -> { + removeAvatar() + } + + is Commands.ClearData -> { + clearData(command.clearNetwork) + } + + is Commands.ShowUsernameDialog -> { + _uiState.update { + it.copy(usernameDialog = UsernameDialogData( + currentName = it.username, + inputName = it.username, + setEnabled = false, + error = null + )) + } + } + + + is Commands.HideUsernameDialog -> { + _uiState.update { it.copy(usernameDialog = null) } + } + + is Commands.SetUsername -> { + _uiState.value.usernameDialog?.inputName?.trim()?.let { + setUsername(it) + } + + // hide username dialog + _uiState.update { it.copy(usernameDialog = null) } + } + + is Commands.UpdateUsername -> { + val trimmedName = command.name.trim() + + val error: String? = when { + trimmedName.textSizeInBytes() > SSKEnvironment.ProfileManagerProtocol.NAME_PADDED_LENGTH -> + context.getString(R.string.displayNameErrorDescriptionShorter) + + else -> null + } + + _uiState.update { it.copy(usernameDialog = + it.usernameDialog?.copy( + inputName = command.name, + setEnabled = trimmedName.isNotEmpty() && // can save if we have an input + trimmedName != it.usernameDialog.currentName && // ... and it isn't the same as what is already saved + error == null, // ... and there are no errors + error = error + ) + ) + } + } + + is Commands.OnDonateClicked -> { + viewModelScope.launch { + inAppReviewManager.onEvent(InAppReviewManager.Event.DonateButtonClicked) + } + showUrlDialog( "https://session.foundation/donate#app") + } + } + } sealed class AvatarDialogState() { object NoAvatar : AvatarDialogState() - data class UserAvatar(val address: Address) : AvatarDialogState() + data class UserAvatar(val data: AvatarUIData) : AvatarDialogState() data class TempAvatar( val data: ByteArray, + val isAnimated: Boolean, val hasAvatar: Boolean // true if the user has an avatar set already but is in this temp state because they are trying out a new avatar ) : AvatarDialogState() } - data class AvatarData( - val publicKey: String, - val displayName: String, - val recipient: Recipient + sealed interface ClearDataState { + data object Hidden: ClearDataState + data object Default: ClearDataState + data object Clearing: ClearDataState + data object ConfirmNetwork: ClearDataState + data object Error: ClearDataState + } + + data class UsernameDialogData( + val currentName: String?, // the currently saved name + val inputName: String?, // the name being inputted + val setEnabled: Boolean, + val error: String? ) + + data class UIState( + val username: String, + val accountID: String, + val hasPath: Boolean, + val version: CharSequence = "", + val showLoader: Boolean = false, + val avatarDialogState: AvatarDialogState = AvatarDialogState.NoAvatar, + val avatarData: AvatarUIData? = null, + val recoveryHidden: Boolean, + val showUrlDialog: String? = null, + val clearDataDialog: ClearDataState = ClearDataState.Hidden, + val showAvatarDialog: Boolean = false, + val showAvatarPickerOptionCamera: Boolean = false, + val showAvatarPickerOptions: Boolean = false, + val showAnimatedProCTA: Boolean = false, + val usernameDialog: UsernameDialogData? = null, + val isPro: Boolean, + val isPostPro: Boolean, + val showProBadge: Boolean + ) + + sealed interface Commands { + data object ShowClearDataDialog: Commands + data object HideClearDataDialog: Commands + data class ShowUrlDialog(val url: String): Commands + data object HideUrlDialog: Commands + data object ShowAvatarDialog: Commands + data class ShowAvatarPickerOptions(val showCamera: Boolean): Commands + data object HideAvatarPickerOptions: Commands + data object SaveAvatar: Commands + data object RemoveAvatar: Commands + data object OnAvatarDialogDismissed: Commands + + data object ShowUsernameDialog : Commands + data object HideUsernameDialog : Commands + data object SetUsername: Commands + data class UpdateUsername(val name: String): Commands + + data object ShowAnimatedProCTA: Commands + data object HideAnimatedProCTA: Commands + + data object OnDonateClicked: Commands + + data class ClearData(val clearNetwork: Boolean): Commands + } } \ No newline at end of file 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 89225d2562..639ba9b47e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt @@ -4,24 +4,20 @@ 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.webkit.MimeTypeMap import android.widget.Toast import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import com.squareup.phrase.Phrase +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException import java.io.File -import java.io.FileOutputStream import java.io.IOException -import java.util.Objects import java.util.concurrent.TimeUnit import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -30,16 +26,20 @@ import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsignal.utilities.ExternalStorageUtil import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.createSessionDialog +import org.thoughtcrime.securesms.logging.PersistentLogger import org.thoughtcrime.securesms.util.FileProviderUtil -import org.thoughtcrime.securesms.util.StreamUtil +import javax.inject.Inject +@AndroidEntryPoint class ShareLogsDialog(private val updateCallback: (Boolean)->Unit): DialogFragment() { private val TAG = "ShareLogsDialog" private var shareJob: Job? = null + @Inject + lateinit var persistentLogger: PersistentLogger + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { title(R.string.helpReportABugExportLogs) val appName = context.getString(R.string.app_name) @@ -63,59 +63,26 @@ class ShareLogsDialog(private val updateCallback: (Boolean)->Unit): DialogFragme updateCallback(true) - shareJob = lifecycleScope.launch(Dispatchers.IO) { - val persistentLogger = ApplicationContext.getInstance(context).persistentLogger + shareJob = lifecycleScope.launch { try { Log.d(TAG, "Starting share logs job...") - - val context = requireContext() - val outputUri: Uri = ExternalStorageUtil.getDownloadUri() - 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) - MediaScannerConnection.scanFile(context, arrayOf(mediaUri.path), arrayOf("text/plain"), null) - } - } else { - context.contentResolver.openOutputStream(mediaUri, "w").use { outputStream -> - val total: Long = StreamUtil.copy(inputStream, outputStream) - if (total > 0) { - updateValues.put(MediaStore.MediaColumns.SIZE, total) - } - } + val mediaUri = withContext(Dispatchers.IO) { + withExternalFile(persistentLogger::readAllLogsCompressed) + } ?: return@launch + + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_STREAM, mediaUri) + type = "application/zip" + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - if (Build.VERSION.SDK_INT > 28) { - updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0) - } - if (updateValues.size() > 0) { - requireContext().contentResolver.update(mediaUri, updateValues, null, null) - } - - val shareUri = if (mediaUri.scheme == ContentResolver.SCHEME_FILE) { - FileProviderUtil.getUriFor(context, File(mediaUri.path!!)) - } else { - mediaUri - } - - withContext(Main) { - val shareIntent = Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_STREAM, shareUri) - type = "text/plain" - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - startActivity(Intent.createChooser(shareIntent, getString(R.string.share))) - } + startActivity(Intent.createChooser(shareIntent, getString(R.string.share))) } catch (e: Exception) { - withContext(Main) { + if (e !is CancellationException) { Log.e("Loki", "Error saving logs", e) - Toast.makeText(context,getString(R.string.errorUnknown), Toast.LENGTH_LONG).show() + Toast.makeText(context, getString(R.string.errorUnknown), Toast.LENGTH_LONG) + .show() } } }.also { shareJob -> @@ -146,33 +113,13 @@ class ShareLogsDialog(private val updateCallback: (Boolean)->Unit): DialogFragme } } - @Throws(IOException::class) - private fun pathTaken(outputUri: Uri, dataPath: String): Boolean { - requireContext().contentResolver.query(outputUri, arrayOf(MediaStore.MediaColumns.DATA), - MediaStore.MediaColumns.DATA + " = ?", arrayOf(dataPath), - null).use { cursor -> - if (cursor == null) { - throw IOException("Something is wrong with the filename to save") - } - return cursor.moveToFirst() - } - } - - private fun getExternalFile(): Uri? { + private fun withExternalFile(action: (Uri) -> Unit): Uri? { val context = requireContext() val base = "${Build.MANUFACTURER}-${Build.DEVICE}-API${Build.VERSION.SDK_INT}-v${BuildConfig.VERSION_NAME}-${System.currentTimeMillis()}" - val extension = "txt" + val extension = "zip" val fileName = "$base.$extension" - val mimeType = MimeTypeMap.getSingleton().getExtensionFromMimeType("text/plain") val outputUri: Uri = ExternalStorageUtil.getDownloadUri() - val contentValues = ContentValues() - contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) - contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType) - contentValues.put(MediaStore.MediaColumns.DATE_ADDED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())) - contentValues.put(MediaStore.MediaColumns.DATE_MODIFIED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())) - if (Build.VERSION.SDK_INT > 28) { - contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1) - } else if (Objects.equals(outputUri.scheme, ContentResolver.SCHEME_FILE)) { + if (outputUri.scheme == ContentResolver.SCHEME_FILE) { val outputDirectory = File(outputUri.path) var outputFile = File(outputDirectory, "$base.$extension") var i = 0 @@ -182,19 +129,34 @@ class ShareLogsDialog(private val updateCallback: (Boolean)->Unit): DialogFragme if (outputFile.isHidden) { throw IOException("Specified name would not be visible") } - return Uri.fromFile(outputFile) + try { + return FileProviderUtil.getUriFor(requireContext(), outputFile).also(action) + } catch (e: Exception) { + outputFile.delete() + throw e + } } else { - var outputFileName = fileName - val externalPath = context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)!! - var dataPath = String.format("%s/%s", externalPath, outputFileName) - var i = 0 - while (pathTaken(outputUri, dataPath)) { - outputFileName = base + "-" + ++i + "." + extension - dataPath = String.format("%s/%s", externalPath, outputFileName) + val contentValues = ContentValues() + contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "application/zip") + contentValues.put(MediaStore.MediaColumns.DATE_ADDED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())) + contentValues.put(MediaStore.MediaColumns.DATE_MODIFIED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())) + contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1) + val uri = context.contentResolver.insert(outputUri, contentValues) ?: return null + try { + action(uri) + + // Remove the pending flag to make the file available + contentValues.clear() + contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0) + context.contentResolver.update(uri, contentValues, null, null) + } catch (e: Exception) { + context.contentResolver.delete(uri, null, null) + throw e } - contentValues.put(MediaStore.MediaColumns.DATA, dataPath) + + return uri } - return context.contentResolver.insert(outputUri, contentValues) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt new file mode 100644 index 0000000000..ec25fccaf6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettings.kt @@ -0,0 +1,315 @@ +package org.thoughtcrime.securesms.preferences.appearance + +import android.graphics.drawable.AdaptiveIconDrawable +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.asComposePath +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.toBitmap +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.DialogButtonData +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import kotlin.math.ceil +import kotlin.math.min + +@Composable +fun AppDisguiseSettingsScreen( + viewModel: AppDisguiseSettingsViewModel, + onBack: () -> Unit +) { + AppDisguiseSettings( + onBack = onBack, + items = viewModel.iconList.collectAsState().value, + dialogState = viewModel.confirmDialogState.collectAsState().value, + onCommand = viewModel::onCommand, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AppDisguiseSettings( + items: List, + dialogState: AppDisguiseSettingsViewModel.ConfirmDialogState?, + onBack: () -> Unit, + onCommand: (AppDisguiseSettingsViewModel.Command) -> Unit, +) { + Scaffold( + topBar = { + BackAppBar(title = stringResource(R.string.sessionAppearance), onBack = onBack) + } + ) { paddings -> + Column( + modifier = Modifier + .padding(paddings) + .padding(LocalDimensions.current.smallSpacing) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xsSpacing) + ) { + BoxWithConstraints { + // Calculate the number of columns based on the min width we want each column + // to be. + val minColumnWidth = LocalDimensions.current.xxsSpacing + ICON_ITEM_SIZE_DP.dp + val maxNumColumn = + (constraints.maxWidth / LocalDensity.current.run { minColumnWidth.toPx() }).toInt() + + // Make sure we fit all the items in the columns by trying each column size until + // we find one that suits. When the column size gets down to 1, it will always fit : + // n % 1 is always 0. + val numColumn = (maxNumColumn downTo 1) + .first { items.size % it == 0 } + + val numRows = ceil(items.size.toFloat() / numColumn).toInt() + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing), + ) { + Text( + stringResource(R.string.appIconAndNameSelectionTitle), + style = LocalType.current.large, + color = LocalColors.current.textSecondary + ) + + Cell { + Column( + modifier = Modifier.padding(LocalDimensions.current.xsSpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing) + ) { + repeat(numRows) { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + for (index in row * numColumn.. Unit, + modifier: Modifier = Modifier, +) { + val resources = LocalContext.current.resources + val theme = LocalContext.current.theme + + val (path: Path, bitmap) = remember(icon, resources, theme) { + val drawable = ResourcesCompat.getDrawable(resources, icon, theme) + + when(drawable){ + is AdaptiveIconDrawable -> drawable.iconMask.asComposePath() to drawable.toBitmap().asImageBitmap() + else -> { // if the system does not support adaptive icons (like Huawei phones) default to a rectangle shape + val bmp = drawable!!.toBitmap() + Path().apply { addRect(Rect(0f, 0f, bmp.width.toFloat(), bmp.height.toFloat())) } to bmp.asImageBitmap() + } + } + } + + val textColor = LocalColors.current.text + val selectedBorderColor = LocalColors.current.textSecondary + val density = LocalDensity.current + val borderStroke = Stroke(density.run { 2.dp.toPx() }) + val nameText = stringResource(name) + + Column( + modifier = modifier + .padding(LocalDimensions.current.xxxsSpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + BitmapPainter(bitmap), + modifier = Modifier + .size(ICON_ITEM_SIZE_DP.dp) + .drawWithContent { + drawContent() + if (selected) { + val scaleX = size.width / path.getBounds().width + scale(scaleX, scaleX, pivot = Offset.Zero) { + drawPath( + path = path, + color = selectedBorderColor, + style = borderStroke + ) + } + } + } + .qaTag("$nameText option") + .selectable( + selected = selected, + onClick = onSelected, + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) + .padding(4.dp), + contentDescription = null + ) + + Text( + nameText, + textAlign = TextAlign.Center, + style = LocalType.current.large, + color = textColor, + ) + } +} + +@Preview +@Preview(device = Devices.TABLET) +@Preview(widthDp = 486) +@Preview(widthDp = 300) +@Composable +private fun AppDisguiseSettingsPreview( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + AppDisguiseSettings( + items = listOf( + AppDisguiseSettingsViewModel.IconAndName( + id = "3", + icon = R.mipmap.ic_launcher, + name = R.string.app_name, + selected = true + ), + AppDisguiseSettingsViewModel.IconAndName( + id = "1", + icon = R.mipmap.ic_launcher_weather, + name = R.string.appNameWeather, + selected = false + ), + AppDisguiseSettingsViewModel.IconAndName( + id = "2", + icon = R.mipmap.ic_launcher_stocks, + name = R.string.appNameStocks, + selected = false + ), + AppDisguiseSettingsViewModel.IconAndName( + id = "1", + icon = R.mipmap.ic_launcher_notes, + name = R.string.appNameNotes, + selected = false + ), + AppDisguiseSettingsViewModel.IconAndName( + id = "1", + icon = R.mipmap.ic_launcher_meetings, + name = R.string.appNameMeetingSE, + selected = false + ), + AppDisguiseSettingsViewModel.IconAndName( + id = "1", + icon = R.mipmap.ic_launcher_calculator, + name = R.string.appNameCalculator, + selected = false + ), + ), + onBack = { }, + dialogState = null, + onCommand = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsActivity.kt new file mode 100644 index 0000000000..fb1a9847d3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsActivity.kt @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.preferences.appearance + +import androidx.compose.runtime.Composable +import androidx.hilt.navigation.compose.hiltViewModel +import dagger.hilt.android.AndroidEntryPoint +import org.thoughtcrime.securesms.FullComposeScreenLockActivity + +@AndroidEntryPoint +class AppDisguiseSettingsActivity : FullComposeScreenLockActivity() { + + @Composable + override fun ComposeContent() { + AppDisguiseSettingsScreen( + viewModel = hiltViewModel(), + onBack = this::finish + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsViewModel.kt new file mode 100644 index 0000000000..e3b28a32c8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsViewModel.kt @@ -0,0 +1,77 @@ +package org.thoughtcrime.securesms.preferences.appearance + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import org.thoughtcrime.securesms.disguise.AppDisguiseManager +import javax.inject.Inject + +@HiltViewModel +class AppDisguiseSettingsViewModel @Inject constructor( + private val manager: AppDisguiseManager +) : ViewModel() { + // The contents of the selection items + val iconList: StateFlow> = combine( + manager.allAppAliases, + manager.selectedAppAliasName, + ) { aliases, selected -> + aliases + .sortedByDescending { it.defaultEnabled } // The default enabled alias must be first + .mapNotNull { alias -> + IconAndName( + id = alias.activityAliasName, + icon = alias.appIcon ?: return@mapNotNull null, + name = alias.appName ?: return@mapNotNull null, + selected = selected?.let { alias.activityAliasName == it } ?: alias.defaultEnabled + ) + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = emptyList() + ) + + private val mutableConfirmDialogState = MutableStateFlow(null) + val confirmDialogState: StateFlow get() = mutableConfirmDialogState + + fun onCommand(command: Command) { + when (command) { + is Command.IconSelectConfirmed -> { + mutableConfirmDialogState.value = null + manager.setSelectedAliasName(command.id) + } + + Command.IconSelectDismissed -> { + mutableConfirmDialogState.value = null + } + + is Command.IconSelected -> { + if (command.id != manager.selectedAppAliasName.value) { + mutableConfirmDialogState.value = ConfirmDialogState(id = command.id,) + } + } + } + } + + data class IconAndName( + val id: String, + @DrawableRes val icon: Int, + @StringRes val name: Int, + val selected: Boolean, + ) + + data class ConfirmDialogState(val id: String) + + sealed interface Command { + data class IconSelected(val id: String) : Command + data class IconSelectConfirmed(val id: String) : Command + data object IconSelectDismissed : Command + } +} \ 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 ddf28e9211..641568a831 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 @@ -1,10 +1,13 @@ package org.thoughtcrime.securesms.preferences.appearance +import android.content.Intent import android.os.Bundle import android.os.Parcelable +import android.se.omapi.Session import android.util.SparseArray import android.view.View import androidx.activity.viewModels +import androidx.compose.runtime.collectAsState import androidx.core.view.children import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint @@ -15,11 +18,13 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.CLASSIC_ import org.session.libsession.utilities.TextSecurePreferences.Companion.CLASSIC_LIGHT import org.session.libsession.utilities.TextSecurePreferences.Companion.OCEAN_DARK import org.session.libsession.utilities.TextSecurePreferences.Companion.OCEAN_LIGHT -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.ScreenLockActionBarActivity +import org.thoughtcrime.securesms.ui.components.SessionSwitch +import org.thoughtcrime.securesms.ui.setThemedContent import org.thoughtcrime.securesms.util.ThemeState @AndroidEntryPoint -class AppearanceSettingsActivity: PassphraseRequiredActionBarActivity(), View.OnClickListener { +class AppearanceSettingsActivity: ScreenLockActionBarActivity(), View.OnClickListener { companion object { private const val SCROLL_PARCEL = "scroll_parcel" @@ -106,10 +111,6 @@ class AppearanceSettingsActivity: PassphraseRequiredActionBarActivity(), View.On } } - private fun updateFollowSystemToggle(followSystemSettings: Boolean) { - binding.systemSettingsSwitch.isChecked = followSystemSettings - } - override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { super.onCreate(savedInstanceState, ready) binding = ActivityAppearanceSettingsBinding.inflate(layoutInflater) @@ -127,16 +128,24 @@ class AppearanceSettingsActivity: PassphraseRequiredActionBarActivity(), View.On it.setOnClickListener(this@AppearanceSettingsActivity) } // system settings toggle - systemSettingsSwitch.setOnCheckedChangeListener { _, isChecked -> viewModel.setNewFollowSystemSettings(isChecked) } - systemSettingsSwitchHolder.setOnClickListener { systemSettingsSwitch.toggle() } + systemSettingsSwitch.setThemedContent { + SessionSwitch( + checked = viewModel.uiState.collectAsState().value.followSystem, + onCheckedChange = viewModel::setNewFollowSystemSettings, + enabled = true + ) + } + + systemSettingsAppIcon.setOnClickListener { + startActivity(Intent(this@AppearanceSettingsActivity, AppDisguiseSettingsActivity::class.java)) + } } lifecycleScope.launchWhenResumed { viewModel.uiState.collectLatest { themeState -> - val (theme, accent, followSystem) = themeState + val (theme, accent) = themeState updateSelectedTheme(theme) updateSelectedAccent(accent) - updateFollowSystemToggle(followSystem) if (currentTheme != null && currentTheme != themeState) { recreate() } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsViewModel.kt index 2547e23e22..4dafa60910 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsViewModel.kt @@ -2,17 +2,23 @@ package org.thoughtcrime.securesms.preferences.appearance import androidx.annotation.StyleRes import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.reviews.InAppReviewManager import org.thoughtcrime.securesms.ui.theme.invalidateComposeThemeColors import org.thoughtcrime.securesms.util.ThemeState import org.thoughtcrime.securesms.util.themeState import javax.inject.Inject @HiltViewModel -class AppearanceSettingsViewModel @Inject constructor(private val prefs: TextSecurePreferences) : ViewModel() { +class AppearanceSettingsViewModel @Inject constructor( + private val prefs: TextSecurePreferences, + private val inAppReviewManager: InAppReviewManager, +) : ViewModel() { private val _uiState = MutableStateFlow(prefs.themeState()) val uiState: StateFlow = _uiState @@ -23,6 +29,10 @@ class AppearanceSettingsViewModel @Inject constructor(private val prefs: TextSec _uiState.value = prefs.themeState() invalidateComposeThemeColors() + + viewModelScope.launch { + inAppReviewManager.onEvent(InAppReviewManager.Event.ThemeChanged) + } } fun setNewStyle(newThemeStyle: String) { @@ -31,6 +41,10 @@ class AppearanceSettingsViewModel @Inject constructor(private val prefs: TextSec _uiState.value = prefs.themeState() invalidateComposeThemeColors() + + viewModelScope.launch { + inAppReviewManager.onEvent(InAppReviewManager.Event.ThemeChanged) + } } fun setNewFollowSystemSettings(followSystemSettings: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt new file mode 100644 index 0000000000..0b3ca340a4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.preferences.prosettings + +import androidx.compose.runtime.Composable +import dagger.hilt.android.AndroidEntryPoint +import org.thoughtcrime.securesms.FullComposeScreenLockActivity +import org.thoughtcrime.securesms.ui.UINavigator +import javax.inject.Inject + +@AndroidEntryPoint +class ProSettingsActivity: FullComposeScreenLockActivity() { + + @Inject + lateinit var navigator: UINavigator + + @Composable + override fun ComposeContent() { + ProSettingsNavHost( + navigator = navigator, + onBack = this::finish + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt new file mode 100644 index 0000000000..85f5c00bb6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -0,0 +1,92 @@ +package org.thoughtcrime.securesms.preferences.prosettings + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.SessionProSettingsHeader +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions + + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun ProSettingsHomeScreen( + viewModel: ProSettingsViewModel, + onBack: () -> Unit, +) { + val data by viewModel.uiState.collectAsState() + val dialogsState by viewModel.dialogState.collectAsState() + + ProSettingsHome( + data = data, + dialogsState = dialogsState, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@Composable +fun ProSettingsHome( + data: ProSettingsViewModel.UIState, + dialogsState: ProSettingsViewModel.DialogsState, + sendCommand: (ProSettingsViewModel.Commands) -> Unit, + onBack: () -> Unit, +) { + Scaffold( + topBar = { + BackAppBar( + title = "", + backgroundColor = Color.Transparent, + onBack = onBack, + ) + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + ) { paddings -> + + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) + .consumeWindowInsets(paddings) + .padding( + horizontal = LocalDimensions.current.spacing, + ) + .verticalScroll(rememberScrollState()), + horizontalAlignment = CenterHorizontally + ) { + Spacer(Modifier.height(46.dp)) + + SessionProSettingsHeader( + color = if(data.disabledHeader) LocalColors.current.disabled else LocalColors.current.accent, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt new file mode 100644 index 0000000000..b1b123d157 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.preferences.prosettings + +import android.annotation.SuppressLint +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.runtime.Composable +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import kotlinx.serialization.Serializable +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.* +import org.thoughtcrime.securesms.ui.NavigationAction +import org.thoughtcrime.securesms.ui.ObserveAsEvents +import org.thoughtcrime.securesms.ui.UINavigator +import org.thoughtcrime.securesms.ui.horizontalSlideComposable + +// Destinations +sealed interface ProSettingsDestination { + @Serializable + data object Home: ProSettingsDestination + + @Serializable + data object ChooseSubscriptionPlan: ProSettingsDestination +} + +@SuppressLint("RestrictedApi") +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun ProSettingsNavHost( + navigator: UINavigator, + onBack: () -> Unit +){ + SharedTransitionLayout { + val navController = rememberNavController() + + ObserveAsEvents(flow = navigator.navigationActions) { action -> + when (action) { + is NavigationAction.Navigate -> navController.navigate( + action.destination + ) { + action.navOptions(this) + } + + NavigationAction.NavigateUp -> navController.navigateUp() + + is NavigationAction.NavigateToIntent -> { + navController.context.startActivity(action.intent) + } + + is NavigationAction.ReturnResult -> {} + } + } + + NavHost(navController = navController, startDestination = Home) { + // Home + horizontalSlideComposable { + val viewModel = hiltViewModel() + + ProSettingsHomeScreen( + viewModel = viewModel, + onBack = onBack, + ) + } + + // Subscription plan selection + horizontalSlideComposable { + + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt new file mode 100644 index 0000000000..ab7d7caa4f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.preferences.prosettings + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.assisted.AssistedFactory +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.ui.UINavigator +import javax.inject.Inject + + +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) +@HiltViewModel +class ProSettingsViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val navigator: UINavigator, + private val proStatusManager: ProStatusManager, +) : ViewModel() { + + private val _uiState: MutableStateFlow = MutableStateFlow( + UIState( + isPro = proStatusManager.isCurrentUserPro() + ) + ) + val uiState: StateFlow = _uiState + + private val _dialogState: MutableStateFlow = MutableStateFlow(DialogsState()) + val dialogState: StateFlow = _dialogState + + init { + + } + + + fun onCommand(command: Commands) { + when (command) { + is Commands.ShowOpenUrlDialog -> { + _dialogState.update { + it.copy(openLinkDialogUrl = command.url) + } + } + } + } + + private fun navigateTo(destination: ProSettingsDestination){ + viewModelScope.launch { + navigator.navigate(destination) + } + } + + sealed interface Commands { + data class ShowOpenUrlDialog(val url: String?) : Commands + } + + data class UIState( + val isPro: Boolean, + val disabledHeader: Boolean = false, + ) + + + data class DialogsState( + val openLinkDialogUrl: String? = null, + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt new file mode 100644 index 0000000000..5f7359f051 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -0,0 +1,146 @@ +package org.thoughtcrime.securesms.pro + +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.visible.VisibleMessage +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ProStatusManager @Inject constructor( + private val prefs: TextSecurePreferences, +) : OnAppStartupComponent { + val MAX_CHARACTER_PRO = 10000 // max characters in a message for pro users + private val MAX_CHARACTER_REGULAR = 2000 // max characters in a message for non pro users + private val MAX_PIN_REGULAR = 5 // max pinned conversation for non pro users + + // live state of the Pro status + private val _proStatus = MutableStateFlow(isCurrentUserPro()) + val proStatus: StateFlow = _proStatus + + // live state for the pre vs post pro launch status + private val _postProLaunchStatus = MutableStateFlow(isPostPro()) + val postProLaunchStatus: StateFlow = _postProLaunchStatus + + init { + GlobalScope.launch { + prefs.watchProStatus().collect { + _proStatus.update { isCurrentUserPro() } + } + } + + GlobalScope.launch { + prefs.watchPostProStatus().collect { + _postProLaunchStatus.update { isPostPro() } + } + } + } + + fun isCurrentUserPro(): Boolean { + // if the debug is set, return that + if (prefs.forceCurrentUserAsPro()) return true + + // otherwise return the true value + return false //todo PRO implement real logic once it's in + } + + fun isUserPro(address: Address?): Boolean{ + //todo PRO implement real logic once it's in - including the specifics for a groupsV2 + if(address == null) return false + + if(address.isCommunity) return false + else if(address.toString() == prefs.getLocalNumber()) return isCurrentUserPro() + else if(prefs.forceOtherUsersAsPro()) return true + + return false + } + + /** + * Logic to determine if we should animate the avatar for a user or freeze it on the first frame + */ + fun freezeFrameForUser(address: Address?): Boolean{ + return if(!isPostPro() || address?.isCommunity == true) false else !isUserPro(address) + } + + /** + * Returns the max length that a visible message can have based on its Pro status + */ + fun getIncomingMessageMaxLength(message: VisibleMessage): Int { + // if the debug is set, return that + if (prefs.forceIncomingMessagesAsPro()) return MAX_CHARACTER_PRO + + // otherwise return the true value + return if(isPostPro()) MAX_CHARACTER_REGULAR else MAX_CHARACTER_PRO //todo PRO implement real logic once it's in + } + + // Temporary method and concept that we should remove once Pro is out + fun isPostPro(): Boolean { + return prefs.forcePostPro() + } + + fun shouldShowProBadge(address: Address?): Boolean { + return isPostPro() && isUserPro(address) //todo PRO also check flag to see if user wants to hide their badge here + } + + fun getCharacterLimit(): Int { + return if (isCurrentUserPro()) MAX_CHARACTER_PRO else MAX_CHARACTER_REGULAR + } + + fun getPinnedConversationLimit(): Int { + if(!isPostPro()) return Int.MAX_VALUE // allow infinite pins while not in post Pro + + return if (isCurrentUserPro()) Int.MAX_VALUE else MAX_PIN_REGULAR + } + + + /** + * This will calculate the pro features of an outgoing message + */ + fun calculateMessageProFeatures(message: String): List{ + val userAddress = prefs.getLocalNumber() + if(!isCurrentUserPro() || userAddress == null) return emptyList() + + val features = mutableListOf() + + // check for pro badge display + if(shouldShowProBadge(Address.fromSerialized(userAddress))){ + features.add(MessageProFeature.ProBadge) + } + + // check for "long message" feature + if(message.length > MAX_CHARACTER_REGULAR){ + features.add(MessageProFeature.LongMessage) + } + + // check is the user has an animated avatar + //todo PRO check for animated avatar here and add appropriate feature + + + return features + } + + /** + * This will get the list of Pro features from an incoming message + */ + fun getMessageProFeatures(messageId: MessageId): Set{ + //todo PRO implement once we have data + + // use debug values if any + if(prefs.forceIncomingMessagesAsPro()){ + return prefs.getDebugMessageFeatures() + } + + return emptySet() + } + + enum class MessageProFeature { + ProBadge, LongMessage, AnimatedAvatar + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java deleted file mode 100644 index 6d900a5b37..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java +++ /dev/null @@ -1,400 +0,0 @@ -package org.thoughtcrime.securesms.providers; - -import android.app.Application; -import android.content.Context; -import android.content.UriMatcher; -import android.net.Uri; -import androidx.annotation.IntRange; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; - -import org.thoughtcrime.securesms.crypto.AttachmentSecret; -import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; -import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; -import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream; -import org.session.libsignal.utilities.Log; -import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.concurrent.SignalExecutors; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; - -/** - * Allows for the creation and retrieval of blobs. - */ -public class BlobProvider { - - private static final String TAG = BlobProvider.class.getSimpleName(); - - private static final String MULTI_SESSION_DIRECTORY = "multi_session_blobs"; - private static final String SINGLE_SESSION_DIRECTORY = "single_session_blobs"; - - public static final Uri CONTENT_URI = Uri.parse("content://network.loki.provider.securesms/blob"); - public static final String AUTHORITY = "network.loki.provider.securesms"; - public static final String PATH = "blob/*/*/*/*/*"; - - private static final int STORAGE_TYPE_PATH_SEGMENT = 1; - private static final int MIMETYPE_PATH_SEGMENT = 2; - private static final int FILENAME_PATH_SEGMENT = 3; - private static final int FILESIZE_PATH_SEGMENT = 4; - private static final int ID_PATH_SEGMENT = 5; - - private static final int MATCH = 1; - private static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH) {{ - addURI(AUTHORITY, PATH, MATCH); - }}; - - private static final BlobProvider INSTANCE = new BlobProvider(); - - private final Map memoryBlobs = new HashMap<>(); - - - public static BlobProvider getInstance() { - return INSTANCE; - } - - /** - * Begin building a blob for the provided data. Allows for the creation of in-memory blobs. - */ - public MemoryBlobBuilder forData(@NonNull byte[] data) { - return new MemoryBlobBuilder(data); - } - - /** - * Begin building a blob for the provided input stream. - */ - public BlobBuilder forData(@NonNull InputStream data, long fileSize) { - return new BlobBuilder(data, fileSize); - } - - /** - * Retrieve a stream for the content with the specified URI. - * @throws IOException If the stream fails to open or the spec of the URI doesn't match. - */ - public synchronized @NonNull InputStream getStream(@NonNull Context context, @NonNull Uri uri) throws IOException { - if (isAuthority(uri)) { - StorageType storageType = StorageType.decode(uri.getPathSegments().get(STORAGE_TYPE_PATH_SEGMENT)); - - if (storageType.isMemory()) { - byte[] data = memoryBlobs.get(uri); - - if (data != null) { - if (storageType == StorageType.SINGLE_USE_MEMORY) { - memoryBlobs.remove(uri); - } - return new ByteArrayInputStream(data); - } else { - throw new IOException("Failed to find in-memory blob for: " + uri); - } - } else { - String id = uri.getPathSegments().get(ID_PATH_SEGMENT); - String directory = getDirectory(storageType); - File file = new File(getOrCreateCacheDirectory(context, directory), buildFileName(id)); - - return ModernDecryptingPartInputStream.createFor(AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), file, 0); - } - } else { - throw new IOException("Provided URI does not match this spec. Uri: " + uri); - } - } - - /** - * Delete the content with the specified URI. - */ - public synchronized void delete(@NonNull Context context, @NonNull Uri uri) { - if (!isAuthority(uri)) { - Log.d(TAG, "Can't delete. Not the authority for uri: " + uri); - return; - } - - try { - StorageType storageType = StorageType.decode(uri.getPathSegments().get(STORAGE_TYPE_PATH_SEGMENT)); - - if (storageType.isMemory()) { - memoryBlobs.remove(uri); - } else { - String id = uri.getPathSegments().get(ID_PATH_SEGMENT); - String directory = getDirectory(storageType); - File file = new File(getOrCreateCacheDirectory(context, directory), buildFileName(id)); - - if (!file.delete()) { - throw new IOException("File wasn't deleted."); - } - } - } catch (IOException e) { - Log.w(TAG, "Failed to delete uri: " + uri, e); - } - } - - /** - * Indicates a new app session has started, allowing old single-session blobs to be deleted. - */ - public synchronized void onSessionStart(@NonNull Context context) { - File directory = getOrCreateCacheDirectory(context, SINGLE_SESSION_DIRECTORY); - for (File file : directory.listFiles()) { - file.delete(); - } - } - - public static @Nullable String getMimeType(@NonNull Uri uri) { - if (isAuthority(uri)) { - return uri.getPathSegments().get(MIMETYPE_PATH_SEGMENT); - } - return null; - } - - public static @Nullable String getFileName(@NonNull Uri uri) { - if (isAuthority(uri)) { - return uri.getPathSegments().get(FILENAME_PATH_SEGMENT); - } - return null; - } - - public static @Nullable Long getFileSize(@NonNull Uri uri) { - if (isAuthority(uri)) { - try { - return Long.parseLong(uri.getPathSegments().get(FILESIZE_PATH_SEGMENT)); - } catch (NumberFormatException e) { - return null; - } - } - return null; - } - - public static boolean isAuthority(@NonNull Uri uri) { - return URI_MATCHER.match(uri) == MATCH; - } - - @WorkerThread - private synchronized @NonNull Uri writeBlobSpecToDisk(@NonNull Context context, @NonNull BlobSpec blobSpec, @Nullable ErrorListener errorListener) throws IOException { - AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); - String directory = getDirectory(blobSpec.getStorageType()); - File outputFile = new File(getOrCreateCacheDirectory(context, directory), buildFileName(blobSpec.id)); - OutputStream outputStream = ModernEncryptingPartOutputStream.createFor(attachmentSecret, outputFile, true).second; - - SignalExecutors.UNBOUNDED.execute(() -> { - try { - Util.copy(blobSpec.getData(), outputStream); - } catch (IOException e) { - if (errorListener != null) { - errorListener.onError(e); - } - } - }); - - return buildUri(blobSpec); - } - - private synchronized @NonNull Uri writeBlobSpecToMemory(@NonNull BlobSpec blobSpec, @NonNull byte[] data) { - Uri uri = buildUri(blobSpec); - memoryBlobs.put(uri, data); - return uri; - } - - private static @NonNull String buildFileName(@NonNull String id) { - return id + ".blob"; - } - - private static @NonNull String getDirectory(@NonNull StorageType storageType) { - return storageType == StorageType.MULTI_SESSION_DISK ? MULTI_SESSION_DIRECTORY : SINGLE_SESSION_DIRECTORY; - } - - private static @NonNull Uri buildUri(@NonNull BlobSpec blobSpec) { - return CONTENT_URI.buildUpon() - .appendPath(blobSpec.getStorageType().encode()) - .appendPath(blobSpec.getMimeType()) - .appendPath(blobSpec.getFileName()) - .appendEncodedPath(String.valueOf(blobSpec.getFileSize())) - .appendPath(blobSpec.getId()) - .build(); - } - - private static File getOrCreateCacheDirectory(@NonNull Context context, @NonNull String directory) { - File file = new File(context.getCacheDir(), directory); - if (!file.exists()) { - file.mkdir(); - } - - return file; - } - - public class BlobBuilder { - - private InputStream data; - private String id; - private String mimeType; - private String fileName; - private long fileSize; - - private BlobBuilder(@NonNull InputStream data, long fileSize) { - this.id = UUID.randomUUID().toString(); - this.data = data; - this.fileSize = fileSize; - } - - public BlobBuilder withMimeType(@NonNull String mimeType) { - this.mimeType = mimeType; - return this; - } - - public BlobBuilder withFileName(@NonNull String fileName) { - this.fileName = fileName; - return this; - } - - protected BlobSpec buildBlobSpec(@NonNull StorageType storageType) { - return new BlobSpec(data, id, storageType, mimeType, fileName, fileSize); - } - - /** - * Create a blob that will exist for a single app session. An app session is defined as the - * period from one {@link Application#onCreate()} to the next. - */ - @WorkerThread - public Uri createForSingleSessionOnDisk(@NonNull Context context, @Nullable ErrorListener errorListener) throws IOException { - return writeBlobSpecToDisk(context, buildBlobSpec(StorageType.SINGLE_SESSION_DISK), errorListener); - } - - /** - * Create a blob that will exist for multiple app sessions. It is the caller's responsibility to - * eventually call {@link BlobProvider#delete(Context, Uri)} when the blob is no longer in use. - */ - @WorkerThread - public Uri createForMultipleSessionsOnDisk(@NonNull Context context, @Nullable ErrorListener errorListener) throws IOException { - return writeBlobSpecToDisk(context, buildBlobSpec(StorageType.MULTI_SESSION_DISK), errorListener); - } - } - - public class MemoryBlobBuilder extends BlobBuilder { - - private byte[] data; - - private MemoryBlobBuilder(@NonNull byte[] data) { - super(new ByteArrayInputStream(data), data.length); - this.data = data; - } - - @Override - public MemoryBlobBuilder withMimeType(@NonNull String mimeType) { - super.withMimeType(mimeType); - return this; - } - - @Override - public MemoryBlobBuilder withFileName(@NonNull String fileName) { - super.withFileName(fileName); - return this; - } - - /** - * Create a blob that is stored in memory and can only be read a single time. After a single - * read, it will be removed from storage. Useful for when a Uri is needed to read transient data. - */ - public Uri createForSingleUseInMemory() { - return writeBlobSpecToMemory(buildBlobSpec(StorageType.SINGLE_USE_MEMORY), data); - } - - /** - * Create a blob that is stored in memory. Will persist for a single app session. You should - * always try to call {@link BlobProvider#delete(Context, Uri)} after you're done with the blob - * to free up memory. - */ - public Uri createForSingleSessionInMemory() { - return writeBlobSpecToMemory(buildBlobSpec(StorageType.SINGLE_SESSION_MEMORY), data); - } - } - - public interface ErrorListener { - @WorkerThread - void onError(IOException e); - } - - private static class BlobSpec { - - private final InputStream data; - private final String id; - private final StorageType storageType; - private final String mimeType; - private final String fileName; - private final long fileSize; - - private BlobSpec(@NonNull InputStream data, - @NonNull String id, - @NonNull StorageType storageType, - @NonNull String mimeType, - @Nullable String fileName, - @IntRange(from = 0) long fileSize) - { - this.data = data; - this.id = id; - this.storageType = storageType; - this.mimeType = mimeType; - this.fileName = fileName; - this.fileSize = fileSize; - } - - private @NonNull InputStream getData() { - return data; - } - - private @NonNull String getId() { - return id; - } - - private @NonNull StorageType getStorageType() { - return storageType; - } - - private @NonNull String getMimeType() { - return mimeType; - } - - private @Nullable String getFileName() { - return fileName; - } - - private long getFileSize() { - return fileSize; - } - } - - private enum StorageType { - - SINGLE_USE_MEMORY("single-use-memory", true), - SINGLE_SESSION_MEMORY("single-session-memory", true), - SINGLE_SESSION_DISK("single-session-disk", false), - MULTI_SESSION_DISK("multi-session-disk", false); - - private final String encoded; - private final boolean inMemory; - - StorageType(String encoded, boolean inMemory) { - this.encoded = encoded; - this.inMemory = inMemory; - } - - private String encode() { - return encoded; - } - - private boolean isMemory() { - return inMemory; - } - - private static StorageType decode(@NonNull String encoded) throws IOException { - for (StorageType storageType : StorageType.values()) { - if (storageType.encoded.equals(encoded)) { - return storageType; - } - } - throw new IOException("Failed to decode lifespan."); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobUtils.java b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobUtils.java new file mode 100644 index 0000000000..4b6a2e3079 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobUtils.java @@ -0,0 +1,415 @@ +package org.thoughtcrime.securesms.providers; + +import android.app.Application; +import android.content.Context; +import android.content.UriMatcher; +import android.net.Uri; + +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +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.thoughtcrime.securesms.crypto.AttachmentSecret; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; +import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import network.loki.messenger.BuildConfig; + +/** + * Allows for the creation and retrieval of blobs. + */ +public class BlobUtils { + + private static final String TAG = BlobUtils.class.getSimpleName(); + + private static final String MULTI_SESSION_DIRECTORY = "multi_session_blobs"; + private static final String SINGLE_SESSION_DIRECTORY = "single_session_blobs"; + + public static final Uri CONTENT_URI = Uri.parse("content://network.loki.provider.securesms" + BuildConfig.AUTHORITY_POSTFIX + "/blob"); + public static final String AUTHORITY = "network.loki.provider.securesms" + BuildConfig.AUTHORITY_POSTFIX; + public static final String PATH = "blob/*/*/*/*/*"; + + private static final int STORAGE_TYPE_PATH_SEGMENT = 1; + private static final int MIMETYPE_PATH_SEGMENT = 2; + private static final int FILENAME_PATH_SEGMENT = 3; + private static final int FILESIZE_PATH_SEGMENT = 4; + private static final int ID_PATH_SEGMENT = 5; + + private static final int MATCH = 1; + private static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH) {{ + addURI(AUTHORITY, PATH, MATCH); + }}; + + private static final BlobUtils INSTANCE = new BlobUtils(); + + private final Map memoryBlobs = new HashMap<>(); + + + public static BlobUtils getInstance() { + return INSTANCE; + } + + /** + * Begin building a blob for the provided data. Allows for the creation of in-memory blobs. + */ + public MemoryBlobBuilder forData(@NonNull byte[] data) { + return new MemoryBlobBuilder(data); + } + + /** + * Begin building a blob for the provided input stream. + */ + public BlobBuilder forData(@NonNull InputStream data, long fileSize) { + return new BlobBuilder(data, fileSize); + } + + /** + * Retrieve a stream for the content with the specified URI. + * @throws IOException If the stream fails to open or the spec of the URI doesn't match. + */ + public synchronized @NonNull InputStream getStream(@NonNull Context context, @NonNull Uri uri) throws IOException { + if (isAuthority(uri)) { + StorageType storageType = StorageType.decode(uri.getPathSegments().get(STORAGE_TYPE_PATH_SEGMENT)); + + if (storageType.isMemory()) { + byte[] data = memoryBlobs.get(uri); + + if (data != null) { + if (storageType == StorageType.SINGLE_USE_MEMORY) { + memoryBlobs.remove(uri); + } + return new ByteArrayInputStream(data); + } else { + throw new IOException("Failed to find in-memory blob for: " + uri); + } + } else { + String id = uri.getPathSegments().get(ID_PATH_SEGMENT); + String directory = getDirectory(storageType); + File file = new File(getOrCreateCacheDirectory(context, directory), buildFileName(id)); + + return ModernDecryptingPartInputStream.createFor(AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), file, 0); + } + } else { + throw new IOException("Provided URI does not match this spec. Uri: " + uri); + } + } + + /** + * Delete the content with the specified URI. + */ + public synchronized void delete(@NonNull Context context, @NonNull Uri uri) { + if (!isAuthority(uri)) { + Log.d(TAG, "Can't delete. Not the authority for uri: " + uri); + return; + } + + try { + StorageType storageType = StorageType.decode(uri.getPathSegments().get(STORAGE_TYPE_PATH_SEGMENT)); + + if (storageType.isMemory()) { + memoryBlobs.remove(uri); + } else { + String id = uri.getPathSegments().get(ID_PATH_SEGMENT); + String directory = getDirectory(storageType); + File file = new File(getOrCreateCacheDirectory(context, directory), buildFileName(id)); + + if (!file.delete()) { + throw new IOException("File wasn't deleted."); + } + } + } catch (IOException e) { + Log.w(TAG, "Failed to delete uri: " + uri, e); + } + } + + /** + * Indicates a new app session has started, allowing old single-session blobs to be deleted. + */ + public synchronized void onSessionStart(@NonNull Context context) { + File directory = getOrCreateCacheDirectory(context, SINGLE_SESSION_DIRECTORY); + File[] files = directory.listFiles(); + if (files == null) { + return; + } + + for (File file : files) { + file.delete(); + } + } + + public static @Nullable String getMimeType(@NonNull Uri uri) { + if (isAuthority(uri)) { + return uri.getPathSegments().get(MIMETYPE_PATH_SEGMENT); + } + return null; + } + + public static @Nullable String getFileName(@NonNull Uri uri) { + if (isAuthority(uri)) { + return uri.getPathSegments().get(FILENAME_PATH_SEGMENT); + } + return null; + } + + public static @Nullable Long getFileSize(@NonNull Uri uri) { + if (isAuthority(uri)) { + try { + return Long.parseLong(uri.getPathSegments().get(FILESIZE_PATH_SEGMENT)); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + public static boolean isAuthority(@NonNull Uri uri) { + return URI_MATCHER.match(uri) == MATCH; + } + + + @WorkerThread + @NonNull + private static CompletableFuture writeBlobSpecToDisk(@NonNull Context context, @NonNull BlobSpec blobSpec, @Nullable ErrorListener errorListener) throws IOException { + AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); + String directory = getDirectory(blobSpec.getStorageType()); + File outputFile = new File(getOrCreateCacheDirectory(context, directory), buildFileName(blobSpec.id)); + OutputStream outputStream = ModernEncryptingPartOutputStream.createFor(attachmentSecret, outputFile, true).second; + + final Uri uri = buildUri(blobSpec); + + return CompletableFuture.supplyAsync(() -> { + try { + Util.copy(blobSpec.getData(), outputStream); + return uri; + } catch (IOException e) { + if (errorListener != null) { + errorListener.onError(e); + } + + throw new RuntimeException(e); + } + }, SignalExecutors.UNBOUNDED); + } + + private synchronized @NonNull Uri writeBlobSpecToMemory(@NonNull BlobSpec blobSpec, @NonNull byte[] data) { + Uri uri = buildUri(blobSpec); + memoryBlobs.put(uri, data); + return uri; + } + + private static @NonNull String buildFileName(@NonNull String id) { + return id + ".blob"; + } + + private static @NonNull String getDirectory(@NonNull StorageType storageType) { + return storageType == StorageType.MULTI_SESSION_DISK ? MULTI_SESSION_DIRECTORY : SINGLE_SESSION_DIRECTORY; + } + + private static @NonNull Uri buildUri(@NonNull BlobSpec blobSpec) { + return CONTENT_URI.buildUpon() + .appendPath(blobSpec.getStorageType().encode()) + .appendPath(blobSpec.getMimeType()) + .appendPath(blobSpec.getFileName()) + .appendEncodedPath(String.valueOf(blobSpec.getFileSize())) + .appendPath(blobSpec.getId()) + .build(); + } + + private static File getOrCreateCacheDirectory(@NonNull Context context, @NonNull String directory) { + File file = new File(context.getCacheDir(), directory); + if (!file.exists()) { + file.mkdir(); + } + + return file; + } + + public class BlobBuilder { + + private InputStream data; + private String id; + private String mimeType; + private String fileName; + private long fileSize; + + private BlobBuilder(@NonNull InputStream data, long fileSize) { + this.id = UUID.randomUUID().toString(); + this.data = data; + this.fileSize = fileSize; + } + + public BlobBuilder withMimeType(@NonNull String mimeType) { + this.mimeType = mimeType; + return this; + } + + public BlobBuilder withFileName(@NonNull String fileName) { + this.fileName = fileName; + return this; + } + + protected BlobSpec buildBlobSpec(@NonNull StorageType storageType) { + return new BlobSpec(data, id, storageType, mimeType, fileName, fileSize); + } + + + /** + * Create a blob that will exist for a single app session. An app session is defined as the + * period from one {@link Application#onCreate()} to the next. + */ + @WorkerThread + public CompletableFuture createForSingleSessionOnDisk(@NonNull Context context, @Nullable ErrorListener errorListener) throws IOException { + return writeBlobSpecToDisk(context, buildBlobSpec(StorageType.SINGLE_SESSION_DISK), errorListener); + } + + /** + * Create a blob that will exist for multiple app sessions. It is the caller's responsibility to + * eventually call {@link BlobUtils#delete(Context, Uri)} when the blob is no longer in use. + */ + @WorkerThread + public CompletableFuture createForMultipleSessionsOnDisk(@NonNull Context context, @Nullable ErrorListener errorListener) throws IOException { + return writeBlobSpecToDisk(context, buildBlobSpec(StorageType.MULTI_SESSION_DISK), errorListener); + } + } + + public class MemoryBlobBuilder extends BlobBuilder { + + private byte[] data; + + private MemoryBlobBuilder(@NonNull byte[] data) { + super(new ByteArrayInputStream(data), data.length); + this.data = data; + } + + @Override + public MemoryBlobBuilder withMimeType(@NonNull String mimeType) { + super.withMimeType(mimeType); + return this; + } + + @Override + public MemoryBlobBuilder withFileName(@NonNull String fileName) { + super.withFileName(fileName); + return this; + } + + /** + * Create a blob that is stored in memory and can only be read a single time. After a single + * read, it will be removed from storage. Useful for when a Uri is needed to read transient data. + */ + public Uri createForSingleUseInMemory() { + return writeBlobSpecToMemory(buildBlobSpec(StorageType.SINGLE_USE_MEMORY), data); + } + + /** + * Create a blob that is stored in memory. Will persist for a single app session. You should + * always try to call {@link BlobUtils#delete(Context, Uri)} after you're done with the blob + * to free up memory. + */ + public Uri createForSingleSessionInMemory() { + return writeBlobSpecToMemory(buildBlobSpec(StorageType.SINGLE_SESSION_MEMORY), data); + } + } + + public interface ErrorListener { + @WorkerThread + void onError(IOException e); + } + + private static class BlobSpec { + + private final InputStream data; + private final String id; + private final StorageType storageType; + private final String mimeType; + private final String fileName; + private final long fileSize; + + private BlobSpec(@NonNull InputStream data, + @NonNull String id, + @NonNull StorageType storageType, + @NonNull String mimeType, + @Nullable String fileName, + @IntRange(from = 0) long fileSize) + { + this.data = data; + this.id = id; + this.storageType = storageType; + this.mimeType = mimeType; + this.fileName = fileName; + this.fileSize = fileSize; + } + + private @NonNull InputStream getData() { + return data; + } + + private @NonNull String getId() { + return id; + } + + private @NonNull StorageType getStorageType() { + return storageType; + } + + private @NonNull String getMimeType() { + return mimeType; + } + + private @Nullable String getFileName() { + return fileName; + } + + private long getFileSize() { + return fileSize; + } + } + + private enum StorageType { + + SINGLE_USE_MEMORY("single-use-memory", true), + SINGLE_SESSION_MEMORY("single-session-memory", true), + SINGLE_SESSION_DISK("single-session-disk", false), + MULTI_SESSION_DISK("multi-session-disk", false); + + private final String encoded; + private final boolean inMemory; + + StorageType(String encoded, boolean inMemory) { + this.encoded = encoded; + this.inMemory = inMemory; + } + + private String encode() { + return encoded; + } + + private boolean isMemory() { + return inMemory; + } + + private static StorageType decode(@NonNull String encoded) throws IOException { + for (StorageType storageType : StorageType.values()) { + if (storageType.encoded.equals(encoded)) { + return storageType; + } + } + throw new IOException("Failed to decode lifespan."); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/PartAndBlobProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/PartAndBlobProvider.java new file mode 100644 index 0000000000..d608b9d0b1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/PartAndBlobProvider.java @@ -0,0 +1,239 @@ +/* + * Copyright (C) 2011 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 . + */ +package org.thoughtcrime.securesms.providers; + +import android.content.ContentProvider; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.OpenableColumns; + +import androidx.annotation.NonNull; + +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId; +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment; +import org.session.libsession.utilities.Util; +import org.session.libsignal.utilities.Log; +import org.thoughtcrime.securesms.dependencies.DatabaseComponent; +import org.thoughtcrime.securesms.mms.PartUriParser; +import org.thoughtcrime.securesms.service.KeyCachingService; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import network.loki.messenger.BuildConfig; + +public class PartAndBlobProvider extends ContentProvider { + + private static final String TAG = PartAndBlobProvider.class.getSimpleName(); + + private static final String CONTENT_URI_STRING = "content://network.loki.provider.securesms" + BuildConfig.AUTHORITY_POSTFIX + "/part"; + private static final Uri CONTENT_URI = Uri.parse(CONTENT_URI_STRING); + private static final int SINGLE_ROW = 1; + private static final int BLOB_ROW = 2; // New constant for blob URIs + + private static final UriMatcher uriMatcher; + + static { + uriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + uriMatcher.addURI("network.loki.provider.securesms" + BuildConfig.AUTHORITY_POSTFIX, "part/*/#", SINGLE_ROW); + uriMatcher.addURI("network.loki.provider.securesms" + BuildConfig.AUTHORITY_POSTFIX, "blob/*/*/*/*/*", BLOB_ROW); // Add blob pattern + } + + @Override + public boolean onCreate() { + Log.i(TAG, "onCreate()"); + return true; + } + + public static Uri getContentUri(AttachmentId attachmentId) { + Uri uri = Uri.withAppendedPath(CONTENT_URI, String.valueOf(attachmentId.getUniqueId())); + return ContentUris.withAppendedId(uri, attachmentId.getRowId()); + } + + @Override + public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException { + Log.i(TAG, "openFile() called for URI: " + uri); + + if (KeyCachingService.isLocked(getContext())) { + Log.w(TAG, "masterSecret was null, abandoning."); + return null; + } + + switch (uriMatcher.match(uri)) { + case SINGLE_ROW: + Log.i(TAG, "Parting out a single row..."); + try { + final PartUriParser partUri = new PartUriParser(uri); + return getParcelStreamForAttachment(partUri.getPartId()); + } catch (IOException ioe) { + Log.w(TAG, ioe); + throw new FileNotFoundException("Error opening file"); + } + + case BLOB_ROW: + Log.i(TAG, "Handling blob URI..."); + try { + return getParcelStreamForBlob(uri); + } catch (IOException ioe) { + Log.w(TAG, "Error opening blob file", ioe); + throw new FileNotFoundException("Error opening blob file"); + } + } + + throw new FileNotFoundException("Request for bad part."); + } + + @Override + public int delete(@NonNull Uri arg0, String arg1, String[] arg2) { + Log.i(TAG, "delete() called"); + return 0; + } + + @Override + public String getType(@NonNull Uri uri) { + Log.i(TAG, "getType() called: " + uri); + + switch (uriMatcher.match(uri)) { + case SINGLE_ROW: + PartUriParser partUriParser = new PartUriParser(uri); + DatabaseAttachment attachment = DatabaseComponent.get(getContext()).attachmentDatabase() + .getAttachment(partUriParser.getPartId()); + + if (attachment != null) { + return attachment.getContentType(); + } + break; + + case BLOB_ROW: + // For blob URIs, get the mime type from the BlobProvider + return BlobUtils.getMimeType(uri); + } + + return null; + } + + @Override + public Uri insert(@NonNull Uri arg0, ContentValues arg1) { + Log.i(TAG, "insert() called"); + return null; + } + + @Override + public Cursor query(@NonNull Uri url, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + Log.i(TAG, "query() called: " + url); + + if (projection == null || projection.length <= 0) return null; + + switch (uriMatcher.match(url)) { + case SINGLE_ROW: + PartUriParser partUri = new PartUriParser(url); + DatabaseAttachment attachment = DatabaseComponent.get(getContext()).attachmentDatabase().getAttachment(partUri.getPartId()); + + if (attachment == null) return null; + + MatrixCursor matrixCursor = new MatrixCursor(projection, 1); + Object[] resultRow = new Object[projection.length]; + + for (int i = 0; i < projection.length; i++) { + if (OpenableColumns.DISPLAY_NAME.equals(projection[i])) { + resultRow[i] = attachment.getFilename(); + } + } + + matrixCursor.addRow(resultRow); + return matrixCursor; + + case BLOB_ROW: + // For blob URIs, create a cursor with blob information + MatrixCursor blobCursor = new MatrixCursor(projection, 1); + Object[] blobRow = new Object[projection.length]; + + for (int i = 0; i < projection.length; i++) { + if (OpenableColumns.DISPLAY_NAME.equals(projection[i])) { + blobRow[i] = BlobUtils.getFileName(url); + } else if (OpenableColumns.SIZE.equals(projection[i])) { + blobRow[i] = BlobUtils.getFileSize(url); + } + } + + blobCursor.addRow(blobRow); + return blobCursor; + } + + return null; + } + + @Override + public int update(@NonNull Uri arg0, ContentValues arg1, String arg2, String[] arg3) { + Log.i(TAG, "update() called"); + return 0; + } + + private ParcelFileDescriptor getParcelStreamForAttachment(AttachmentId attachmentId) throws IOException { + return getStreamingParcelFileDescriptor(() -> + DatabaseComponent.get(getContext()).attachmentDatabase().getAttachmentStream(attachmentId, 0) + ); + } + + private ParcelFileDescriptor getStreamingParcelFileDescriptor(InputStreamProvider provider) throws IOException { + try { + ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); + ParcelFileDescriptor readSide = pipe[0]; + ParcelFileDescriptor writeSide = pipe[1]; + + new Thread(() -> { + try (InputStream inputStream = provider.getInputStream(); + OutputStream outputStream = new ParcelFileDescriptor.AutoCloseOutputStream(writeSide)) { + + Util.copy(inputStream, outputStream); + + } catch (IOException e) { + Log.w(TAG, "Error streaming data", e); + try { + writeSide.closeWithError("Error streaming data: " + e.getMessage()); + } catch (IOException closeException) { + Log.w(TAG, "Error closing write side of pipe", closeException); + } + } + }).start(); + + return readSide; + + } catch (IOException e) { + Log.w(TAG, "Error creating streaming pipe", e); + throw new FileNotFoundException("Error creating streaming pipe: " + e.getMessage()); + } + } + + private interface InputStreamProvider { + InputStream getInputStream() throws IOException; + } + + private ParcelFileDescriptor getParcelStreamForBlob(Uri uri) throws IOException { + // Always use streaming for blobs since they're often shared media files that can be large + return getStreamingParcelFileDescriptor(() -> + BlobUtils.getInstance().getStream(getContext(), uri) + ); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/PartProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/PartProvider.java deleted file mode 100644 index 8c65182f57..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/providers/PartProvider.java +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright (C) 2011 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 . - */ -package org.thoughtcrime.securesms.providers; - -import android.content.ContentProvider; -import android.content.ContentUris; -import android.content.ContentValues; -import android.content.UriMatcher; -import android.database.Cursor; -import android.database.MatrixCursor; -import android.net.Uri; -import android.os.MemoryFile; -import android.os.ParcelFileDescriptor; -import android.provider.OpenableColumns; - -import androidx.annotation.NonNull; - -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId; -import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment; -import org.session.libsession.utilities.Util; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.mms.PartUriParser; -import org.thoughtcrime.securesms.service.KeyCachingService; -import org.thoughtcrime.securesms.util.MemoryFileUtil; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; - -public class PartProvider extends ContentProvider { - - private static final String TAG = PartProvider.class.getSimpleName(); - - private static final String CONTENT_URI_STRING = "content://network.loki.provider.securesms/part"; - private static final Uri CONTENT_URI = Uri.parse(CONTENT_URI_STRING); - private static final int SINGLE_ROW = 1; - - private static final UriMatcher uriMatcher; - - static { - uriMatcher = new UriMatcher(UriMatcher.NO_MATCH); - uriMatcher.addURI("network.loki.provider.securesms", "part/*/#", SINGLE_ROW); - } - - @Override - public boolean onCreate() { - Log.i(TAG, "onCreate()"); - return true; - } - - public static Uri getContentUri(AttachmentId attachmentId) { - Uri uri = Uri.withAppendedPath(CONTENT_URI, String.valueOf(attachmentId.getUniqueId())); - return ContentUris.withAppendedId(uri, attachmentId.getRowId()); - } - - @Override - public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException { - Log.i(TAG, "openFile() called!"); - - if (KeyCachingService.isLocked(getContext())) { - Log.w(TAG, "masterSecret was null, abandoning."); - return null; - } - - switch (uriMatcher.match(uri)) { - case SINGLE_ROW: - Log.i(TAG, "Parting out a single row..."); - try { - final PartUriParser partUri = new PartUriParser(uri); - return getParcelStreamForAttachment(partUri.getPartId()); - } catch (IOException ioe) { - Log.w(TAG, ioe); - throw new FileNotFoundException("Error opening file"); - } - } - - throw new FileNotFoundException("Request for bad part."); - } - - @Override - public int delete(@NonNull Uri arg0, String arg1, String[] arg2) { - Log.i(TAG, "delete() called"); - return 0; - } - - @Override - public String getType(@NonNull Uri uri) { - Log.i(TAG, "getType() called: " + uri); - - switch (uriMatcher.match(uri)) { - case SINGLE_ROW: - PartUriParser partUriParser = new PartUriParser(uri); - DatabaseAttachment attachment = DatabaseComponent.get(getContext()).attachmentDatabase() - .getAttachment(partUriParser.getPartId()); - - if (attachment != null) { - return attachment.getContentType(); - } - } - - return null; - } - - @Override - public Uri insert(@NonNull Uri arg0, ContentValues arg1) { - Log.i(TAG, "insert() called"); - return null; - } - - @Override - public Cursor query(@NonNull Uri url, String[] projection, String selection, String[] selectionArgs, String sortOrder) { - Log.i(TAG, "query() called: " + url); - - if (projection == null || projection.length <= 0) return null; - - switch (uriMatcher.match(url)) { - case SINGLE_ROW: - PartUriParser partUri = new PartUriParser(url); - DatabaseAttachment attachment = DatabaseComponent.get(getContext()).attachmentDatabase().getAttachment(partUri.getPartId()); - - if (attachment == null) return null; - - MatrixCursor matrixCursor = new MatrixCursor(projection, 1); - Object[] resultRow = new Object[projection.length]; - - for (int i=0;i { private static final int MAX_REACTORS = 5; @@ -152,8 +152,11 @@ public RecipientViewHolder(ReactionViewPagerAdapter.Listener callback, @NonNull void bind(@NonNull ReactionDetails reaction) { this.remove.setOnClickListener((v) -> { - MessageId messageId = new MessageId(reaction.getLocalId(), reaction.isMms()); - callback.onRemoveReaction(reaction.getBaseEmoji(), messageId, reaction.getTimestamp()); + callback.onRemoveReaction(reaction.getBaseEmoji(), reaction.getLocalId(), reaction.getTimestamp()); + }); + + itemView.setOnClickListener((v) -> { + callback.onEmojiReactionUserTapped(reaction.getSender()); }); this.avatar.update(reaction.getSender()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionViewPagerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionViewPagerAdapter.java index 2ef3c4ebfb..ec6e8ec9ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionViewPagerAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionViewPagerAdapter.java @@ -10,6 +10,7 @@ import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; +import org.session.libsession.utilities.recipients.Recipient; import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.util.ContextUtil; import org.thoughtcrime.securesms.util.adapter.AlwaysChangedDiffUtil; @@ -119,6 +120,8 @@ public void setSelected(int position) { public interface Listener { void onRemoveReaction(@NonNull String emoji, @NonNull MessageId messageId, long timestamp); + void onEmojiReactionUserTapped(@NonNull Recipient recipient); + void onClearAll(@NonNull String emoji, @NonNull MessageId messageId); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsDialogFragment.java index e9da05212b..ba30b36edf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsDialogFragment.java @@ -20,6 +20,7 @@ import com.google.android.material.tabs.TabLayout; import com.google.android.material.tabs.TabLayoutMediator; +import org.session.libsession.utilities.recipients.Recipient; import org.thoughtcrime.securesms.components.emoji.EmojiImageView; import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.util.LifecycleDisposable; @@ -181,9 +182,16 @@ public void onClearAll(@NonNull String emoji, @NonNull MessageId messageId) { dismiss(); } + @Override + public void onEmojiReactionUserTapped(@NonNull Recipient recipient) { + callback.onEmojiReactionUserTapped(recipient); + } + public interface Callback { void onRemoveReaction(@NonNull String emoji, @NonNull MessageId messageId); + void onEmojiReactionUserTapped(@NonNull Recipient recipient); + void onClearAll(@NonNull String emoji, @NonNull MessageId messageId); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsRepository.kt index 8687a0f9bb..b48ab5ad59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsRepository.kt @@ -31,7 +31,6 @@ class ReactionsRepository { timestamp = reaction.dateReceived, serverId = reaction.serverId, localId = reaction.messageId, - isMms = reaction.isMms, count = reaction.count.toInt() ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt index 56ca84efee..7889463fd8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPassword.kt @@ -3,12 +3,12 @@ package org.thoughtcrime.securesms.recoverypassword import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -28,14 +28,14 @@ import androidx.compose.ui.unit.dp import network.loki.messenger.R import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.Cell -import org.thoughtcrime.securesms.ui.DialogButtonModel +import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.SessionShieldIcon +import org.thoughtcrime.securesms.ui.border import org.thoughtcrime.securesms.ui.components.QrImage import org.thoughtcrime.securesms.ui.components.SlimOutlineButton import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton -import org.thoughtcrime.securesms.ui.components.border -import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -54,7 +54,7 @@ internal fun RecoveryPasswordScreen( Column( verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing), modifier = Modifier - .contentDescription(R.string.AccessibilityId_sessionRecoveryPassword) + .qaTag(R.string.AccessibilityId_sessionRecoveryPassword) .verticalScroll(rememberScrollState()) .padding(bottom = LocalDimensions.current.smallSpacing) .padding(horizontal = LocalDimensions.current.spacing) @@ -84,7 +84,9 @@ private fun RecoveryPasswordCell( style = LocalType.current.h8 ) Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) - SessionShieldIcon() + SessionShieldIcon( + modifier = Modifier.align(Alignment.CenterVertically) + ) } Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) @@ -106,9 +108,8 @@ private fun RecoveryPasswordCell( seed, modifier = Modifier .padding(vertical = LocalDimensions.current.spacing) - .contentDescription(R.string.AccessibilityId_qrCode), - contentPadding = 10.dp, - icon = R.drawable.session_shield + .qaTag(R.string.AccessibilityId_qrCode), + icon = R.drawable.ic_recovery_password_custom ) } @@ -143,13 +144,13 @@ private fun RecoveryPassword(mnemonic: String) { Text( mnemonic, modifier = Modifier - .contentDescription(R.string.AccessibilityId_sessionRecoveryPasswordContainer) + .qaTag(R.string.AccessibilityId_sessionRecoveryPasswordContainer) .padding(vertical = LocalDimensions.current.spacing) .border() .padding(LocalDimensions.current.spacing), textAlign = TextAlign.Center, style = LocalType.current.extraSmall.monospace(), - color = LocalColors.current.run { if (isLight) text else primary }, + color = LocalColors.current.run { if (isLight) text else accent }, ) } @@ -162,7 +163,8 @@ private fun HideRecoveryPasswordCell( Cell { Row( - modifier = Modifier.padding(LocalDimensions.current.smallSpacing) + modifier = Modifier.padding(LocalDimensions.current.smallSpacing), + verticalAlignment = Alignment.CenterVertically ) { Column( Modifier.weight(1f) @@ -180,9 +182,8 @@ private fun HideRecoveryPasswordCell( SlimOutlineButton( text = stringResource(R.string.hide), modifier = Modifier - .wrapContentWidth() - .align(Alignment.CenterVertically) - .contentDescription(R.string.AccessibilityId_recoveryPasswordHideRecoveryPassword), + .widthIn(min = LocalDimensions.current.minSmallButtonWidth) + .qaTag(R.string.AccessibilityId_recoveryPasswordHideRecoveryPassword), color = LocalColors.current.danger, onClick = { showHideRecoveryDialog = true } ) @@ -196,12 +197,12 @@ private fun HideRecoveryPasswordCell( title = stringResource(R.string.recoveryPasswordHidePermanently), text = stringResource(R.string.recoveryPasswordHidePermanentlyDescription1), buttons = listOf( - DialogButtonModel( + DialogButtonData( GetString(R.string.theContinue), color = LocalColors.current.danger, onClick = { showHideRecoveryConfirmationDialog = true } ), - DialogButtonModel(GetString(android.R.string.cancel)) + DialogButtonData(GetString(android.R.string.cancel)) ) ) } @@ -213,12 +214,12 @@ private fun HideRecoveryPasswordCell( title = stringResource(R.string.recoveryPasswordHidePermanently), text = stringResource(R.string.recoveryPasswordHidePermanentlyDescription2), buttons = listOf( - DialogButtonModel( + DialogButtonData( GetString(R.string.yes), color = LocalColors.current.danger, onClick = confirmHideRecovery ), - DialogButtonModel(GetString(android.R.string.cancel)) + DialogButtonData(GetString(android.R.string.cancel)) ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt index cc9630ef57..7570317284 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordActivity.kt @@ -5,11 +5,14 @@ import android.os.Bundle import androidx.activity.viewModels import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import network.loki.messenger.R +import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.ui.setComposeContent - +@AndroidEntryPoint class RecoveryPasswordActivity : BaseActionBarActivity() { companion object { @@ -18,6 +21,8 @@ class RecoveryPasswordActivity : BaseActionBarActivity() { private val viewModel: RecoveryPasswordViewModel by viewModels() + @Inject lateinit var prefs: TextSecurePreferences + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) supportActionBar!!.title = resources.getString(R.string.sessionRecoveryPassword) @@ -30,13 +35,16 @@ class RecoveryPasswordActivity : BaseActionBarActivity() { mnemonic = mnemonic, seed = seed, confirmHideRecovery = { - val returnIntent = Intent() - returnIntent.putExtra(RESULT_RECOVERY_HIDDEN, true) - setResult(RESULT_OK, returnIntent) + prefs.setHidePassword(true) + finish() }, copyMnemonic = viewModel::copyMnemonic ) } + + // Set the seed as having been viewed when the user has seen this activity, which + // removes the reminder banner on the HomeActivity. + prefs.setHasViewedSeed(true) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordViewModel.kt index b159accf23..f97daa9319 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recoverypassword/RecoveryPasswordViewModel.kt @@ -7,21 +7,19 @@ import android.content.Context import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.session.libsession.utilities.AppTextSecurePreferences -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.utilities.hexEncodedPrivateKey import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.MnemonicUtilities -import javax.inject.Inject @HiltViewModel class RecoveryPasswordViewModel @Inject constructor( @@ -31,12 +29,27 @@ class RecoveryPasswordViewModel @Inject constructor( val seed = MutableStateFlow(null) val mnemonic = seed.filterNotNull() - .map { MnemonicCodec { MnemonicUtilities.loadFileContents(application, it) }.encode(it, MnemonicCodec.Language.Configuration.english) } + .map { + MnemonicCodec { + MnemonicUtilities.loadFileContents(application, it) + } + .encode(it, MnemonicCodec.Language.Configuration.english) + .trim() // Remove any leading or trailing whitespace + } .stateIn(viewModelScope, SharingStarted.Eagerly, "") fun copyMnemonic() { prefs.setHasViewedSeed(true) - ClipData.newPlainText("Seed", mnemonic.value) + + // Ensure that our mnemonic words are separated by single spaces only without any excessive + // whitespace or control characters via: + // - Replacing all control chars (\p{Cc}) or Unicode separators (\p{Z}) with a single space, then + // - Trimming all leading & trailing spaces. + val normalisedMnemonic = mnemonic.value + .replace(Regex("[\\p{Cc}\\p{Z}]+"), " ") + .trim() + + ClipData.newPlainText("Seed", normalisedMnemonic) .let(application.clipboard::setPrimaryClip) } 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 157247a776..28c79a87aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -8,12 +8,12 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.withContext import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.userAuth import org.session.libsession.messaging.groups.GroupManagerV2 -import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.MarkAsDeletedMessage import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.control.UnsendRequest @@ -30,6 +30,7 @@ import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.DraftDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase @@ -40,9 +41,11 @@ 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.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.util.observeChanges import javax.inject.Inject interface ConversationRepository { @@ -53,13 +56,14 @@ interface ConversationRepository { fun saveDraft(threadId: Long, text: String) fun getDraft(threadId: Long): String? fun clearDrafts(threadId: Long) - fun inviteContacts(threadId: Long, contacts: List) - fun setBlocked(threadId: Long, recipient: Recipient, blocked: Boolean) + fun inviteContactsToCommunity(threadId: Long, contacts: List) + fun setBlocked(recipient: Recipient, blocked: Boolean) fun markAsDeletedLocally(messages: Set, displayedMessage: String) fun deleteMessages(messages: Set, threadId: Long) fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord) fun setApproved(recipient: Recipient, isApproved: Boolean) fun isGroupReadOnly(recipient: Recipient): Boolean + fun getLastSentMessageID(threadId: Long): Flow suspend fun deleteCommunityMessagesRemotely(threadId: Long, messages: Set) suspend fun delete1on1MessagesRemotely( @@ -88,6 +92,16 @@ interface ConversationRepository { suspend fun declineMessageRequest(threadId: Long, recipient: Recipient): Result fun hasReceived(threadId: Long): Boolean fun getInvitingAdmin(threadId: Long): Recipient? + + /** + * This will delete all messages from the database. + * If a groupId is passed along, and if the user is an admin of that group, + * this will also remove the messages from the swarm and update + * the delete_before flag for that group to now + * + * Returns the amount of deleted messages + */ + suspend fun clearAllMessages(threadId: Long, groupId: AccountId?): Int } class DefaultConversationRepository @Inject constructor( @@ -117,7 +131,7 @@ class DefaultConversationRepository @Inject constructor( if (!recipient.isCommunityInboxRecipient) return null return Recipient.from( context, - Address.fromSerialized(GroupUtil.getDecodedOpenGroupInboxAccountId(recipient.address.serialize())), + Address.fromSerialized(GroupUtil.getDecodedOpenGroupInboxAccountId(recipient.address.toString())), false ) } @@ -147,7 +161,7 @@ class DefaultConversationRepository @Inject constructor( draftDb.clearDrafts(threadId) } - override fun inviteContacts(threadId: Long, contacts: List) { + override fun inviteContactsToCommunity(threadId: Long, contacts: List) { val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return for (contact in contacts) { val message = VisibleMessage() @@ -157,7 +171,8 @@ class DefaultConversationRepository @Inject constructor( url = openGroup.joinURL } message.openGroupInvitation = openGroupInvitation - val expirationConfig = threadDb.getOrCreateThreadIdFor(contact).let(storage::getExpirationConfiguration) + val contactThreadId = threadDb.getOrCreateThreadIdFor(contact) + val expirationConfig = contactThreadId.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( @@ -167,7 +182,12 @@ class DefaultConversationRepository @Inject constructor( expiresInMillis, expireStartedAt ) - smsDb.insertMessageOutbox(-1, outgoingTextMessage, message.sentTimestamp!!, true) + + message.id = MessageId( + smsDb.insertMessageOutbox(contactThreadId, outgoingTextMessage, false, message.sentTimestamp!!, true), + false + ) + MessageSender.send(message, contact.address) } } @@ -178,14 +198,24 @@ class DefaultConversationRepository @Inject constructor( return false } - val groupId = recipient.address.serialize() + val groupId = recipient.address.toString() return configFactory.withUserConfigs { configs -> configs.userGroups.getClosedGroup(groupId)?.let { it.kicked || it.destroyed } == true } } + override fun getLastSentMessageID(threadId: Long): Flow { + return (contentResolver.observeChanges(DatabaseContentProviders.Conversation.getUriForThread(threadId)) as Flow<*>) + .onStart { emit(Unit) } + .map { + withContext(Dispatchers.Default) { + mmsSmsDb.getLastSentMessageID(threadId) + } + } + } + // This assumes that recipient.isContactRecipient is true - override fun setBlocked(threadId: Long, recipient: Recipient, blocked: Boolean) { + override fun setBlocked(recipient: Recipient, blocked: Boolean) { if (recipient.isContactRecipient) { storage.setBlocked(listOf(recipient), blocked) } @@ -218,11 +248,11 @@ class DefaultConversationRepository @Inject constructor( val (mms, sms) = messages.partition { it.isMms } if(mms.isNotEmpty()){ - messageDataProvider.markMessagesAsDeleted(mms.map { MarkAsDeletedMessage( - messageId = it.id, - isOutgoing = it.isOutgoing - ) }, - isSms = false, + messageDataProvider.markMessagesAsDeleted( + mms.map { MarkAsDeletedMessage( + messageId = it.messageId, + isOutgoing = it.isOutgoing + ) }, displayedMessage = displayedMessage ) @@ -231,11 +261,11 @@ class DefaultConversationRepository @Inject constructor( } if(sms.isNotEmpty()){ - messageDataProvider.markMessagesAsDeleted(sms.map { MarkAsDeletedMessage( - messageId = it.id, - isOutgoing = it.isOutgoing - ) }, - isSms = true, + messageDataProvider.markMessagesAsDeleted( + sms.map { MarkAsDeletedMessage( + messageId = it.messageId, + isOutgoing = it.isOutgoing + ) }, displayedMessage = displayedMessage ) @@ -249,7 +279,7 @@ class DefaultConversationRepository @Inject constructor( val senderId = messageRecord.recipient.address.contactIdentifier() val messageRecordsToRemoveFromLocalStorage = mmsSmsDb.getAllMessageRecordsFromSenderInThread(threadId, senderId) for (message in messageRecordsToRemoveFromLocalStorage) { - messageDataProvider.deleteMessage(message.id, !message.isMms) + messageDataProvider.deleteMessage(messageId = message.messageId) } } @@ -264,7 +294,7 @@ class DefaultConversationRepository @Inject constructor( val community = checkNotNull(lokiThreadDb.getOpenGroupChat(threadId)) { "Not a community" } messages.forEach { message -> - lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID -> + lokiMessageDb.getServerID(message.messageId)?.let { messageServerID -> OpenGroupApi.deleteMessage(messageServerID, community.room, community.server).await() } } @@ -276,7 +306,7 @@ class DefaultConversationRepository @Inject constructor( messages: Set ) { // delete the messages remotely - val publicKey = recipient.address.serialize() + val publicKey = recipient.address.toString() val userAddress: Address? = textSecurePreferences.getLocalNumber()?.let { Address.fromSerialized(it) } val userAuth = requireNotNull(storage.userAuth) { "User auth is required to delete messages remotely" @@ -284,7 +314,7 @@ class DefaultConversationRepository @Inject constructor( messages.forEach { message -> // delete from swarm - messageDataProvider.getServerHashForMessage(message.id, message.isMms) + messageDataProvider.getServerHashForMessage(message.messageId) ?.let { serverHash -> SnodeAPI.deleteMessage(publicKey, userAuth, listOf(serverHash)) } @@ -323,9 +353,9 @@ class DefaultConversationRepository @Inject constructor( ) { require(recipient.isGroupV2Recipient) { "Recipient is not a group v2 recipient" } - val groupId = AccountId(recipient.address.serialize()) + val groupId = AccountId(recipient.address.toString()) val hashes = messages.mapNotNullTo(mutableSetOf()) { msg -> - messageDataProvider.getServerHashForMessage(msg.id, msg.isMms) + messageDataProvider.getServerHashForMessage(msg.messageId) } groupManager.requestMessageDeletion(groupId, hashes) @@ -337,7 +367,7 @@ class DefaultConversationRepository @Inject constructor( messages: Set ) { // delete the messages remotely - val publicKey = recipient.address.serialize() + val publicKey = recipient.address.toString() val userAddress: Address? = textSecurePreferences.getLocalNumber()?.let { Address.fromSerialized(it) } val userAuth = requireNotNull(storage.userAuth) { "User auth is required to delete messages remotely" @@ -345,7 +375,7 @@ class DefaultConversationRepository @Inject constructor( messages.forEach { message -> // delete from swarm - messageDataProvider.getServerHashForMessage(message.id, message.isMms) + messageDataProvider.getServerHashForMessage(message.messageId) ?.let { serverHash -> SnodeAPI.deleteMessage(publicKey, userAuth, listOf(serverHash)) } @@ -357,10 +387,6 @@ class DefaultConversationRepository @Inject constructor( } } - private fun shouldSendUnsendRequest(recipient: Recipient): Boolean { - return recipient.is1on1 || recipient.isLegacyGroupRecipient - } - private fun buildUnsendRequest(message: MessageRecord): UnsendRequest { return UnsendRequest( author = message.takeUnless { it.isOutgoing }?.run { individualRecipient.address.contactIdentifier() } ?: textSecurePreferences.getLocalNumber(), @@ -399,28 +425,40 @@ class DefaultConversationRepository @Inject constructor( deleteMessageRequest(reader.current) val recipient = reader.current.recipient if (block && !recipient.isGroupV2Recipient) { - setBlocked(reader.current.threadId, recipient, true) + setBlocked(recipient, true) } } } } } + override suspend fun clearAllMessages(threadId: Long, groupId: AccountId?): Int { + return withContext(Dispatchers.Default) { + // delete data locally + val deletedHashes = storage.clearAllMessages(threadId) + Log.i("", "Cleared messages with hashes: $deletedHashes") + + // if required, also sync groupV2 data + if (groupId != null) { + groupManager.clearAllMessagesForEveryone(groupId, deletedHashes) + } + + deletedHashes.size + } + } + override suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient) = runCatching { withContext(Dispatchers.Default) { storage.setRecipientApproved(recipient, true) if (recipient.isGroupV2Recipient) { groupManager.respondToInvitation( - AccountId(recipient.address.serialize()), + AccountId(recipient.address.toString()), approved = true ) } else { val message = MessageRequestResponse(true) - MessageSender.sendNonDurably( - message = message, - destination = Destination.from(recipient.address), - isSyncMessage = recipient.isLocalNumber - ).await() + + MessageSender.send(message = message, address = recipient.address) // add a control message for our user storage.insertMessageRequestResponseFromYou(threadId) @@ -435,11 +473,11 @@ class DefaultConversationRepository @Inject constructor( sessionJobDb.cancelPendingMessageSendJobs(threadId) if (recipient.isGroupV2Recipient) { groupManager.respondToInvitation( - AccountId(recipient.address.serialize()), + AccountId(recipient.address.toString()), approved = false ) } else { - storage.deleteConversation(threadId) + storage.deleteContactAndSyncConfig(recipient.address.toString()) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/reviews/InAppReviewManager.kt b/app/src/main/java/org/thoughtcrime/securesms/reviews/InAppReviewManager.kt new file mode 100644 index 0000000000..25df99c30c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reviews/InAppReviewManager.kt @@ -0,0 +1,173 @@ +package org.thoughtcrime.securesms.reviews + +import android.content.Context +import androidx.annotation.VisibleForTesting +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import org.session.libsession.snode.SnodeClock +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.ManagerScope +import java.util.EnumSet +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds + +@OptIn(DelicateCoroutinesApi::class) +@Singleton +class InAppReviewManager @Inject constructor( + @param:ApplicationContext val context: Context, + private val prefs: TextSecurePreferences, + private val json: Json, + private val storeReviewManager: StoreReviewManager, + @param:ManagerScope private val scope: CoroutineScope, +) { + private val stateChangeNotification = MutableSharedFlow(extraBufferCapacity = 1) + private val eventsChannel: SendChannel + + @Suppress("OPT_IN_USAGE") + val shouldShowPrompt: StateFlow = stateChangeNotification + .onStart { emit(Unit) } + .map { prefs.reviewState } + .flatMapLatest { state -> + when (state) { + InAppReviewState.DismissedForever, is InAppReviewState.WaitingForTrigger, null -> flowOf(false) + InAppReviewState.ShowingReviewRequest -> flowOf(true) + is InAppReviewState.DismissedUntil -> { + val now = System.currentTimeMillis() + val delayMills = state.untilTimestampMills - now + if (delayMills <= 0) { + flowOf(true) + } else { + flow { + emit(false) + Log.i(TAG, "Review request is not ready yet, will show in $delayMills ms.") + delay(delayMills) + emit(true) + } + } + } + } + } + .stateIn(scope, SharingStarted.Eagerly, false) + + init { + val channel = Channel() + eventsChannel = channel + + scope.launch { + val startState = prefs.reviewState ?: run { + if (storeReviewManager.supportsReviewFlow) { + val pkg = context.packageManager.getPackageInfo(context.packageName, 0) + InAppReviewState.WaitingForTrigger( + appUpdated = pkg.firstInstallTime != pkg.lastUpdateTime + ) + } else { + InAppReviewState.DismissedForever + } + } + + channel.consumeAsFlow() + .scan(startState) { state, event -> + Log.d(TAG, "Received event: $event, current state: $state") + when { + // If we have determined that we should not show the review request, + // no amount of events will change that. + state == InAppReviewState.DismissedForever -> state + + // If we have shown the review request and the user has abandoned it... + state == InAppReviewState.ShowingReviewRequest && event == Event.ReviewFlowAbandoned -> { + InAppReviewState.DismissedUntil(System.currentTimeMillis() + REVIEW_REQUEST_DISMISS_DELAY.inWholeMilliseconds) + } + + // If the user abandoned the review flow **again**... + state is InAppReviewState.DismissedUntil && event == Event.ReviewFlowAbandoned -> { + InAppReviewState.DismissedForever + } + + // If we are showing the review request and the user has dismissed it... + state == InAppReviewState.ShowingReviewRequest && event == Event.Dismiss -> { + InAppReviewState.DismissedForever + } + + // If we are showing the review request and the user has dismissed it... + state is InAppReviewState.DismissedUntil && event == Event.Dismiss -> { + InAppReviewState.DismissedForever + } + + // If we are waiting for the user to trigger the review request, and eligible + // trigger events happen... + state is InAppReviewState.WaitingForTrigger && ( + (state.appUpdated && event == Event.DonateButtonClicked) || + (!state.appUpdated && event in EnumSet.of( + Event.PathScreenVisited, + Event.DonateButtonClicked, + Event.ThemeChanged + )) + ) -> { + InAppReviewState.ShowingReviewRequest + } + + else -> state + } + } + .distinctUntilChanged() + .collectLatest { + prefs.reviewState = it + Log.d(TAG, "New review state is: $it") + } + } + } + + suspend fun onEvent(event: Event) { + eventsChannel.send(event) + } + + enum class Event { + PathScreenVisited, + DonateButtonClicked, + ThemeChanged, + ReviewFlowAbandoned, + Dismiss, + } + + private var TextSecurePreferences.reviewState + get() = prefs.inAppReviewState?.let { + runCatching { json.decodeFromString(it) } + .onFailure { Log.w(TAG, "Failed to decode review state", it) } + .getOrNull() + } + set(value) { + prefs.inAppReviewState = + value?.let { json.encodeToString(InAppReviewState.serializer(), it) } + stateChangeNotification.tryEmit(Unit) + } + + + companion object { + private const val TAG = "InAppReviewManager" + + @VisibleForTesting + val REVIEW_REQUEST_DISMISS_DELAY = 14.days + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/reviews/InAppReviewState.kt b/app/src/main/java/org/thoughtcrime/securesms/reviews/InAppReviewState.kt new file mode 100644 index 0000000000..120a15eed1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reviews/InAppReviewState.kt @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.reviews + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +@JsonClassDiscriminator(InAppReviewState.DISCRIMINATOR) +sealed interface InAppReviewState { + + /** + * Indicate we are waiting for the user to trigger the review request. + */ + @Serializable + @SerialName("waiting") + data class WaitingForTrigger( + // Whether the app was updated over an old version since the review request feature was added. + val appUpdated: Boolean + ) : InAppReviewState + + /** + * Indicates that we should be showing the review prompt right now. + */ + @Serializable + @SerialName("showing") + data object ShowingReviewRequest : InAppReviewState + + /** + * Indicates that we have shown the review request but the user has abandoned it mid-flow, + * we'll then try to prompt them again later. + * + * When at this state, we should compare the current time with the `until` time to determine + * whether we are should show the review request now. + */ + @Serializable + @SerialName("dismissed_until") + data class DismissedUntil( + @SerialName("until") + val untilTimestampMills: Long, + ) : InAppReviewState + + /** + * Indicates that the user has dismissed the review request permanently, or we have + * determined that the user should not be prompted again. + */ + @Serializable + @SerialName("dismissed_forever") + data object DismissedForever : InAppReviewState + + companion object { + const val DISCRIMINATOR = "type" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/reviews/InAppReviewsModule.kt b/app/src/main/java/org/thoughtcrime/securesms/reviews/InAppReviewsModule.kt new file mode 100644 index 0000000000..57d84cd553 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reviews/InAppReviewsModule.kt @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.reviews + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass + + +/** + * Module for providing JSON serializers. + */ +@Module +@InstallIn(SingletonComponent::class) +class ReviewsSerializerModule { + @Provides + @IntoSet + fun provideReviewsSerializersModule(): SerializersModule = SerializersModule { + polymorphic(InAppReviewState::class) { + subclass(InAppReviewState.WaitingForTrigger::class) + subclass(InAppReviewState.ShowingReviewRequest::class) + subclass(InAppReviewState.DismissedForever::class) + subclass(InAppReviewState.DismissedUntil::class) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/reviews/StoreReviewManager.kt b/app/src/main/java/org/thoughtcrime/securesms/reviews/StoreReviewManager.kt new file mode 100644 index 0000000000..644c735bcb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reviews/StoreReviewManager.kt @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.reviews + +/** + * Interface for managing review flows using a particular app store's API. + */ +interface StoreReviewManager { + val supportsReviewFlow: Boolean + val storeName: String + suspend fun requestReviewFlow() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reviews/ui/InAppReview.kt b/app/src/main/java/org/thoughtcrime/securesms/reviews/ui/InAppReview.kt new file mode 100644 index 0000000000..0b5ad12c4a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reviews/ui/InAppReview.kt @@ -0,0 +1,244 @@ +package org.thoughtcrime.securesms.reviews.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.squareup.phrase.Phrase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import network.loki.messenger.BuildConfig +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.STORE_VARIANT_KEY +import org.thoughtcrime.securesms.reviews.StoreReviewManager +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.AlertDialogContent +import org.thoughtcrime.securesms.ui.DialogButtonData +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.OpenURLAlertDialog +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors + +private const val SESSION_FEEDBACK_BASE_URL = "https://getsession.org/feedback?platform=android" + +@Composable +fun InAppReview( + uiStateFlow: Flow, + storeReviewManager: StoreReviewManager, + sendCommands: (InAppReviewViewModel.UiCommand) -> Unit, +) { + val context = LocalContext.current + val lifecycle = LocalLifecycleOwner.current.lifecycle + + var isInForeground by remember { mutableStateOf(lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) } + + DisposableEffect(lifecycle) { + val observer = object : LifecycleEventObserver { + override fun onStateChanged( + source: LifecycleOwner, + event: Lifecycle.Event + ) { + isInForeground = event == Lifecycle.Event.ON_START || event == Lifecycle.Event.ON_RESUME + } + } + lifecycle.addObserver(observer) + onDispose { + lifecycle.removeObserver(observer) + } + } + + var uiState by remember { mutableStateOf(InAppReviewViewModel.UiState.Hidden) } + + // Only subscribe to the flow when the app is in the foreground + LaunchedEffect(uiStateFlow, isInForeground) { + if (isInForeground) { + uiStateFlow.collectLatest { + uiState = it + } + } + } + + val extraAnimationDelay = if (uiState == InAppReviewViewModel.UiState.StartPrompt) 1000 else 0 + + AnimatedContent(uiState, transitionSpec = { + (fadeIn(animationSpec = tween(220, delayMillis = 90 + extraAnimationDelay)) + + scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90 + extraAnimationDelay))) + .togetherWith(fadeOut(animationSpec = tween(90 + extraAnimationDelay))) + }) { st -> + when (st) { + InAppReviewViewModel.UiState.StartPrompt -> { + InAppReviewStartPrompt(sendCommands) + } + + InAppReviewViewModel.UiState.ConfirmOpeningSurvey -> OpenURLAlertDialog( + onDismissRequest = { sendCommands(InAppReviewViewModel.UiCommand.CloseButtonClicked) }, + url = SESSION_FEEDBACK_BASE_URL + .toUri() + .buildUpon() + .appendQueryParameter("version", BuildConfig.VERSION_NAME) + .build() + .toString(), + ) + InAppReviewViewModel.UiState.ReviewLimitReached -> AlertDialog( + onDismissRequest = { sendCommands(InAppReviewViewModel.UiCommand.CloseButtonClicked) }, + showCloseButton = true, + title = context.getString(R.string.reviewLimit), + text = Phrase.from(context, R.string.reviewLimitDescription) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format() + .toString(), + ) + + InAppReviewViewModel.UiState.PositivePrompt -> InAppReviewPositivePrompt( + storeReviewManager = storeReviewManager, + sendCommands = sendCommands + ) + InAppReviewViewModel.UiState.NegativePrompt -> InAppReviewNegativePrompt(sendCommands) + InAppReviewViewModel.UiState.Hidden -> {} + } + } +} + +@Composable +private fun InAppReviewDialog( + title: String, + message: String, + positiveButtonText: String, + negativeButtonText: String, + positiveButtonQaTag: String, + negativeButtonQaTag: String, + sendCommands: (InAppReviewViewModel.UiCommand) -> Unit, +) { + AlertDialogContent( + showCloseButton = true, + onDismissRequest = { sendCommands(InAppReviewViewModel.UiCommand.CloseButtonClicked) }, + title = AnnotatedString(title), + text = AnnotatedString(message), + buttons = listOf( + DialogButtonData( + text = GetString.FromString(positiveButtonText), + color = LocalColors.current.accentText, + qaTag = positiveButtonQaTag, + dismissOnClick = false + ) { + sendCommands(InAppReviewViewModel.UiCommand.PositiveButtonClicked) + }, + + DialogButtonData( + text = GetString.FromString(negativeButtonText), + qaTag = negativeButtonQaTag, + dismissOnClick = false + ) { + sendCommands(InAppReviewViewModel.UiCommand.NegativeButtonClicked) + }, + ) + ) +} + +@Composable +private fun InAppReviewStartPrompt( + sendCommands: (InAppReviewViewModel.UiCommand) -> Unit = {} +) { + val context = LocalContext.current + + InAppReviewDialog( + title = Phrase.from(context, R.string.enjoyingSession) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format() + .toString(), + message = Phrase.from(context, R.string.enjoyingSessionDescription) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format() + .toString(), + positiveButtonText = Phrase.from(context, R.string.enjoyingSessionButtonPositive) + .put(EMOJI_KEY, "❤\uFE0F") + .format() + .toString(), + negativeButtonText = Phrase.from(context, R.string.enjoyingSessionButtonNegative) + .put(EMOJI_KEY, "\uD83D\uDE15") + .format() + .toString(), + positiveButtonQaTag = stringResource(R.string.qa_inapp_review_dialog_button_great), + negativeButtonQaTag = stringResource(R.string.qa_inapp_review_dialog_button_work), + sendCommands = sendCommands + ) +} + +@Composable +private fun InAppReviewPositivePrompt( + storeReviewManager: StoreReviewManager? = null, + sendCommands: (InAppReviewViewModel.UiCommand) -> Unit = {} +) { + val context = LocalContext.current + + InAppReviewDialog( + title = Phrase.from(context, R.string.rateSession) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format() + .toString(), + message = Phrase.from(context, R.string.rateSessionModalDescription) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .put(STORE_VARIANT_KEY, storeReviewManager?.storeName ?: "Google Play Store") + .format() + .toString(), + positiveButtonText = context.getString(R.string.rateSessionApp), + negativeButtonText = context.getString(R.string.notNow), + positiveButtonQaTag = stringResource(R.string.qa_inapp_review_dialog_button_rate), + negativeButtonQaTag = stringResource(R.string.qa_inapp_review_dialog_button_not_now), + sendCommands = sendCommands + ) +} + +@Composable +private fun InAppReviewNegativePrompt( + sendCommands: (InAppReviewViewModel.UiCommand) -> Unit = {} +) { + + val context = LocalContext.current + + InAppReviewDialog( + title = context.getString(R.string.giveFeedback), + message = Phrase.from(context, R.string.giveFeedbackDescription) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format() + .toString(), + positiveButtonText = context.getString(R.string.openSurvey), + negativeButtonText = context.getString(R.string.notNow), + positiveButtonQaTag = stringResource(R.string.qa_inapp_review_dialog_button_survey), + negativeButtonQaTag = stringResource(R.string.qa_inapp_review_dialog_button_not_now), + sendCommands = sendCommands + ) +} + +@Composable +@Preview +fun PreviewInAppReviewPrompt( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +){ + PreviewTheme(colors = colors) { + InAppReviewStartPrompt() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/reviews/ui/InAppReviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/reviews/ui/InAppReviewViewModel.kt new file mode 100644 index 0000000000..90e0482a27 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reviews/ui/InAppReviewViewModel.kt @@ -0,0 +1,141 @@ +package org.thoughtcrime.securesms.reviews.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.stateIn +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.reviews.InAppReviewManager +import org.thoughtcrime.securesms.reviews.StoreReviewManager +import javax.inject.Inject + +private const val TAG = "InAppReviewViewModel" + +@HiltViewModel +class InAppReviewViewModel @Inject constructor( + private val manager: InAppReviewManager, + private val storeReviewManager: StoreReviewManager, +) : ViewModel() { + private val commands = MutableSharedFlow(extraBufferCapacity = 1) + + /** + * Represent the current state of the in-app review flow. + * + * This flow is done by advancing the state machine based on the events emitted by both + * UI and the [InAppReviewManager]. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val uiState: StateFlow = merge(commands, manager.shouldShowPrompt.filter { it }.map { ShowPrompt }) + .scan(UiState.Hidden) { st, event -> + Log.d(TAG, "Received $event, current state = $st") + when (st) { + UiState.Hidden -> when (event) { + ShowPrompt -> UiState.StartPrompt + else -> st // Ignore other events + } + + UiState.StartPrompt -> { + when (event) { + UiCommand.PositiveButtonClicked -> UiState.PositivePrompt + UiCommand.NegativeButtonClicked -> UiState.NegativePrompt + UiCommand.CloseButtonClicked -> { + manager.onEvent(InAppReviewManager.Event.Dismiss) + UiState.Hidden + } + else -> st // Ignore other event + } + } + + UiState.PositivePrompt -> when (event) { + // "Rate App" button clicked + UiCommand.PositiveButtonClicked -> { + manager.onEvent(InAppReviewManager.Event.Dismiss) + + if (runCatching { storeReviewManager.requestReviewFlow() }.isSuccess) { + UiState.Hidden + } else { + UiState.ReviewLimitReached + } + } + + // "Not Now"/close button clicked + UiCommand.CloseButtonClicked, UiCommand.NegativeButtonClicked -> { + manager.onEvent(InAppReviewManager.Event.ReviewFlowAbandoned) + UiState.Hidden + } + + else -> st // Ignore other events + } + + UiState.NegativePrompt -> when (event) { + // "Open Survey" button clicked + UiCommand.PositiveButtonClicked -> { + manager.onEvent(InAppReviewManager.Event.Dismiss) + UiState.ConfirmOpeningSurvey + } + + // "Not Now"/close button clicked + UiCommand.CloseButtonClicked, UiCommand.NegativeButtonClicked -> { + manager.onEvent(InAppReviewManager.Event.Dismiss) + UiState.Hidden + } + + else -> st // Ignore other events + } + + UiState.ConfirmOpeningSurvey -> when (event) { + UiCommand.CloseButtonClicked -> UiState.Hidden + else -> st // Ignore other commands + } + + UiState.ReviewLimitReached -> when (event) { + UiCommand.CloseButtonClicked -> UiState.Hidden + else -> st // Ignore other commands + } + } + } + .distinctUntilChanged() + .onEach { Log.d(TAG, "New state $it") } + .stateIn(viewModelScope, started = SharingStarted.Eagerly, initialValue = UiState.Hidden) + + fun sendUiCommand(command: UiCommand) { + commands.tryEmit(command) + } + + enum class UiState { + StartPrompt, + PositivePrompt, + NegativePrompt, + ConfirmOpeningSurvey, + ReviewLimitReached, + Hidden, + } + + /** + * Represents the event that can occur in the in-app review flow. + */ + private sealed interface Event + + enum class UiCommand : Event { + PositiveButtonClicked, + NegativeButtonClicked, + CloseButtonClicked, + } + + /** + * Triggered when the [InAppReviewManager] determines that we should show the review prompt. + * + * Whether we actually show the prompt will be further controlled by us. + */ + private data object ShowPrompt : Event +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java index 39b75c7a82..7cbff2d8f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -25,6 +25,7 @@ import org.thoughtcrime.securesms.scribbles.widget.VerticalSlideColorPicker; import org.thoughtcrime.securesms.util.ParcelUtil; import org.session.libsession.utilities.TextSecurePreferences; +import org.thoughtcrime.securesms.util.ViewUtilitiesKt; import static android.app.Activity.RESULT_OK; @@ -121,6 +122,8 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat imageEditorHud = view.findViewById(R.id.scribble_hud); imageEditorView = view.findViewById(R.id.image_editor_view); + ViewUtilitiesKt.applySafeInsetsMargins(imageEditorHud); + imageEditorHud.setEventListener(this); imageEditorView.setTapListener(selectionListener); @@ -132,8 +135,6 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat if (restoredModel != null) { editorModel = restoredModel; restoredModel = null; - } else if (savedInstanceState != null) { - editorModel = new Data(savedInstanceState).readModel(); } if (editorModel == null) { @@ -165,12 +166,6 @@ public Uri getUri() { return imageUri; } - @Nullable - @Override - public View getPlaybackControls() { - return null; - } - @Override public Object saveState() { Data data = new Data(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java index a40e0d72a6..5593ea3ea5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java @@ -4,6 +4,7 @@ import android.graphics.Color; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.content.res.ResourcesCompat; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import android.util.AttributeSet; @@ -96,7 +97,7 @@ private void initialize() { } private void updateCropAspectLockImage(boolean cropAspectLocked) { - cropAspectLock.setImageDrawable(getResources().getDrawable(cropAspectLocked ? R.drawable.ic_crop_lock_32 : R.drawable.ic_crop_unlock_32)); + cropAspectLock.setImageDrawable(ResourcesCompat.getDrawable(getResources(), cropAspectLocked ? R.drawable.ic_crop_lock_custom : R.drawable.ic_crop_unlock_custom, getContext().getTheme())); } private void initializeVisibilityMap() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/StickerSelectActivity.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/StickerSelectActivity.java index fd2937a5ac..8afc241a56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/StickerSelectActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/StickerSelectActivity.java @@ -18,45 +18,47 @@ import android.content.Intent; import android.os.Bundle; +import android.view.MenuItem; + +import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.material.tabs.TabLayout; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentStatePagerAdapter; -import androidx.viewpager.widget.ViewPager; -import android.view.MenuItem; +import androidx.viewpager2.adapter.FragmentStateAdapter; + +import com.google.android.material.tabs.TabLayoutMediator; import network.loki.messenger.R; +import network.loki.messenger.databinding.ScribbleSelectStickerActivityBinding; public class StickerSelectActivity extends FragmentActivity implements StickerSelectFragment.StickerSelectionListener { - private static final String TAG = StickerSelectActivity.class.getSimpleName(); - public static final String EXTRA_STICKER_FILE = "extra_sticker_file"; private static final int[] TAB_TITLES = new int[] { - R.drawable.ic_tag_faces_white_24dp, - R.drawable.ic_work_white_24dp, - R.drawable.ic_pets_white_24dp, - R.drawable.ic_local_dining_white_24dp, - R.drawable.ic_wb_sunny_white_24dp + R.drawable.ic_emoji_custom, + R.drawable.ic_briefcase, + R.drawable.ic_paw_print, + R.drawable.ic_utensils_crossed, + R.drawable.ic_sun }; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.scribble_select_sticker_activity); - ViewPager viewPager = (ViewPager) findViewById(R.id.camera_sticker_pager); - viewPager.setAdapter(new StickerPagerAdapter(getSupportFragmentManager(), this)); + final ScribbleSelectStickerActivityBinding binding = ScribbleSelectStickerActivityBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); - TabLayout tabLayout = (TabLayout) findViewById(R.id.camera_sticker_tabs); - tabLayout.setupWithViewPager(viewPager); + binding.cameraStickerPager.setAdapter(new StickerPagerAdapter(this, this)); - for (int i=0;i { + tab.setIcon(TAB_TITLES[position]); + } + ).attach(); } @Override @@ -76,12 +78,12 @@ public void onStickerSelected(String name) { finish(); } - static class StickerPagerAdapter extends FragmentStatePagerAdapter { + static class StickerPagerAdapter extends FragmentStateAdapter { private final Fragment[] fragments; - StickerPagerAdapter(FragmentManager fm, StickerSelectFragment.StickerSelectionListener listener) { - super(fm); + StickerPagerAdapter(FragmentActivity activity, StickerSelectFragment.StickerSelectionListener listener) { + super(activity); this.fragments = new Fragment[] { StickerSelectFragment.newInstance("stickers/emoticons"), @@ -96,13 +98,14 @@ static class StickerPagerAdapter extends FragmentStatePagerAdapter { } } + @NonNull @Override - public Fragment getItem(int position) { + public Fragment createFragment(int position) { return fragments[position]; } @Override - public int getCount() { + public int getItemCount() { return fragments.length; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchModule.kt b/app/src/main/java/org/thoughtcrime/securesms/search/SearchModule.kt index 7ee247fdb4..65d475336b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchModule.kt @@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.SearchDatabase import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.dependencies.ConfigFactory @Module @InstallIn(ViewModelComponent::class) @@ -26,8 +27,12 @@ object SearchModule { searchDatabase: SearchDatabase, threadDatabase: ThreadDatabase, groupDatabase: GroupDatabase, - contactDatabase: SessionContactDatabase) = - SearchRepository(context, searchDatabase, threadDatabase, groupDatabase, contactDatabase, ContactAccessor.getInstance(), SignalExecutors.SERIAL) + contactDatabase: SessionContactDatabase, + configFactory: ConfigFactory) = + SearchRepository( + context, searchDatabase, threadDatabase, groupDatabase, contactDatabase, + ContactAccessor.getInstance(), configFactory, SignalExecutors.SERIAL + ) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java deleted file mode 100644 index 204b63f802..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java +++ /dev/null @@ -1,285 +0,0 @@ -package org.thoughtcrime.securesms.search; - -import android.content.Context; -import android.database.Cursor; -import android.database.DatabaseUtils; -import android.database.MergeCursor; -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; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.contacts.ContactAccessor; -import org.thoughtcrime.securesms.database.CursorList; -import org.thoughtcrime.securesms.database.GroupDatabase; -import org.thoughtcrime.securesms.database.MmsSmsColumns; -import org.thoughtcrime.securesms.database.SearchDatabase; -import org.thoughtcrime.securesms.database.SessionContactDatabase; -import org.thoughtcrime.securesms.database.ThreadDatabase; -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; - -// Class to manage data retrieval for search -public class SearchRepository { - private static final String TAG = SearchRepository.class.getSimpleName(); - - private static final Set BANNED_CHARACTERS = new HashSet<>(); - static { - // 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; - private final SearchDatabase searchDatabase; - private final ThreadDatabase threadDatabase; - private final GroupDatabase groupDatabase; - private final SessionContactDatabase contactDatabase; - private final ContactAccessor contactAccessor; - private final Executor executor; - - public SearchRepository(@NonNull Context context, - @NonNull SearchDatabase searchDatabase, - @NonNull ThreadDatabase threadDatabase, - @NonNull GroupDatabase groupDatabase, - @NonNull SessionContactDatabase contactDatabase, - @NonNull ContactAccessor contactAccessor, - @NonNull Executor executor) - { - this.context = context.getApplicationContext(); - this.searchDatabase = searchDatabase; - this.threadDatabase = threadDatabase; - this.groupDatabase = groupDatabase; - this.contactDatabase = contactDatabase; - this.contactAccessor = contactAccessor; - this.executor = executor; - } - - public void query(@NonNull String query, @NonNull Callback callback) { - // If the sanitized search is empty then abort without search - String cleanQuery = sanitizeQuery(query).trim(); - - executor.execute(() -> { - Stopwatch timer = new Stopwatch("FtsQuery"); - timer.split("clean"); - - Pair, List> contacts = queryContacts(cleanQuery); - timer.split("Contacts"); - - CursorList conversations = queryConversations(cleanQuery, contacts.getSecond()); - timer.split("Conversations"); - - CursorList messages = queryMessages(cleanQuery); - timer.split("Messages"); - - timer.stop(TAG); - - callback.onResult(new SearchResult(cleanQuery, contacts.getFirst(), conversations, messages)); - }); - } - - public void query(@NonNull String query, long threadId, @NonNull Callback> callback) { - // 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(() -> { - CursorList messages = queryMessages(cleanQuery, threadId); - callback.onResult(messages); - }); - } - - public Pair, List> queryContacts(String query) { - Cursor contacts = contactDatabase.queryContactsByName(query); - List
contactList = new ArrayList<>(); - List contactStrings = new ArrayList<>(); - - while (contacts.moveToNext()) { - try { - Contact contact = contactDatabase.contactFromCursor(contacts); - String contactAccountId = contact.getAccountID(); - Address address = Address.fromSerialized(contactAccountId); - contactList.add(address); - contactStrings.add(contactAccountId); - } catch (Exception e) { - Log.e("Loki", "Error building Contact from cursor in query", e); - } - } - - contacts.close(); - - Cursor addressThreads = threadDatabase.searchConversationAddresses(query); - Cursor individualRecipients = threadDatabase.getFilteredConversationList(contactList); - if (individualRecipients == null && addressThreads == null) { - return new Pair<>(CursorList.emptyList(),contactStrings); - } - MergeCursor merged = new MergeCursor(new Cursor[]{addressThreads, individualRecipients}); - - return new Pair<>(new CursorList<>(merged, new ContactModelBuilder(contactDatabase, threadDatabase)), contactStrings); - } - - private CursorList queryConversations(@NonNull String query, List matchingAddresses) { - List numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query); - String localUserNumber = TextSecurePreferences.getLocalNumber(context); - if (localUserNumber != null) { - matchingAddresses.remove(localUserNumber); - } - Set
addresses = new HashSet<>(Stream.of(numbers).map(number -> Address.fromExternal(context, number)).toList()); - - Cursor membersGroupList = groupDatabase.getGroupsFilteredByMembers(matchingAddresses); - if (membersGroupList != null) { - GroupDatabase.Reader reader = new GroupDatabase.Reader(membersGroupList); - while (membersGroupList.moveToNext()) { - GroupRecord record = reader.getCurrent(); - if (record == null) continue; - - addresses.add(Address.fromSerialized(record.getEncodedId())); - } - membersGroupList.close(); - } - - Cursor conversations = threadDatabase.getFilteredConversationList(new ArrayList<>(addresses)); - return conversations != null ? new CursorList<>(conversations, new GroupModelBuilder(threadDatabase, groupDatabase)) - : CursorList.emptyList(); - } - - private CursorList queryMessages(@NonNull String query) { - Cursor messages = searchDatabase.queryMessages(query); - return messages != null ? new CursorList<>(messages, new MessageModelBuilder(context)) - : CursorList.emptyList(); - } - - private CursorList queryMessages(@NonNull String query, long threadId) { - Cursor messages = searchDatabase.queryMessages(query, threadId); - return messages != null ? new CursorList<>(messages, new MessageModelBuilder(context)) - : CursorList.emptyList(); - } - - /** - * Unfortunately {@link DatabaseUtils#sqlEscapeString(String)} is not sufficient for our purposes. - * MATCH queries have a separate format of their own that disallow most "special" characters. - * - * Also, SQLite can't search for apostrophes, meaning we can't normally find words like "I'm". - * However, if we replace the apostrophe with a space, then the query will find the match. - */ - private String sanitizeQuery(@NonNull String query) { - StringBuilder out = new StringBuilder(); - - for (int i = 0; i < query.length(); i++) { - char c = query.charAt(i); - if (!BANNED_CHARACTERS.contains(c)) { - out.append(c); - } else if (c == '\'') { - out.append(' '); - } - } - - return out.toString(); - } - - private static class ContactModelBuilder implements CursorList.ModelBuilder { - - private final SessionContactDatabase contactDb; - private final ThreadDatabase threadDb; - - public ContactModelBuilder(SessionContactDatabase contactDb, ThreadDatabase threadDb) { - this.contactDb = contactDb; - this.threadDb = threadDb; - } - - @Override - public Contact build(@NonNull Cursor cursor) { - ThreadRecord threadRecord = threadDb.readerFor(cursor).getCurrent(); - Contact contact = contactDb.getContactWithAccountID(threadRecord.getRecipient().getAddress().serialize()); - if (contact == null) { - contact = new Contact(threadRecord.getRecipient().getAddress().serialize()); - contact.setThreadID(threadRecord.getThreadId()); - } - return contact; - } - } - - private static class RecipientModelBuilder implements CursorList.ModelBuilder { - - private final Context context; - - RecipientModelBuilder(@NonNull Context context) { this.context = context; } - - @Override - public Recipient build(@NonNull Cursor cursor) { - Address address = Address.fromExternal(context, cursor.getString(1)); - return Recipient.from(context, address, false); - } - } - - private static class GroupModelBuilder implements CursorList.ModelBuilder { - private final ThreadDatabase threadDatabase; - private final GroupDatabase groupDatabase; - - public GroupModelBuilder(ThreadDatabase threadDatabase, GroupDatabase groupDatabase) { - this.threadDatabase = threadDatabase; - this.groupDatabase = groupDatabase; - } - - @Override - public GroupRecord build(@NonNull Cursor cursor) { - ThreadRecord threadRecord = threadDatabase.readerFor(cursor).getCurrent(); - return groupDatabase.getGroup(threadRecord.getRecipient().getAddress().toGroupString()).get(); - } - } - - private static class ThreadModelBuilder implements CursorList.ModelBuilder { - - private final ThreadDatabase threadDatabase; - - ThreadModelBuilder(@NonNull ThreadDatabase threadDatabase) { - this.threadDatabase = threadDatabase; - } - - @Override - public ThreadRecord build(@NonNull Cursor cursor) { - return threadDatabase.readerFor(cursor).getCurrent(); - } - } - - private static class MessageModelBuilder implements CursorList.ModelBuilder { - - private final Context context; - - MessageModelBuilder(@NonNull Context context) { this.context = context; } - - @Override - public MessageResult build(@NonNull Cursor cursor) { - Address conversationAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.CONVERSATION_ADDRESS))); - Address messageAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.MESSAGE_ADDRESS))); - Recipient conversationRecipient = Recipient.from(context, conversationAddress, false); - Recipient messageRecipient = Recipient.from(context, messageAddress, false); - String body = cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.SNIPPET)); - 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, sentMs); - } - } - - public interface Callback { - void onResult(@NonNull E result); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt new file mode 100644 index 0000000000..d62fb34932 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt @@ -0,0 +1,257 @@ +package org.thoughtcrime.securesms.search + +import android.content.Context +import android.database.Cursor +import android.database.MergeCursor +import com.annimon.stream.Stream +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.fromExternal +import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.GroupRecord +import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.contacts.ContactAccessor +import org.thoughtcrime.securesms.database.CursorList +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.MmsSmsColumns +import org.thoughtcrime.securesms.database.SearchDatabase +import org.thoughtcrime.securesms.database.SessionContactDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.search.model.MessageResult +import org.thoughtcrime.securesms.search.model.SearchResult +import org.thoughtcrime.securesms.util.Stopwatch +import java.util.concurrent.Executor + +// Class to manage data retrieval for search +class SearchRepository( + context: Context, + private val searchDatabase: SearchDatabase, + private val threadDatabase: ThreadDatabase, + private val groupDatabase: GroupDatabase, + private val contactDatabase: SessionContactDatabase, + private val contactAccessor: ContactAccessor, + private val configFactory: ConfigFactory, + private val executor: Executor +) { + private val context: Context = context.applicationContext + + fun query(query: String, callback: (SearchResult) -> Unit) { + // If the sanitized search is empty then abort without search + val cleanQuery = sanitizeQuery(query).trim { it <= ' ' } + + executor.execute { + val timer = + Stopwatch("FtsQuery") + timer.split("clean") + + val contacts = + queryContacts(cleanQuery) + timer.split("Contacts") + + val conversations = + queryConversations(cleanQuery) + timer.split("Conversations") + + val messages = queryMessages(cleanQuery) + timer.split("Messages") + + timer.stop(TAG) + callback( + SearchResult( + cleanQuery, + contacts, + conversations, + messages + ) + ) + } + } + + fun query(query: String, threadId: Long, callback: (CursorList) -> Unit) { + // If the sanitized search query is empty then abort the search + val cleanQuery = sanitizeQuery(query).trim { it <= ' ' } + if (cleanQuery.isEmpty()) { + callback(CursorList.emptyList()) + return + } + + executor.execute { + val messages = queryMessages(cleanQuery, threadId) + callback(messages) + } + } + + // Get set of blocked contact AccountIDs from the ConfigFactory + private fun getBlockedContacts(): MutableSet { + val blockedContacts = mutableSetOf() + configFactory.withUserConfigs { userConfigs -> + userConfigs.contacts.all().forEach { contact -> + if (contact.blocked) { + blockedContacts.add(contact.id) + } + } + } + return blockedContacts + } + + fun queryContacts(query: String): CursorList { + val excludingAddresses = getBlockedContacts() + val contacts = contactDatabase.queryContactsByName(query, excludeUserAddresses = excludingAddresses) + val contactList: MutableList
= ArrayList() + + while (contacts.moveToNext()) { + try { + val contact = contactDatabase.contactFromCursor(contacts) + val contactAccountId = contact.accountID + val address = fromSerialized(contactAccountId) + contactList.add(address) + + // Add the address in this query to the excluded addresses so the next query + // won't get the same contact again + excludingAddresses.add(contactAccountId) + } catch (e: Exception) { + Log.e("Loki", "Error building Contact from cursor in query", e) + } + } + + contacts.close() + + val addressThreads = threadDatabase.searchConversationAddresses(query, excludingAddresses)// filtering threads by looking up the accountID itself + val individualRecipients = threadDatabase.getFilteredConversationList(contactList) + if (individualRecipients == null && addressThreads == null) { + return CursorList.emptyList() + } + val merged = MergeCursor(arrayOf(addressThreads, individualRecipients)) + + return CursorList(merged, ContactModelBuilder(contactDatabase, threadDatabase)) + } + + private fun queryConversations( + query: String, + ): CursorList { + val numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query) + val addresses = numbers.map { fromExternal(context, it) } + + val conversations = threadDatabase.getFilteredConversationList(addresses) + return if (conversations != null) + CursorList(conversations, GroupModelBuilder(threadDatabase, groupDatabase)) + else + CursorList.emptyList() + } + + private fun queryMessages(query: String): CursorList { + val blockedContacts = getBlockedContacts() + val messages = searchDatabase.queryMessages(query, blockedContacts) + return if (messages != null) + CursorList(messages, MessageModelBuilder(context)) + else + CursorList.emptyList() + } + + private fun queryMessages(query: String, threadId: Long): CursorList { + val blockedContacts = getBlockedContacts() + val messages = searchDatabase.queryMessages(query, threadId, blockedContacts) + return if (messages != null) + CursorList(messages, MessageModelBuilder(context)) + else + CursorList.emptyList() + } + + /** + * Unfortunately [DatabaseUtils.sqlEscapeString] is not sufficient for our purposes. + * MATCH queries have a separate format of their own that disallow most "special" characters. + * + * Also, SQLite can't search for apostrophes, meaning we can't normally find words like "I'm". + * However, if we replace the apostrophe with a space, then the query will find the match. + */ + private fun sanitizeQuery(query: String): String { + val out = StringBuilder() + + for (i in 0.. { + override fun build(cursor: Cursor): Contact { + val threadRecord = threadDb.readerFor(cursor).current + var contact = + contactDb.getContactWithAccountID(threadRecord.recipient.address.toString()) + if (contact == null) { + contact = Contact(threadRecord.recipient.address.toString()) + contact.threadID = threadRecord.threadId + } + return contact + } + } + + private class GroupModelBuilder( + private val threadDatabase: ThreadDatabase, + private val groupDatabase: GroupDatabase + ) : CursorList.ModelBuilder { + override fun build(cursor: Cursor): GroupRecord { + val threadRecord = threadDatabase.readerFor(cursor).current + return groupDatabase.getGroup(threadRecord.recipient.address.toGroupString()).get() + } + } + + private class MessageModelBuilder(private val context: Context) : CursorList.ModelBuilder { + override fun build(cursor: Cursor): MessageResult { + val conversationAddress = + fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.CONVERSATION_ADDRESS))) + val messageAddress = + fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.MESSAGE_ADDRESS))) + val conversationRecipient = Recipient.from(context, conversationAddress, false) + val messageRecipient = Recipient.from(context, messageAddress, false) + val body = cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.SNIPPET)) + val sentMs = + cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_SENT)) + val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.THREAD_ID)) + + return MessageResult(conversationRecipient, messageRecipient, body, threadId, sentMs) + } + } + + interface Callback { + fun onResult(result: E) + } + + companion object { + private val TAG: String = SearchRepository::class.java.simpleName + + private val BANNED_CHARACTERS: MutableSet = HashSet() + + init { + // Construct a list containing several ranges of invalid ASCII characters + // See: https://www.ascii-code.com/ + for (i in 33..47) { + BANNED_CHARACTERS.add(i.toChar()) + } // !, ", #, $, %, &, ', (, ), *, +, ,, -, ., / + + for (i in 58..64) { + BANNED_CHARACTERS.add(i.toChar()) + } // :, ;, <, =, >, ?, @ + + for (i in 91..96) { + BANNED_CHARACTERS.add(i.toChar()) + } // [, \, ], ^, _, ` + + for (i in 123..126) { + BANNED_CHARACTERS.add(i.toChar()) + } // {, |, }, ~ + } + } +} \ No newline at end of file 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 deleted file mode 100644 index 58e3f1a69a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/search/model/MessageResult.java +++ /dev/null @@ -1,30 +0,0 @@ -package org.thoughtcrime.securesms.search.model; - -import androidx.annotation.NonNull; - -import org.session.libsession.utilities.recipients.Recipient; - -/** - * Represents a search result for a message. - */ -public class MessageResult { - - public final Recipient conversationRecipient; - public final Recipient messageRecipient; - public final String bodySnippet; - public final long threadId; - public final long sentTimestampMs; - - public MessageResult(@NonNull Recipient conversationRecipient, - @NonNull Recipient messageRecipient, - @NonNull String bodySnippet, - long threadId, - long sentTimestampMs) - { - this.conversationRecipient = conversationRecipient; - this.messageRecipient = messageRecipient; - this.bodySnippet = bodySnippet; - this.threadId = threadId; - this.sentTimestampMs = sentTimestampMs; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/model/MessageResult.kt b/app/src/main/java/org/thoughtcrime/securesms/search/model/MessageResult.kt new file mode 100644 index 0000000000..21c9b83e26 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/search/model/MessageResult.kt @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.search.model + +import org.session.libsession.utilities.recipients.Recipient + +data class MessageResult( + val conversationRecipient: Recipient, + val messageRecipient: Recipient, + val bodySnippet: String, + val threadId: Long, + val sentTimestampMs: Long +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java b/app/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java deleted file mode 100644 index 33c687c939..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java +++ /dev/null @@ -1,79 +0,0 @@ -package org.thoughtcrime.securesms.search.model; - -import android.database.ContentObserver; - -import androidx.annotation.NonNull; - -import org.session.libsession.messaging.contacts.Contact; -import org.session.libsession.utilities.GroupRecord; -import org.thoughtcrime.securesms.database.CursorList; -import org.thoughtcrime.securesms.database.model.ThreadRecord; - -import java.util.List; - -/** - * Represents an all-encompassing search result that can contain various result for different - * subcategories. - */ -public class SearchResult { - - public static final SearchResult EMPTY = new SearchResult("", CursorList.emptyList(), CursorList.emptyList(), CursorList.emptyList()); - - private final String query; - private final CursorList contacts; - private final CursorList conversations; - private final CursorList messages; - - public SearchResult(@NonNull String query, - @NonNull CursorList contacts, - @NonNull CursorList conversations, - @NonNull CursorList messages) - { - this.query = query; - this.contacts = contacts; - this.conversations = conversations; - this.messages = messages; - } - - public List getContacts() { - return contacts; - } - - public List getConversations() { - return conversations; - } - - public List getMessages() { - return messages; - } - - public String getQuery() { - return query; - } - - public int size() { - return contacts.size() + conversations.size() + messages.size(); - } - - public boolean isEmpty() { - return size() == 0; - } - - public void registerContentObserver(@NonNull ContentObserver observer) { - contacts.registerContentObserver(observer); - conversations.registerContentObserver(observer); - messages.registerContentObserver(observer); - } - - public void unregisterContentObserver(@NonNull ContentObserver observer) { - contacts.unregisterContentObserver(observer); - conversations.unregisterContentObserver(observer); - messages.unregisterContentObserver(observer); - } - - public void close() { - contacts.close(); - conversations.close(); - messages.close(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.kt b/app/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.kt new file mode 100644 index 0000000000..2f8465003a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.kt @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.search.model + +import android.database.ContentObserver +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.utilities.GroupRecord +import org.thoughtcrime.securesms.database.CursorList + +/** + * Represents an all-encompassing search result that can contain various result for different + * subcategories. + */ +class SearchResult( + val query: String, + val contacts: CursorList, + val conversations: CursorList, + val messages: CursorList +) { + fun size(): Int { + return contacts.size + conversations.size + messages.size + } + + val isEmpty: Boolean + get() = size() == 0 + + fun close() { + contacts.close() + conversations.close() + messages.close() + } + + companion object { + val EMPTY: SearchResult = SearchResult( + "", + CursorList.emptyList(), + CursorList.emptyList(), + CursorList.emptyList() + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/CallForegroundService.kt b/app/src/main/java/org/thoughtcrime/securesms/service/CallForegroundService.kt new file mode 100644 index 0000000000..9ba7051616 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/CallForegroundService.kt @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.service + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.ServiceCompat +import androidx.core.content.IntentCompat +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder +import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.Companion.TYPE_INCOMING_CONNECTING +import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.Companion.WEBRTC_NOTIFICATION + +class CallForegroundService : Service() { + + companion object { + const val EXTRA_RECIPIENT_ADDRESS = "RECIPIENT_ID" + const val EXTRA_TYPE = "CALL_STEP_TYPE" + + fun startIntent(context: Context, type: Int, recipient: Recipient?): Intent { + return Intent(context, CallForegroundService::class.java) + .putExtra(EXTRA_TYPE, type) + .putExtra(EXTRA_RECIPIENT_ADDRESS, recipient?.address) + } + } + + private fun getRemoteRecipient(intent: Intent): Recipient? { + val remoteAddress = IntentCompat.getParcelableExtra(intent, + EXTRA_RECIPIENT_ADDRESS, Address::class.java) + ?: return null + + return Recipient.from(this, remoteAddress, true) + } + + private fun startForeground(type: Int, recipient: Recipient?) { + if (CallNotificationBuilder.areNotificationsEnabled(this)) { + try { + ServiceCompat.startForeground( + this, + WEBRTC_NOTIFICATION, + CallNotificationBuilder.getCallInProgressNotification(this, type, recipient), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } else { + 0 + } + ) + return + } catch (e: IllegalStateException) { + Log.e("", "Failed to setCallInProgressNotification as a foreground service for type: ${type}", e) + } + } + + // if we failed to start in foreground, stop service + stopSelf() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + + Log.d("", "CallForegroundService onStartCommand: ${intent}") + + // check if the intent has the appropriate data to start this service, otherwise stop + if(intent?.hasExtra(EXTRA_TYPE) == true){ + startForeground(intent.getIntExtra(EXTRA_TYPE, TYPE_INCOMING_CONNECTING), getRemoteRecipient(intent)) + } else { + stopSelf() + } + + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null +} \ No newline at end of file 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 c8a67926a1..8a6ed84f3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/DirectShareService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/DirectShareService.java @@ -44,7 +44,7 @@ public List onGetChooserTargets(ComponentName targetActivityName, while ((record = reader.getNext()) != null && results.size() < 10) { Recipient recipient = Recipient.from(this, record.getRecipient().getAddress(), false); - String name = recipient.toShortString(); + String name = recipient.getName(); Bitmap avatar; 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 a0ef945c9a..aa10ce2e4f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpirationListener.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpirationListener.java @@ -8,11 +8,17 @@ import org.thoughtcrime.securesms.ApplicationContext; +/** + * This is a [BroadcastReceiver] that receives the alarm event from the OS. The + * alarm is most likely set off by the disappearing message handling, which only requires the apps + * to be alive. So the whole purpose of this receiver is just to bring the app up (or keep it alive) + * when the alarm goes off. The disappearing message handling will pick themselves up from the + * previous states. + */ public class ExpirationListener extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { - ApplicationContext.getInstance(context).getExpiringMessageManager().checkSchedule(); } public static void setAlarm(Context context, long waitTimeMillis) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt index 207bb2d30a..ac9a18c07e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt @@ -3,14 +3,21 @@ package org.thoughtcrime.securesms.service import android.content.Context import dagger.Lazy import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import network.loki.messenger.libsession_util.util.ExpiryMode -import network.loki.messenger.libsession_util.util.ExpiryMode.AfterSend +import org.session.libsession.messaging.messages.Message 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.messaging.messages.signal.OutgoingGroupMediaMessage +import org.session.libsession.messaging.messages.signal.OutgoingSecureMediaMessage import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.DistributionTypes import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol @@ -21,58 +28,57 @@ import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.database.DatabaseContentProviders +import org.thoughtcrime.securesms.database.MessagingDatabase import org.thoughtcrime.securesms.database.MmsDatabase -import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.mms.MmsException +import org.thoughtcrime.securesms.util.observeChanges import java.io.IOException -import java.util.TreeSet -import java.util.concurrent.Executor -import java.util.concurrent.Executors import javax.inject.Inject import javax.inject.Singleton private val TAG = ExpiringMessageManager::class.java.simpleName +/** + * A manager that reactively looking into the [MmsDatabase] and [SmsDatabase] for expired messages, + * and deleting them. This is done by observing the expiration timestamps of messages and scheduling + * the deletion of them when they are expired. + * + * There is no need (and no way) to ask this manager to schedule a deletion of a message, instead, all you + * need to do is set the expiryMills and expiryStarted fields of the message and save to db, + * this manager will take care of the rest. + */ @Singleton class ExpiringMessageManager @Inject constructor( - @ApplicationContext private val context: Context, + @param:ApplicationContext private val context: Context, private val smsDatabase: SmsDatabase, private val mmsDatabase: MmsDatabase, - private val mmsSmsDatabase: MmsSmsDatabase, private val clock: SnodeClock, private val storage: Lazy, private val preferences: TextSecurePreferences, -) : MessageExpirationManagerProtocol { - private val expiringMessageReferences = TreeSet() - private val executor: Executor = Executors.newSingleThreadExecutor() + @ManagerScope scope: CoroutineScope, +) : MessageExpirationManagerProtocol, OnAppStartupComponent { init { - 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() + scope.launch { + listOf( + launch { processDatabase(smsDatabase) }, + launch { processDatabase(mmsDatabase) } + ).joinAll() } } - fun checkSchedule() { - synchronized(expiringMessageReferences) { (expiringMessageReferences as Object).notifyAll() } - } + private fun getDatabase(mms: Boolean) = if (mms) mmsDatabase else smsDatabase private fun insertIncomingExpirationTimerMessage( message: ExpirationTimerUpdate, - expireStartedAt: Long - ) { + ): MessageId? { val senderPublicKey = message.sender val sentTimestamp = message.sentTimestamp val groupId = message.groupPublicKey @@ -82,8 +88,8 @@ class ExpiringMessageManager @Inject constructor( 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 (recipient.isBlocked && groupId == null) return null + return try { if (groupId != null) { val groupAddress: Address groupInfo = when { @@ -99,16 +105,18 @@ class ExpiringMessageManager @Inject constructor( } recipient = Recipient.from(context, groupAddress, false) } - val threadId = storage.get().getThreadId(recipient) ?: return + val threadId = storage.get().getThreadId(recipient) ?: return null val mediaMessage = IncomingMediaMessage( address, sentTimestamp!!, -1, - expiresInMillis, expireStartedAt, true, - false, + expiresInMillis, + 0, // Marking expiryStartedAt as 0 as expiration logic will be universally applied on received messages + // We no longer set this to true anymore as it won't be used in the future, false, false, Optional.absent(), groupInfo, Optional.absent(), + DisappearingMessageUpdate(message.expiryMode), Optional.absent(), Optional.absent(), Optional.absent(), @@ -116,17 +124,20 @@ class ExpiringMessageManager @Inject constructor( ) //insert the timer update message mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true) + .orNull() + ?.let { MessageId(it.messageId, mms = true) } } catch (ioe: IOException) { Log.e("Loki", "Failed to insert expiration update message.") + null } catch (ioe: MmsException) { Log.e("Loki", "Failed to insert expiration update message.") + null } } private fun insertOutgoingExpirationTimerMessage( message: ExpirationTimerUpdate, - expireStartedAt: Long - ) { + ): MessageId? { val sentTimestamp = message.sentTimestamp val groupId = message.groupPublicKey val duration = message.expiryMode.expiryMillis @@ -140,100 +151,120 @@ class ExpiringMessageManager @Inject constructor( val recipient = Recipient.from(context, address, false) message.threadID = storage.get().getOrCreateThreadIdFor(address) - val timerUpdateMessage = OutgoingExpirationUpdateMessage( + val content = DisappearingMessageUpdate(message.expiryMode) + val timerUpdateMessage = if (groupId != null) OutgoingGroupMediaMessage( + recipient, + "", + groupId, + null, + sentTimestamp!!, + duration, + 0, // Marking as 0 as expiration shouldn't start until we send the message + false, + null, + emptyList(), + emptyList(), + content + ) else OutgoingSecureMediaMessage( recipient, + "", + emptyList(), sentTimestamp!!, + DistributionTypes.CONVERSATION, duration, - expireStartedAt, - groupId + 0, // Marking as 0 as expiration shouldn't start until we send the message + null, + emptyList(), + emptyList(), + content ) - mmsDatabase.insertSecureDecryptedMessageOutbox( + + return mmsDatabase.insertSecureDecryptedMessageOutbox( timerUpdateMessage, message.threadID!!, sentTimestamp, true - ) + ).orNull()?.messageId?.let { MessageId(it, mms = true) } } catch (ioe: MmsException) { Log.e("Loki", "Failed to insert expiration update message.", ioe) + return null } catch (ioe: IOException) { Log.e("Loki", "Failed to insert expiration update message.", ioe) + return null } } override fun insertExpirationTimerMessage(message: ExpirationTimerUpdate) { - val expiryMode: ExpiryMode = message.expiryMode - val userPublicKey = preferences.getLocalNumber() 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) { + message.id = if (senderPublicKey == null || userPublicKey == senderPublicKey) { // sender is self or a linked device - insertOutgoingExpirationTimerMessage(message, expireStartedAt) + insertOutgoingExpirationTimerMessage(message) } else { - insertIncomingExpirationTimerMessage(message, expireStartedAt) + insertIncomingExpirationTimerMessage(message) } - - 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!") + override fun onMessageSent(message: Message) { + // When a message is sent, we'll schedule deletion immediately if we have an expiry mode, + // even if the expiry mode is set to AfterRead, as we don't have a reliable way to know + // that the recipient has read the message at at all. From our perspective it's better + // to disappear the message regardlessly for the safety of ourselves. + // As for the receiver, they will be able to disappear the message correctly after + // they've done reading it. + val messageId = message.id + if (message.expiryMode != ExpiryMode.NONE && messageId != null) { + getDatabase(messageId.mms) + .markExpireStarted(messageId.id, clock.currentTimeMills()) + } } - 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 - ) - } + override fun onMessageReceived(message: Message) { + // When we receive a message, we'll schedule deletion if it has an expiry mode set to + // AfterSend, as the message would be considered sent from the sender's perspective. + val messageId = message.id + if (message.expiryMode is ExpiryMode.AfterSend && messageId != null) { + getDatabase(messageId.mms) + .markExpireStarted(messageId.id, clock.currentTimeMills()) } } - private inner class ProcessTask : Runnable { - override fun run() { - while (true) { - synchronized(expiringMessageReferences) { + private suspend fun processDatabase(db: MessagingDatabase) { + while (true) { + val expiredMessages = db.getExpiredMessageIDs(clock.currentTimeMills()) + + if (expiredMessages.isNotEmpty()) { + Log.d(TAG, "Deleting ${expiredMessages.size} expired messages from ${db.javaClass.simpleName}") + for (messageId in expiredMessages) { try { - while (expiringMessageReferences.isEmpty()) (expiringMessageReferences as Object).wait() - val nextReference = expiringMessageReferences.first() - val waitTime = nextReference.expiresAtMillis - clock.currentTimeMills() - 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 + db.deleteMessage(messageId) + } catch (e: Exception) { + Log.e(TAG, "Failed to delete expired message with ID $messageId", e) } - }?.run { getDatabase(mms).deleteMessage(id) } + } + } + + val nextExpiration = db.nextExpiringTimestamp + val now = clock.currentTimeMills() + + if (nextExpiration > 0 && nextExpiration <= now) { + continue // Proceed to the next iteration if the next expiration is already or about go to in the past } - } - } - private data class ExpiringMessageReference( - val id: Long, - val mms: Boolean, - val expiresAtMillis: Long - ): Comparable { - override fun compareTo(other: ExpiringMessageReference) = compareValuesBy(this, other, { it.expiresAtMillis }, { it.id }, { it.mms }) + val dbChanges = context.contentResolver.observeChanges(DatabaseContentProviders.Conversation.CONTENT_URI, true) + + if (nextExpiration > 0) { + val delayMills = nextExpiration - now + Log.d(TAG, "Wait for up to $delayMills ms for next expiration in ${db.javaClass.simpleName}") + withTimeoutOrNull(delayMills) { + dbChanges.first() + } + } else { + Log.d(TAG, "No next expiration found, waiting for any change in ${db.javaClass.simpleName}") + // If there are no next expiration, just wait for any change in the database + dbChanges.first() + } + } } } 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 dc22bf5e83..932498a158 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java @@ -39,6 +39,8 @@ import androidx.core.app.ServiceCompat; import com.squareup.phrase.Phrase; import java.util.concurrent.TimeUnit; + +import network.loki.messenger.BuildConfig; import network.loki.messenger.R; import org.session.libsession.utilities.ServiceUtil; import org.session.libsession.utilities.TextSecurePreferences; @@ -62,7 +64,7 @@ public class KeyCachingService extends Service { public static final int SERVICE_RUNNING_ID = 4141; - public static final String KEY_PERMISSION = "network.loki.messenger.ACCESS_SESSION_SECRETS"; + public static final String KEY_PERMISSION = "network.loki.messenger.ACCESS_SESSION_SECRETS" + BuildConfig.AUTHORITY_POSTFIX; public static final String CLEAR_KEY_EVENT = "org.thoughtcrime.securesms.service.action.CLEAR_KEY_EVENT"; public static final String LOCK_TOGGLED_EVENT = "org.thoughtcrime.securesms.service.action.LOCK_ENABLED_EVENT"; private static final String PASSPHRASE_EXPIRED_EVENT = "org.thoughtcrime.securesms.service.action.PASSPHRASE_EXPIRED_EVENT"; @@ -248,11 +250,11 @@ private void foregroundService() { .put(APP_NAME_KEY, c.getString(R.string.app_name)) .format().toString(); builder.setContentTitle(unlockedTxt); - builder.setSmallIcon(R.drawable.icon_cached); + builder.setSmallIcon(R.drawable.ic_lock_keyhole_open); builder.setWhen(0); builder.setPriority(Notification.PRIORITY_MIN); - builder.addAction(R.drawable.ic_menu_lock_dark, getString(R.string.lockApp), buildLockIntent()); + builder.addAction(R.drawable.ic_lock_keyhole, getString(R.string.lockApp), buildLockIntent()); builder.setContentIntent(buildLaunchIntent()); stopForeground(true); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/TelephonyHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/service/TelephonyHandler.kt deleted file mode 100644 index 3e78c223fa..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/service/TelephonyHandler.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.thoughtcrime.securesms.service - -import android.os.Build -import android.telephony.PhoneStateListener -import android.telephony.PhoneStateListener.LISTEN_NONE -import android.telephony.TelephonyManager -import androidx.annotation.RequiresApi -import org.thoughtcrime.securesms.webrtc.HangUpRtcOnPstnCallAnsweredListener -import org.thoughtcrime.securesms.webrtc.HangUpRtcTelephonyCallback -import java.util.concurrent.ExecutorService - -internal interface TelephonyHandler { - fun register(telephonyManager: TelephonyManager) - fun unregister(telephonyManager: TelephonyManager) -} - -internal fun TelephonyHandler(serviceExecutor: ExecutorService, callback: () -> Unit) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - TelephonyHandlerV31(serviceExecutor, callback) -} else { - TelephonyHandlerV23(callback) -} - -@RequiresApi(Build.VERSION_CODES.S) -private class TelephonyHandlerV31(val serviceExecutor: ExecutorService, callback: () -> Unit): TelephonyHandler { - private val callback = HangUpRtcTelephonyCallback(callback) - - override fun register(telephonyManager: TelephonyManager) { - telephonyManager.registerTelephonyCallback(serviceExecutor, callback) - } - - override fun unregister(telephonyManager: TelephonyManager) { - telephonyManager.unregisterTelephonyCallback(callback) - } -} - -private class TelephonyHandlerV23(callback: () -> Unit): TelephonyHandler { - val callback = HangUpRtcOnPstnCallAnsweredListener(callback) - - override fun register(telephonyManager: TelephonyManager) { - telephonyManager.listen(callback, PhoneStateListener.LISTEN_CALL_STATE) - } - - override fun unregister(telephonyManager: TelephonyManager) { - telephonyManager.listen(callback, LISTEN_NONE) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt deleted file mode 100644 index 6f55dde70e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt +++ /dev/null @@ -1,1004 +0,0 @@ -package org.thoughtcrime.securesms.service - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP -import android.content.Intent.FLAG_ACTIVITY_NEW_TASK -import android.content.IntentFilter -import android.content.pm.PackageManager -import android.content.pm.ServiceInfo -import android.media.AudioManager -import android.os.Build -import android.os.ResultReceiver -import android.telephony.TelephonyManager -import androidx.core.app.ServiceCompat -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 kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import java.util.UUID -import java.util.concurrent.ExecutionException -import java.util.concurrent.Executors -import java.util.concurrent.ScheduledFuture -import java.util.concurrent.TimeUnit -import javax.inject.Inject -import org.session.libsession.messaging.calls.CallMessageType -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.FutureTaskListener -import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.ApplicationContext -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.util.CallNotificationBuilder.Companion.WEBRTC_NOTIFICATION -import org.thoughtcrime.securesms.util.NetworkConnectivity -import org.thoughtcrime.securesms.webrtc.AudioManagerCommand -import org.thoughtcrime.securesms.webrtc.CallManager -import org.thoughtcrime.securesms.webrtc.CallViewModel -import org.thoughtcrime.securesms.webrtc.IncomingPstnCallReceiver -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.audio.OutgoingRinger -import org.thoughtcrime.securesms.webrtc.data.Event -import org.thoughtcrime.securesms.webrtc.data.State as CallState -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 - -@AndroidEntryPoint -class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { - - companion object { - - private val TAG = Log.tag(WebRtcCallService::class.java) - - const val ACTION_INCOMING_RING = "RING_INCOMING" - const val ACTION_OUTGOING_CALL = "CALL_OUTGOING" - const val ACTION_ANSWER_CALL = "ANSWER_CALL" - const val ACTION_DENY_CALL = "DENY_CALL" - const val ACTION_LOCAL_HANGUP = "LOCAL_HANGUP" - const val ACTION_SET_MUTE_AUDIO = "SET_MUTE_AUDIO" - const val ACTION_SET_MUTE_VIDEO = "SET_MUTE_VIDEO" - const val ACTION_FLIP_CAMERA = "FLIP_CAMERA" - const val ACTION_UPDATE_AUDIO = "UPDATE_AUDIO" - const val ACTION_WIRED_HEADSET_CHANGE = "WIRED_HEADSET_CHANGE" - const val ACTION_SCREEN_OFF = "SCREEN_OFF" - const val ACTION_CHECK_TIMEOUT = "CHECK_TIMEOUT" - const val ACTION_CHECK_RECONNECT = "CHECK_RECONNECT" - const val ACTION_CHECK_RECONNECT_TIMEOUT = "CHECK_RECONNECT_TIMEOUT" - const val ACTION_IS_IN_CALL_QUERY = "IS_IN_CALL" - const val ACTION_WANTS_TO_ANSWER = "WANTS_TO_ANSWER" - - const val ACTION_PRE_OFFER = "PRE_OFFER" - const val ACTION_RESPONSE_MESSAGE = "RESPONSE_MESSAGE" - const val ACTION_ICE_MESSAGE = "ICE_MESSAGE" - const val ACTION_REMOTE_HANGUP = "REMOTE_HANGUP" - const val ACTION_ICE_CONNECTED = "ICE_CONNECTED" - - const val EXTRA_RECIPIENT_ADDRESS = "RECIPIENT_ID" - const val EXTRA_ENABLED = "ENABLED" - const val EXTRA_AUDIO_COMMAND = "AUDIO_COMMAND" - const val EXTRA_SWAPPED = "is_video_swapped" - const val EXTRA_MUTE = "mute_value" - const val EXTRA_AVAILABLE = "enabled_value" - const val EXTRA_REMOTE_DESCRIPTION = "remote_description" - const val EXTRA_TIMESTAMP = "timestamp" - const val EXTRA_CALL_ID = "call_id" - const val EXTRA_ICE_SDP = "ice_sdp" - const val EXTRA_ICE_SDP_MID = "ice_sdp_mid" - const val EXTRA_ICE_SDP_LINE_INDEX = "ice_sdp_line_index" - const val EXTRA_RESULT_RECEIVER = "result_receiver" - const val EXTRA_WANTS_TO_ANSWER = "wants_to_answer" - - const val INVALID_NOTIFICATION_ID = -1 - private const val TIMEOUT_SECONDS = 30L - private const val RECONNECT_SECONDS = 5L - private const val MAX_RECONNECTS = 5 - - 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) - - fun acceptCallIntent(context: Context) = Intent(context, WebRtcCallService::class.java) - .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 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 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) - - 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) - - fun iceCandidates( - context: Context, - address: Address, - iceCandidates: List, - 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 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 sendAudioManagerCommand(context: Context, command: AudioManagerCommand) { - val intent = Intent(context, WebRtcCallService::class.java) - .setAction(ACTION_UPDATE_AUDIO) - .putExtra(EXTRA_AUDIO_COMMAND, command) - context.startService(intent) - } - - fun broadcastWantsToAnswer(context: Context, wantsToAnswer: Boolean) { - 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) - context.startService(intent) - } - } - - @Inject - lateinit var callManager: CallManager - - @Inject - lateinit var networkConnectivity: NetworkConnectivity - - private var wantsToAnswer = false - private var currentTimeouts = 0 - private var isNetworkAvailable = true - private var scheduledTimeout: ScheduledFuture<*>? = null - private var scheduledReconnect: ScheduledFuture<*>? = null - - private val lockManager by lazy { LockManager(this) } - private val serviceExecutor = Executors.newSingleThreadExecutor() - private val timeoutExecutor = Executors.newScheduledThreadPool(1) - - private val telephonyHandler = TelephonyHandler(serviceExecutor) { - ContextCompat.startForegroundService(this, hangupIntent(this)) - } - - private var callReceiver: IncomingPstnCallReceiver? = null - private var wantsToAnswerReceiver: BroadcastReceiver? = null - private var wiredHeadsetStateReceiver: WiredHeadsetStateReceiver? = null - private var uncaughtExceptionHandlerManager: UncaughtExceptionHandlerManager? = null - private var powerButtonReceiver: PowerButtonReceiver? = null - - @Synchronized - private fun terminate() { - LocalBroadcastManager.getInstance(this).sendBroadcast(Intent(WebRtcCallActivity.ACTION_END)) - lockManager.updatePhoneState(LockManager.PhoneState.IDLE) - callManager.stop() - wantsToAnswer = false - currentTimeouts = 0 - isNetworkAvailable = true - scheduledTimeout?.cancel(false) - scheduledReconnect?.cancel(false) - scheduledTimeout = null - scheduledReconnect = null - - lifecycleScope.launchWhenCreated { - stopForeground(true) - } - } - - private fun isSameCall(intent: Intent): Boolean { - val expectedCallId = getCallId(intent) - return callManager.callId == expectedCallId - } - - private fun isPreOffer() = callManager.isPreOffer() - - private fun isBusy(intent: Intent) = callManager.isBusy(this, getCallId(intent)) - - private fun isIdle() = callManager.isIdle() - - override fun onHangup() { - serviceExecutor.execute { - callManager.handleRemoteHangup() - - if (callManager.currentConnectionState in CallState.CAN_DECLINE_STATES) { - callManager.recipient?.let { recipient -> - insertMissedCall(recipient, true) - } - } - terminate() - } - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) - if (intent == null || intent.action == null) return START_NOT_STICKY - serviceExecutor.execute { - val action = 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_PRE_OFFER -> if (isIdle()) handlePreOffer(intent) - ACTION_INCOMING_RING -> when { - isSameCall(intent) && callManager.currentConnectionState == CallState.Reconnecting -> { - handleNewOffer(intent) - } - isBusy(intent) -> handleBusyCall(intent) - isPreOffer() -> handleIncomingRing(intent) - } - ACTION_OUTGOING_CALL -> if (isIdle()) handleOutgoingCall(intent) - ACTION_ANSWER_CALL -> handleAnswerCall(intent) - ACTION_DENY_CALL -> handleDenyCall(intent) - ACTION_LOCAL_HANGUP -> handleLocalHangup(intent) - ACTION_REMOTE_HANGUP -> handleRemoteHangup(intent) - ACTION_SET_MUTE_AUDIO -> handleSetMuteAudio(intent) - ACTION_SET_MUTE_VIDEO -> handleSetMuteVideo(intent) - ACTION_FLIP_CAMERA -> handleSetCameraFlip(intent) - ACTION_WIRED_HEADSET_CHANGE -> handleWiredHeadsetChanged(intent) - ACTION_SCREEN_OFF -> handleScreenOffChange(intent) - ACTION_RESPONSE_MESSAGE -> handleResponseMessage(intent) - ACTION_ICE_MESSAGE -> handleRemoteIceCandidate(intent) - ACTION_ICE_CONNECTED -> handleIceConnected(intent) - ACTION_CHECK_TIMEOUT -> handleCheckTimeout(intent) - ACTION_CHECK_RECONNECT -> handleCheckReconnect(intent) - ACTION_IS_IN_CALL_QUERY -> handleIsInCallQuery(intent) - ACTION_UPDATE_AUDIO -> handleUpdateAudio(intent) - } - } - return START_NOT_STICKY - } - - override fun onCreate() { - super.onCreate() - callManager.registerListener(this) - wantsToAnswer = false - isNetworkAvailable = true - registerIncomingPstnCallReceiver() - registerWiredHeadsetStateReceiver() - registerWantsToAnswerReceiver() - if (checkSelfPermission(android.Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) { - telephonyHandler.register(getSystemService(TelephonyManager::class.java)) - } - registerUncaughtExceptionHandler() - - GlobalScope.launch { - networkConnectivity.networkAvailable.collectLatest(::networkChange) - } - } - - private fun registerUncaughtExceptionHandler() { - uncaughtExceptionHandlerManager = UncaughtExceptionHandlerManager().apply { - registerHandler(ProximityLockRelease(lockManager)) - } - } - - private fun registerIncomingPstnCallReceiver() { - callReceiver = IncomingPstnCallReceiver() - registerReceiver(callReceiver, IntentFilter("android.intent.action.PHONE_STATE")) - } - - private fun registerWantsToAnswerReceiver() { - val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - wantsToAnswer = intent?.getBooleanExtra(EXTRA_WANTS_TO_ANSWER, false) ?: false - } - } - wantsToAnswerReceiver = receiver - LocalBroadcastManager.getInstance(this) - .registerReceiver(receiver, IntentFilter(ACTION_WANTS_TO_ANSWER)) - } - - private fun registerWiredHeadsetStateReceiver() { - wiredHeadsetStateReceiver = WiredHeadsetStateReceiver() - registerReceiver(wiredHeadsetStateReceiver, IntentFilter(AudioManager.ACTION_HEADSET_PLUG)) - } - - private fun handleBusyCall(intent: Intent) { - val recipient = getRemoteRecipient(intent) - val callState = callManager.currentConnectionState - - insertMissedCall(recipient, false) - - if (callState == CallState.Idle) { - lifecycleScope.launchWhenCreated { - stopForeground(true) - } - } - } - - private fun handleUpdateAudio(intent: Intent) { - val audioCommand = intent.getParcelableExtra(EXTRA_AUDIO_COMMAND)!! - if (callManager.currentConnectionState !in arrayOf( - CallState.Connected, - *CallState.PENDING_CONNECTION_STATES - ) - ) { - Log.w(TAG, "handling audio command not in call") - return - } - callManager.handleAudioCommand(audioCommand) - } - - private fun handleNewOffer(intent: Intent) { - val offer = intent.getStringExtra(EXTRA_REMOTE_DESCRIPTION) ?: return - val callId = getCallId(intent) - val recipient = getRemoteRecipient(intent) - callManager.onNewOffer(offer, callId, recipient).fail { - Log.e("Loki", "Error handling new offer", it) - callManager.postConnectionError() - terminate() - } - } - - private fun handlePreOffer(intent: Intent) { - if (!callManager.isIdle()) { - Log.w(TAG, "Handling pre-offer from non-idle state") - return - } - val callId = getCallId(intent) - val recipient = getRemoteRecipient(intent) - - if (isIncomingMessageExpired(intent)) { - insertMissedCall(recipient, true) - terminate() - return - } - - callManager.onPreOffer(callId, recipient) { - setCallInProgressNotification(TYPE_INCOMING_PRE_OFFER, recipient) - callManager.postViewModelState(CallViewModel.State.CALL_PRE_INIT) - callManager.initializeAudioForCall() - callManager.startIncomingRinger() - callManager.setAudioEnabled(true) - - BackgroundPollWorker.scheduleOnce( - this, - listOf(BackgroundPollWorker.Target.ONE_TO_ONE) - ) - } - } - - private fun handleIncomingRing(intent: Intent) { - val callId = getCallId(intent) - val recipient = getRemoteRecipient(intent) - val preOffer = callManager.preOfferCallData - - if (callManager.isPreOffer() && (preOffer == null || preOffer.callId != callId || preOffer.recipient != recipient)) { - Log.d(TAG, "Incoming ring from non-matching pre-offer") - return - } - - val offer = intent.getStringExtra(EXTRA_REMOTE_DESCRIPTION) ?: return - val timestamp = intent.getLongExtra(EXTRA_TIMESTAMP, -1) - - callManager.onIncomingRing(offer, callId, recipient, timestamp) { - if (wantsToAnswer) { - setCallInProgressNotification(TYPE_INCOMING_CONNECTING, recipient) - } else { - setCallInProgressNotification(TYPE_INCOMING_RINGING, recipient) - } - callManager.clearPendingIceUpdates() - callManager.postViewModelState(CallViewModel.State.CALL_RINGING) - registerPowerButtonReceiver() - } - } - - private fun handleOutgoingCall(intent: Intent) { - callManager.postConnectionEvent(Event.SendPreOffer) { - val recipient = getRemoteRecipient(intent) - callManager.recipient = recipient - val callId = UUID.randomUUID() - callManager.callId = callId - - callManager.initializeVideo(this) - - callManager.postViewModelState(CallViewModel.State.CALL_OUTGOING) - lockManager.updatePhoneState(LockManager.PhoneState.IN_CALL) - 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.setAudioEnabled(true) - - val expectedState = callManager.currentConnectionState - val expectedCallId = callManager.callId - - try { - val offerFuture = callManager.onOutgoingCall(this) - offerFuture.fail { 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) - callManager.postConnectionError() - terminate() - } - } - } - - private fun handleAnswerCall(intent: Intent) { - 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) { - Log.e(TAG, "Can only answer from ringing!") - return - } - - intent.putExtra(EXTRA_CALL_ID, callId) - intent.putExtra(EXTRA_RECIPIENT_ADDRESS, recipient.address) - intent.putExtra(EXTRA_REMOTE_DESCRIPTION, pending) - intent.putExtra(EXTRA_TIMESTAMP, timestamp) - - if (isIncomingMessageExpired(intent)) { - val didHangup = callManager.postConnectionEvent(Event.TimeOut) { - insertMissedCall(recipient, true) - terminate() - } - if (didHangup) { return } - } - - callManager.postConnectionEvent(Event.SendAnswer) { - setCallInProgressNotification(TYPE_INCOMING_CONNECTING, recipient) - - callManager.silenceIncomingRinger() - callManager.postViewModelState(CallViewModel.State.CALL_INCOMING) - - scheduledTimeout = timeoutExecutor.schedule( - TimeoutRunnable(callId, this), - TIMEOUT_SECONDS, - TimeUnit.SECONDS - ) - - callManager.initializeAudioForCall() - callManager.initializeVideo(this) - - val expectedState = callManager.currentConnectionState - val expectedCallId = callManager.callId - - try { - val answerFuture = callManager.onIncomingCall(this) - answerFuture.fail { e -> - if (isConsistentState( - expectedState, - expectedCallId, - callManager.currentConnectionState, - callManager.callId - ) - ) { - Log.e(TAG, e) - insertMissedCall(recipient, true) - callManager.postConnectionError() - terminate() - } - } - lockManager.updatePhoneState(LockManager.PhoneState.PROCESSING) - callManager.setAudioEnabled(true) - } catch (e: Exception) { - Log.e(TAG, e) - callManager.postConnectionError() - terminate() - } - } - } - - private fun handleDenyCall(intent: Intent) { - callManager.handleDenyCall() - terminate() - } - - private fun handleLocalHangup(intent: Intent) { - val intentRecipient = getOptionalRemoteRecipient(intent) - callManager.handleLocalHangup(intentRecipient) - terminate() - } - - private fun handleRemoteHangup(intent: Intent) { - if (callManager.callId != getCallId(intent)) { - Log.e(TAG, "Hangup for non-active call...") - lifecycleScope.launchWhenCreated { - stopForeground(true) - } - return - } - - onHangup() - } - - private fun handleSetMuteAudio(intent: Intent) { - val muted = intent.getBooleanExtra(EXTRA_MUTE, false) - callManager.handleSetMuteAudio(muted) - } - - private fun handleSetMuteVideo(intent: Intent) { - val muted = intent.getBooleanExtra(EXTRA_MUTE, false) - callManager.handleSetMuteVideo(muted, lockManager) - } - - private fun handleSetCameraFlip(intent: Intent) { - callManager.handleSetCameraFlip() - } - - private fun handleWiredHeadsetChanged(intent: Intent) { - callManager.handleWiredHeadsetChanged(intent.getBooleanExtra(EXTRA_AVAILABLE, false)) - } - - private fun handleScreenOffChange(intent: Intent) { - callManager.handleScreenOffChange() - } - - private fun handleResponseMessage(intent: Intent) { - try { - val recipient = getRemoteRecipient(intent) - if (callManager.isCurrentUser(recipient) && callManager.currentConnectionState in CallState.CAN_DECLINE_STATES) { - handleLocalHangup(intent) - return - } - val callId = getCallId(intent) - val description = intent.getStringExtra(EXTRA_REMOTE_DESCRIPTION) - callManager.handleResponseMessage( - recipient, - callId, - SessionDescription(SessionDescription.Type.ANSWER, description) - ) - } catch (e: PeerConnectionException) { - terminate() - } - } - - /** - * Handles remote ICE candidates received from a signaling server. - * - * This function is called when a new ICE candidate is received for a specific call. - * It extracts the candidate information from the intent, creates IceCandidate objects, - * and passes them to the CallManager to be added to the PeerConnection. - * - * @param intent The intent containing the remote ICE candidate information. - * The intent should contain the following extras: - * - EXTRA_CALL_ID: The ID of the call. - * - EXTRA_ICE_SDP_MID: An array of SDP media stream identification strings. - * - EXTRA_ICE_SDP_LINE_INDEX: An array of SDP media line indexes. - * - EXTRA_ICE_SDP: An array of SDP candidate strings. - */ - private fun handleRemoteIceCandidate(intent: Intent) { - val callId = getCallId(intent) - val sdpMids = intent.getStringArrayExtra(EXTRA_ICE_SDP_MID) ?: return - 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") - return - } - val iceCandidates = sdpMids.indices.map { index -> - IceCandidate( - sdpMids[index], - sdpLineIndexes[index], - sdps[index] - ) - } - callManager.handleRemoteIceCandidate(iceCandidates, callId) - } - - private fun handleIceConnected(intent: Intent) { - val recipient = callManager.recipient ?: return - val connected = callManager.postConnectionEvent(Event.Connect) { - callManager.postViewModelState(CallViewModel.State.CALL_CONNECTED) - setCallInProgressNotification(TYPE_ESTABLISHED, recipient) - callManager.startCommunication(lockManager) - } - if (!connected) { - Log.e("Loki", "Error handling ice connected state transition") - callManager.postConnectionError() - terminate() - } - } - - private fun handleIsInCallQuery(intent: Intent) { - val listener = intent.getParcelableExtra(EXTRA_RESULT_RECEIVER) ?: return - val currentState = callManager.currentConnectionState - 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)) - } - } - - private fun handleCheckReconnect(intent: Intent) { - val callId = callManager.callId ?: return - val numTimeouts = ++currentTimeouts - - 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 - ) - } 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 - ) - } 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 - )) - ) { - Log.w(TAG, "Timing out call: $callId") - handleLocalHangup(intent) - } - } - - - - // Over the course of setting up a phone call this method is called multiple times with `types` - // of PRE_OFFER -> RING_INCOMING -> ICE_MESSAGE - private fun setCallInProgressNotification(type: Int, recipient: Recipient?) { - // Wake the device if needed - (applicationContext as ApplicationContext).wakeUpDeviceAndDismissKeyguardIfRequired() - - // If notifications are enabled we'll try and start a foreground service to show the notification - var failedToStartForegroundService = false - if (CallNotificationBuilder.areNotificationsEnabled(this)) { - try { - ServiceCompat.startForeground( - this, - WEBRTC_NOTIFICATION, - CallNotificationBuilder.getCallInProgressNotification(this, type, recipient), - if (Build.VERSION.SDK_INT >= 30) ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL else 0 - ) - return - } catch (e: IllegalStateException) { - Log.e(TAG, "Failed to setCallInProgressNotification as a foreground service for type: ${type}, trying to update instead", e) - failedToStartForegroundService = true - } - } else { - // Notifications are NOT enabled! Skipped attempt at startForeground and going straight to fullscreen intent attempt! - } - - if ((type == TYPE_INCOMING_PRE_OFFER || type == TYPE_INCOMING_RINGING) && failedToStartForegroundService) { - // Start an intent for the fullscreen call activity - val foregroundIntent = Intent(this, WebRtcCallActivity::class.java) - .setFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP) - .setAction(WebRtcCallActivity.ACTION_FULL_SCREEN_INTENT) - startActivity(foregroundIntent) - return - } - } - - private fun getOptionalRemoteRecipient(intent: Intent): Recipient? = - intent.takeIf { it.hasExtra(EXTRA_RECIPIENT_ADDRESS) }?.let(::getRemoteRecipient) - - private fun getRemoteRecipient(intent: Intent): Recipient { - val remoteAddress = intent.getParcelableExtra
(EXTRA_RECIPIENT_ADDRESS) - ?: throw AssertionError("No recipient in intent!") - - return Recipient.from(this, remoteAddress, true) - } - - private fun getCallId(intent: Intent): UUID = - intent.getSerializableExtra(EXTRA_CALL_ID) as? UUID - ?: throw AssertionError("No callId in intent!") - - private fun insertMissedCall(recipient: Recipient, signal: Boolean) { - callManager.insertCallMessage( - threadPublicKey = recipient.address.serialize(), - callMessageType = CallMessageType.CALL_MISSED, - signal = signal - ) - } - - private fun isIncomingMessageExpired(intent: Intent) = - System.currentTimeMillis() - intent.getLongExtra( - EXTRA_TIMESTAMP, - -1 - ) > TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS) - - override fun onDestroy() { - Log.d(TAG, "onDestroy()") - callManager.unregisterListener(this) - callReceiver?.let { receiver -> - unregisterReceiver(receiver) - } - wiredHeadsetStateReceiver?.let(::unregisterReceiver) - powerButtonReceiver?.let(::unregisterReceiver) - wantsToAnswerReceiver?.let { receiver -> - LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver) - } - callManager.shutDownAudioManager() - powerButtonReceiver = null - wiredHeadsetStateReceiver = null - callReceiver = null - uncaughtExceptionHandlerManager?.unregister() - wantsToAnswer = false - currentTimeouts = 0 - isNetworkAvailable = false - if (checkSelfPermission(android.Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) { - telephonyHandler.unregister(getSystemService(TelephonyManager::class.java)) - } - super.onDestroy() - } - - private fun networkChange(networkAvailable: Boolean) { - Log.d("Loki", "flipping network available to $networkAvailable") - isNetworkAvailable = networkAvailable - if (networkAvailable && callManager.currentConnectionState == CallState.Connected) { - Log.d("Loki", "Should reconnected") - } - } - - 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) - .putExtra(EXTRA_CALL_ID, callId) - context.startService(intent) - } - } - - 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) - context.startService(intent) - } - } - - private abstract class FailureListener( - expectedState: CallState, - expectedCallId: UUID?, - getState: () -> Pair - ) : StateAwareListener(expectedState, expectedCallId, getState) { - override fun onSuccessContinue(result: V) {} - } - - private abstract class SuccessOnlyListener( - expectedState: CallState, - expectedCallId: UUID?, - getState: () -> Pair - ) : StateAwareListener(expectedState, expectedCallId, getState) { - override fun onFailureContinue(throwable: Throwable?) { - Log.e(TAG, throwable) - throw AssertionError(throwable) - } - } - - private abstract class StateAwareListener( - private val expectedState: CallState, - private val expectedCallId: UUID?, - private val getState: () -> Pair - ) : FutureTaskListener { - - companion object { - private val TAG = Log.tag(StateAwareListener::class.java) - } - - override fun onSuccess(result: V) { - if (!isConsistentState()) { - Log.w(TAG, "State has changed since request, aborting success callback...") - } else { - onSuccessContinue(result) - } - } - - override fun onFailure(exception: ExecutionException?) { - if (!isConsistentState()) { - Log.w(TAG, exception) - Log.w(TAG, "State has changed since request, aborting failure callback...") - } else { - exception?.let { - onFailureContinue(it.cause) - } - } - } - - private fun isConsistentState(): Boolean { - val (currentState, currentCallId) = getState() - return expectedState == currentState && expectedCallId == currentCallId - } - - abstract fun onSuccessContinue(result: V) - abstract fun onFailureContinue(throwable: Throwable?) - - } - - private fun isConsistentState( - expectedState: CallState, - expectedCallId: UUID?, - currentState: CallState, - currentCallId: UUID? - ): Boolean { - return expectedState == currentState && expectedCallId == currentCallId - } - - override fun onSignalingChange(p0: PeerConnection.SignalingState?) {} - - override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) { - newState?.let { state -> processIceConnectionChange(state) } - } - - private fun processIceConnectionChange(newState: PeerConnection.IceConnectionState) { - serviceExecutor.execute { - if (newState == CONNECTED) { - scheduledTimeout?.cancel(false) - scheduledReconnect?.cancel(false) - scheduledTimeout = null - scheduledReconnect = null - - val intent = Intent(this, WebRtcCallService::class.java) - .setAction(ACTION_ICE_CONNECTED) - startService(intent) - } 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 - ) - } else { - Log.i("Loki", "Starting timeout, awaiting new reconnect") - callManager.postConnectionEvent(Event.PrepareForNewOffer) { - scheduledTimeout = timeoutExecutor.schedule( - TimeoutRunnable(callId, this), - TIMEOUT_SECONDS, - TimeUnit.SECONDS - ) - } - } - } - } ?: run { - val intent = hangupIntent(this) - startService(intent) - } - } - Log.i("Loki", "onIceConnectionChange: $newState") - } - } - - override fun onIceConnectionReceivingChange(p0: Boolean) {} - - override fun onIceGatheringChange(p0: PeerConnection.IceGatheringState?) {} - - override fun onIceCandidate(p0: IceCandidate?) {} - - override fun onIceCandidatesRemoved(p0: Array?) {} - - override fun onAddStream(p0: MediaStream?) {} - - override fun onRemoveStream(p0: MediaStream?) {} - - override fun onDataChannel(p0: DataChannel?) {} - - override fun onRenegotiationNeeded() { - Log.w(TAG, "onRenegotiationNeeded was called!") - } - - override fun onAddTrack(p0: RtpReceiver?, p1: Array?) {} -} \ No newline at end of file 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 a86e932970..da9258d710 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt @@ -11,6 +11,7 @@ import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.upsertContact import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.database.RecipientDatabase @@ -31,7 +32,7 @@ class ProfileManager @Inject constructor( override fun setNickname(context: Context, recipient: Recipient, nickname: String?) { if (recipient.isLocalNumber) return - val accountID = recipient.address.serialize() + val accountID = recipient.address.toString() var contact = contactDatabase.getContactWithAccountID(accountID) if (contact == null) contact = Contact(accountID) contact.threadID = storage.get().getThreadId(recipient.address) @@ -45,7 +46,7 @@ class ProfileManager @Inject constructor( override fun setName(context: Context, recipient: Recipient, name: String?) { // New API if (recipient.isLocalNumber) return - val accountID = recipient.address.serialize() + val accountID = recipient.address.toString() var contact = contactDatabase.getContactWithAccountID(accountID) if (contact == null) contact = Contact(accountID) contact.threadID = storage.get().getThreadId(recipient.address) @@ -72,7 +73,7 @@ class ProfileManager @Inject constructor( recipient.resolve() - val accountID = recipient.address.serialize() + val accountID = recipient.address.toString() var contact = contactDatabase.getContactWithAccountID(accountID) if (contact == null) contact = Contact(accountID) contact.threadID = storage.get().getThreadId(recipient.address) @@ -88,14 +89,10 @@ class ProfileManager @Inject constructor( } } - override fun setUnidentifiedAccessMode(context: Context, recipient: Recipient, unidentifiedAccessMode: Recipient.UnidentifiedAccessMode) { - recipientDatabase.setUnidentifiedAccessMode(recipient, unidentifiedAccessMode) - } override fun contactUpdatedInternal(contact: Contact): String? { if (contact.accountID == preferences.getLocalNumber()) return null - val accountId = AccountId(contact.accountID) - if (accountId.prefix != IdPrefix.STANDARD) return null // only internally store standard account IDs + if (IdPrefix.fromValue(contact.accountID) != IdPrefix.STANDARD) return null // only internally store standard account IDs return configFactory.withMutableUserConfigs { val contactConfig = it.contacts contactConfig.upsertContact(contact.accountID) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt index 131657c43b..599ef2041c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.sskenvironment -import android.content.Context import org.session.libsession.utilities.Address import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences @@ -16,7 +15,11 @@ class ReadReceiptManager @Inject constructor( private val mmsSmsDatabase: MmsSmsDatabase, ): SSKEnvironment.ReadReceiptManagerProtocol { - override fun processReadReceipts(context: Context, fromRecipientId: String, sentTimestamps: List, readTimestamp: Long) { + override fun processReadReceipts( + fromRecipientId: String, + sentTimestamps: List, + readTimestamp: Long + ) { if (textSecurePreferences.isReadReceiptsEnabled()) { // Redirect message to master device conversation 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 09cc276a27..2bd50d5023 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/TypingStatusRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/TypingStatusRepository.java @@ -30,6 +30,8 @@ import javax.inject.Inject; import javax.inject.Singleton; +import dagger.hilt.android.qualifiers.ApplicationContext; + @SuppressLint("UseSparseArrays") @Singleton public class TypingStatusRepository implements SSKEnvironment.TypingIndicatorsProtocol { @@ -43,28 +45,30 @@ public class TypingStatusRepository implements SSKEnvironment.TypingIndicatorsPr private final Map> notifiers; private final MutableLiveData> threadsNotifier; private final TextSecurePreferences preferences; + private final Context appContext; @Inject - public TypingStatusRepository(TextSecurePreferences preferences) { + public TypingStatusRepository(@ApplicationContext Context appContext, TextSecurePreferences preferences) { this.typistMap = new HashMap<>(); this.timers = new HashMap<>(); this.notifiers = new HashMap<>(); this.threadsNotifier = new MutableLiveData<>(); this.preferences = preferences; + this.appContext = appContext; } @Override - public synchronized void didReceiveTypingStartedMessage(@NotNull Context context, long threadId, @NotNull Address author, int device) { - if (author.serialize().equals(preferences.getLocalNumber())) { + public synchronized void didReceiveTypingStartedMessage(long threadId, @NotNull Address author, int device) { + if (author.toString().equals(preferences.getLocalNumber())) { return; } - if (Recipient.from(context, author, false).isBlocked()) { + if (Recipient.from(appContext, author, false).isBlocked()) { return; } Set typists = Util.getOrDefault(typistMap, threadId, new LinkedHashSet<>()); - Typist typist = new Typist(Recipient.from(context, author, false), device, threadId); + Typist typist = new Typist(Recipient.from(appContext, author, false), device, threadId); if (!typists.contains(typist)) { typists.add(typist); @@ -77,23 +81,23 @@ public synchronized void didReceiveTypingStartedMessage(@NotNull Context context Util.cancelRunnableOnMain(timer); } - timer = () -> didReceiveTypingStoppedMessage(context, threadId, author, device, false); + timer = () -> didReceiveTypingStoppedMessage(threadId, author, device, false); Util.runOnMainDelayed(timer, RECIPIENT_TYPING_TIMEOUT); timers.put(typist, timer); } @Override - public synchronized void didReceiveTypingStoppedMessage(@NotNull Context context, long threadId, @NotNull Address author, int device, boolean isReplacedByIncomingMessage) { - if (author.serialize().equals(preferences.getLocalNumber())) { + public synchronized void didReceiveTypingStoppedMessage(long threadId, @NotNull Address author, int device, boolean isReplacedByIncomingMessage) { + if (author.toString().equals(preferences.getLocalNumber())) { return; } - if (Recipient.from(context, author, false).isBlocked()) { + if (Recipient.from(appContext, author, false).isBlocked()) { return; } Set typists = Util.getOrDefault(typistMap, threadId, new LinkedHashSet<>()); - Typist typist = new Typist(Recipient.from(context, author, false), device, threadId); + Typist typist = new Typist(Recipient.from(appContext, author, false), device, threadId); if (typists.contains(typist)) { typists.remove(typist); @@ -112,8 +116,8 @@ public synchronized void didReceiveTypingStoppedMessage(@NotNull Context context } @Override - public synchronized void didReceiveIncomingMessage(@NotNull Context context, long threadId, @NotNull Address author, int device) { - didReceiveTypingStoppedMessage(context, threadId, author, device, true); + public synchronized void didReceiveIncomingMessage(long threadId, @NotNull Address author, int device) { + didReceiveTypingStoppedMessage(threadId, author, device, true); } public synchronized LiveData getTypists(long threadId) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/BigDecimalSerializer.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/BigDecimalSerializer.kt new file mode 100644 index 0000000000..ad407e8d67 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/BigDecimalSerializer.kt @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.tokenpage + +import java.math.BigDecimal +import java.math.BigInteger +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonUnquotedLiteral +import kotlinx.serialization.json.jsonPrimitive + +// We can't serialize the BigDecimal data type by default so we have to wrap it. +// Note: Code adapted from aSemy's StackOverflow solution at: https://stackoverflow.com/a/75257763/1868200 +@OptIn(ExperimentalSerializationApi::class) +object BigDecimalSerializer : KSerializer { + + override val descriptor = PrimitiveSerialDescriptor("java.math.BigDecimal", PrimitiveKind.DOUBLE) + + /** + * If decoding JSON uses [JsonDecoder.decodeJsonElement] to get the raw content, + * otherwise decodes using [Decoder.decodeString]. + */ + override fun deserialize(decoder: Decoder): BigDecimal = + when (decoder) { + is JsonDecoder -> decoder.decodeJsonElement().jsonPrimitive.content.toBigDecimal() + else -> decoder.decodeString().toBigDecimal() + } + + /** + * If encoding JSON uses [JsonUnquotedLiteral] to encode the exact [BigDecimal] value. + * + * Otherwise, [value] is encoded using encodes using [Encoder.encodeString]. + */ + override fun serialize(encoder: Encoder, value: BigDecimal) = + when (encoder) { + is JsonEncoder -> encoder.encodeJsonElement(JsonUnquotedLiteral(value.toPlainString())) + else -> encoder.encodeString(value.toPlainString()) + } +} + +typealias BigIntegerJson = @Serializable(with = BigIntegerSerializer::class) BigInteger + +@OptIn(ExperimentalSerializationApi::class) +private object BigIntegerSerializer : KSerializer { + + override val descriptor = PrimitiveSerialDescriptor("java.math.BigInteger", PrimitiveKind.LONG) + + /** + * If decoding JSON uses [JsonDecoder.decodeJsonElement] to get the raw content, + * otherwise decodes using [Decoder.decodeString]. + */ + override fun deserialize(decoder: Decoder): BigInteger = + when (decoder) { + is JsonDecoder -> decoder.decodeJsonElement().jsonPrimitive.content.toBigInteger() + else -> decoder.decodeString().toBigInteger() + } + + /** + * If encoding JSON uses [JsonUnquotedLiteral] to encode the exact [BigInteger] value. + * + * Otherwise, [value] is encoded using encodes using [Encoder.encodeString]. + */ + override fun serialize(encoder: Encoder, value: BigInteger) = + when (encoder) { + is JsonEncoder -> encoder.encodeJsonElement(JsonUnquotedLiteral(value.toString())) + else -> encoder.encodeString(value.toString()) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDataManager.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDataManager.kt new file mode 100644 index 0000000000..05f2c0115d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDataManager.kt @@ -0,0 +1,149 @@ +package org.thoughtcrime.securesms.tokenpage + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TokenDataManager @Inject constructor( + private val textSecurePreferences: TextSecurePreferences, + private val tokenRepository: TokenRepository, + @param:ManagerScope private val scope: CoroutineScope +) : OnAppStartupComponent { + private val TAG = "TokenDataManager" + + // Cached infoResponse in memory + private val _infoResponse = MutableStateFlow(InfoResponseState.Loading) + val infoResponse: StateFlow get() = _infoResponse + + // Store the reference update time separately from UI state + private var _lastUpdateTimeMillis: MutableStateFlow = MutableStateFlow(System.currentTimeMillis()) + val lastUpdateTimeMillis: StateFlow get() = _lastUpdateTimeMillis + + // Even if the server responds back to us faster than this we'll wait until at least a total + // duration of this value milliseconds has elapsed before updating the UI - otherwise it looks + // jank when the UI switches to "Loading..." and then near-instantly updates to the given values. + private val MINIMUM_SERVER_RESPONSE_DELAY_MS = 500L + + override fun onPostAppStarted() { + // we want to preload the data as soon as the user is logged in + scope.launch { + textSecurePreferences.watchLocalNumber() + .map { it != null } + .distinctUntilChanged() + .collect { logged -> + if(logged) fetchInfoResponse() + } + } + } + + fun getLastUpdateTimeMillis() = _lastUpdateTimeMillis.value + + + /** + * Fetches the InfoResponse from the tokenRepository, delays if needed, + * and then updates the MutableStateFlow. + * + * @return The fetched InfoResponse, or null if there was an error. + */ + private suspend fun fetchInfoResponse() { + _infoResponse.value = InfoResponseState.Loading + + val requestStartTimestamp = System.currentTimeMillis() + return try { + // Fetch the InfoResponse on an IO dispatcher + val response = withContext(Dispatchers.IO) { + tokenRepository.getInfoResponse() + } + // Ensure the minimum delay to avoid janky UI updates + forceWaitAtLeast500ms(requestStartTimestamp) + // Update the state flow so observers can react + _infoResponse.value = if(response != null ) + InfoResponseState.Data(response) + else InfoResponseState.Failure(Exception("InfoResponse was null")) + + updateLastUpdateTimeMillis() + Log.w(TAG, "Fetched infoResponse: $response") + } catch (e: Exception) { + Log.w(TAG, "InfoResponse error: $e") + _infoResponse.value =InfoResponseState.Failure(e) + } + } + + fun updateLastUpdateTimeMillis() { + _lastUpdateTimeMillis.value = System.currentTimeMillis() + } + + + // Method to ensure we wait for at least the `MINIMUM_SERVER_RESPONSE_DELAY_MS` milliseconds + // when requesting data from the server so that the UI doesn't blink to "Loading..." and then + // near-instantly back to the correct values. + private suspend fun forceWaitAtLeast500ms(requestStartTimestampMS: Long) { + val requestEndTimestamp = System.currentTimeMillis() + val requestDuration = requestEndTimestamp - requestStartTimestampMS + if (requestDuration < MINIMUM_SERVER_RESPONSE_DELAY_MS) { + val fillerDelayMS = MINIMUM_SERVER_RESPONSE_DELAY_MS - requestDuration + delay(fillerDelayMS) + } + } + + /** + * Fetches the info data if it's considered stale. + * + * This function checks if the current data is stale using [dataIsStale]. If the data is + * stale, it fetches new data using [fetchInfoResponse] and returns `true`. Otherwise, it + * logs that the data is not stale and returns `false`. + * + * @return `true` if new data was fetched, `false` otherwise. + */ + suspend fun fetchInfoDataIfNeeded(): Boolean{ + return if(dataIsStale()) { + Log.i(TAG, "Data is stale, fetch new data") + fetchInfoResponse() + true + } else{ + Log.i(TAG, "Data is not stale...") + updateLastUpdateTimeMillis() + false + } + } + + // If the server data is considered stale then 'timestamp minus now' is negative and we should refresh data from the server + private fun dataIsStale() = if (getInfoResponse() != null) { + val nowInSeconds = System.currentTimeMillis() / 1000L + + // If this value is negative it means that the server should have fresh data we can grab + val secondsUntilThereWillBeFreshData = + getInfoResponse()!!.priceData.staleTimestampSecs - nowInSeconds + + val freshDataExists = secondsUntilThereWillBeFreshData < 0 + freshDataExists + } else { + // If we don't have a previous infoResponse object then we should refresh the data + true + } + + /** + * Returns the cached InfoResponse if available. + */ + fun getInfoResponse(): InfoResponse? = (_infoResponse.value as? InfoResponseState.Data)?.data + + sealed class InfoResponseState { + data object Loading : InfoResponseState() + data class Data(val data: InfoResponse) : InfoResponseState() + data class Failure(val exception: Exception) : InfoResponseState() + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDropNotificationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDropNotificationWorker.kt new file mode 100644 index 0000000000..4741c9555e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenDropNotificationWorker.kt @@ -0,0 +1,140 @@ +package org.thoughtcrime.securesms.tokenpage + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.TaskStackBuilder +import android.content.Context +import android.content.Intent +import android.media.AudioAttributes +import android.media.RingtoneManager +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat.getString +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.squareup.phrase.Phrase +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants.NETWORK_NAME +import org.session.libsession.utilities.NonTranslatableStringConstants.TOKEN_NAME_LONG +import org.session.libsession.utilities.StringSubstitutionConstants.NETWORK_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.TOKEN_NAME_LONG_KEY +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.home.HomeActivity +import org.thoughtcrime.securesms.preferences.SettingsActivity + +@HiltWorker +class TokenDropNotificationWorker +@AssistedInject constructor( + @Assisted private val context: Context, + @Assisted private val workerParams: WorkerParameters, + private val prefs: TextSecurePreferences, + private val tokenDataManager: TokenDataManager +) : CoroutineWorker(context, workerParams) { + + private val NOTIFICATION_CHANNEL_ID = "SessionTokenNotifications" + private val NOTIFICATION_CHANNEL_NAME = "Session Token Notifications" + private val TOKEN_NOTIFICATION_ID = 777 + + override suspend fun doWork(): Result { + val isDebugNotification = + workerParams.tags.contains(TokenPageNotificationManager.debugNotificationWorkName) + val alreadyShownTokenPageNotification = prefs.hasSeenTokenPageNotification() + + tokenDataManager.fetchInfoDataIfNeeded() + + // If this is a proper notification (not a debug one) and we've already + // shown it then early exit rather than attempting to schedule another + if ( + !isDebugNotification && + (alreadyShownTokenPageNotification) + ) { + return Result.success() + } + + // Create the notification + val notificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Create a notification channel + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + NOTIFICATION_CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Session Token Page Notification Channel" + + // Typically Android will not allow a notification to display if the app is open in the foreground. To get around that and + // show a "heads-up" notification that WILL display in the foreground we need to specifically add either a sound or a + // vibration to it. In this case, we're specifically adding the default notification sound as a sound, which allows the + // heads-up notification to show even when the app is already open. + val soundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + setSound( + soundUri, AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) + } + notificationManager.createNotificationChannel(channel) + + // Create an intent to open the TokenPageActivity when the notification is clicked + val tokenPageActivityIntent = Intent(context, TokenPageActivity::class.java).apply { + // Add flags to handle existing activity instances + flags = + Intent.FLAG_ACTIVITY_NEW_TASK or // Necessary when starting an activity from a non-activity context (e.g. from a notification) + Intent.FLAG_ACTIVITY_CLEAR_TOP or // If the activity is already running then bring it to the front and clear all other activities on top of it + Intent.FLAG_ACTIVITY_SINGLE_TOP // Prevent the creation of a new instance if the activity is already at the top of the stack + } + + // Use TaskStackBuilder to build the back stack - without this if we schedule a notification and then + // with the app closed we click on it it takes us directly to the Token Page, but when we click the back + // button it closed the app because we don't have a back-stack! + // Note: I did try adding PARENT_ACTIVITY meta-data to the manifest to auto-generate the back-stack but + // it didn't work so I've just hard-coded the back-stack here as there's only a single path to the token + // page activity. + val stackBuilder = TaskStackBuilder.create(context).apply { + addNextIntent(Intent(context, HomeActivity::class.java)) + addNextIntent(Intent(context, SettingsActivity::class.java)) + addNextIntent(tokenPageActivityIntent) + } + + val pendingIntent = stackBuilder.getPendingIntent( + 0, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notificationTxt = Phrase.from(applicationContext, R.string.sessionNetworkNotificationLive) + .put(TOKEN_NAME_LONG_KEY, TOKEN_NAME_LONG) + .put(NETWORK_NAME_KEY, NETWORK_NAME) + .put(TOKEN_NAME_LONG_KEY, TOKEN_NAME_LONG) + .format().toString() + val builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setColor(context.getColor(R.color.textsecure_primary)) + .setContentTitle(getString(context, R.string.app_name)) + .setContentText(notificationTxt) + + // Without setting a `BigTextStyle` on the notification we only see a single line that gets ellipsized at the edge of the screen + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(notificationTxt) + ) + + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(pendingIntent) + .setAutoCancel(true) // Automatically dismiss the notification when tapped + + // Show the notification & update the shared preference to record the fact that we've done so + notificationManager.notify(TOKEN_NOTIFICATION_ID, builder.build()) + + // Update our preference data to indicate we've now shown the notification if this isn't a debug / test notification + if (!isDebugNotification) { + prefs.setHasSeenTokenPageNotification(true) + } + + return Result.success() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt new file mode 100644 index 0000000000..0c1ba1b02f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPage.kt @@ -0,0 +1,1004 @@ +package org.thoughtcrime.securesms.tokenpage + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.TopCenter +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.squareup.phrase.Phrase +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants.NETWORK_NAME +import org.session.libsession.utilities.NonTranslatableStringConstants.STAKING_REWARD_POOL +import org.session.libsession.utilities.NonTranslatableStringConstants.TOKEN_NAME_LONG +import org.session.libsession.utilities.NonTranslatableStringConstants.TOKEN_NAME_SHORT +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.ICON_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NETWORK_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.STAKING_REWARD_POOL_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.TOKEN_NAME_LONG_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.TOKEN_NAME_SHORT_KEY +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.ui.OpenURLAlertDialog +import org.thoughtcrime.securesms.ui.SpeechBubbleTooltip +import org.thoughtcrime.securesms.ui.components.AccentOutlineButtonRect +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.BlurredImage +import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator +import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.components.iconExternalLink +import org.thoughtcrime.securesms.ui.components.inlineContentMap +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.bold +import org.thoughtcrime.securesms.ui.verticalScrollbar +import org.thoughtcrime.securesms.util.NumberUtil.formatAbbreviated + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TokenPage( + uiState: TokenPageUIState, + sendCommand: (TokenPageCommand) -> Unit, + modifier: Modifier = Modifier, + onClose: () -> Unit +) { + val snackbarHostState = remember { SnackbarHostState() } + + val scrollState = rememberScrollState() + + // Details for the pull-to-refresh & limit-refresh-to-when-we-have-fresh-data mechanisms + val pullToRefreshState = rememberPullToRefreshState() + + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = LocalColors.current.background, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + topBar = { + BackAppBar( + title = NETWORK_NAME, + onBack = onClose, + modifier = Modifier + .qaTag("Page heading") + ) + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + ) { contentPadding -> + + PullToRefreshBox( + modifier = Modifier.padding(contentPadding), + state = pullToRefreshState, + isRefreshing = uiState.isRefreshing, + onRefresh = { sendCommand(TokenPageCommand.RefreshData) }, + indicator = { + // Colour the "spinning arrow" indicator to match our theme + Indicator( + state = pullToRefreshState, + isRefreshing = uiState.isRefreshing, + containerColor = LocalColors.current.backgroundSecondary, + color = LocalColors.current.accent, + modifier = Modifier.align(TopCenter) + ) + } + ) { + // This is the main column that contains all elements of the Token Page. + // It reaches the entire width of the screen and scrolls if there is sufficient content to allow it. + Column( + modifier = Modifier + .fillMaxSize() + .background(color = LocalColors.current.background) + // IMPORTANT: Add this `verticalScrollbar` modifier property BEFORE `.verticalScroll(scrollState)`! + .verticalScrollbar( + state = scrollState + ) + .verticalScroll(scrollState) + ) { + Column( + modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing) + ) { + + // The Session Network section is just some text with a link to "Learn More" - this does NOT contain the stats section - that comes next. + SessionNetworkInfoSection() + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // Stats section - this outlines the number of nodes in our swarm amongst other details + StatsSection( + currentSessionNodesInSwarm = uiState.currentSessionNodesInSwarm, + currentSessionNodesSecuringMessages = uiState.currentSessionNodesSecuringMessages, + showNodeCountsAsRefreshing = uiState.showNodeCountsAsRefreshing, + priceDataPopupText = uiState.priceDataPopupText, + currentSentPriceUSDString = uiState.currentSentPriceUSDString, + networkSecuredByUSDString = uiState.networkSecuredByUSDString, + networkSecuredBySENTString = uiState.networkSecuredBySENTString + ) + + // Token section that lists the staking pool size, market cap, and a button to learn more about staking + SessionTokenSection( + currentStakingRewardPoolString = uiState.currentStakingRewardPoolString, + showNodeCountsAsRefreshing = uiState.showNodeCountsAsRefreshing, + currentMarketCapUSDString = uiState.currentMarketCapUSDString + ) + } + + // There is a design idiosyncrasy where the "last updated" text needs to be at the very bottom of the screen + // when there is no scroll, otherwise it should be docked under the "learn about staking" button with padding + val hasNoScroll = scrollState.maxValue == 0 || scrollState.maxValue == Int.MAX_VALUE + + Column( + modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing) + .then( + if (hasNoScroll) { + Modifier.weight(1f) + } else Modifier + ) + ) { + // Last updated indicator ("Last updated 17m ago" etc.) + if (uiState.infoResponseData != null) { + // the last updated section should be docked at the bottom when there is no scrolling + // or below the button if the content is scrolling + if (hasNoScroll) { + Spacer(modifier = Modifier.weight(1f)) + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + Text( + text = uiState.lastUpdatedString, + textAlign = TextAlign.Center, + style = LocalType.current.sessionNetworkHeading, + color = LocalColors.current.textSecondary, + modifier = modifier + .fillMaxWidth() + .qaTag("Last updated timestamp") + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + } + + } + + Spacer( + modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) + ) + } + } + } +} + +@Composable +fun SessionNetworkInfoSection(modifier: Modifier = Modifier) { + val context = LocalContext.current + + Column( + modifier = modifier + ) { + // 1.) "Session Network" small heading + Text( + text = NETWORK_NAME, + style = LocalType.current.sessionNetworkHeading, + color = LocalColors.current.textSecondary + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + + // 2.) Session network description + val sessionNetworkDetailsAnnotatedString = annotatedStringResource( + highlightColor = LocalColors.current.accentText, + text = Phrase.from(context.getText(R.string.sessionNetworkDescription)) + .put(NETWORK_NAME_KEY, NETWORK_NAME) + .put(TOKEN_NAME_LONG_KEY, TOKEN_NAME_LONG) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .put(ICON_KEY, iconExternalLink) + .format() + ) + + // Note: We apply the link to the entire box so the user doesn't have to click exactly on the highlighted text. + var showTheOpenUrlModal by remember { mutableStateOf(false) } + Text( + modifier = Modifier + .clickable { showTheOpenUrlModal = true } + .qaTag("Learn more link"), // The entire clickable box acts as the link, so that's what I've put the qaTag on + text = sessionNetworkDetailsAnnotatedString, + inlineContent = inlineContentMap(LocalType.current.large.fontSize), + style = LocalType.current.large + ) + + if (showTheOpenUrlModal) { + OpenURLAlertDialog( + url = "https://docs.getsession.org/session-network", + onDismissRequest = { showTheOpenUrlModal = false } + ) + } + } +} + +// Composable to stack to transparent images on top of each other to create the session nodes display +@Composable +fun StatsImageBox( + showNodeCountsAsRefreshing: Boolean, + lineDrawableId: Int, + circlesDrawableId: Int, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .aspectRatio(1.15f) + .border( + width = 1.dp, + color = LocalColors.current.accent, + shape = MaterialTheme.shapes.extraSmall + ) + ) { + // Draw the waiting dots animation if we're refreshing the node counts.. + if (showNodeCountsAsRefreshing) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = LocalColors.current.text + ) + } else { + // ..otherwise draw the correct image for the number of nodes in our swarm. + + // We draw the white connecting lines first.. + Image( + painter = painterResource(id = lineDrawableId), + contentDescription = null, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Fit, // Note: `Fit` keeps the image aspect ratio - `FillBounds` will distort it + colorFilter = ColorFilter.tint(LocalColors.current.text), + ) + + // ..and THEN we draw the colored circles on top and tint them to the theme's accent colour - BUT + // we have to cheat if we want a glow effect - so we'll draw a blurred version first, and then draw + // the non-blurred version on top. + // + // Also: On Android API 31 and higher we can just call `.blur` on the modifier. While I did attempt to use an android.renderscript + // blur for older Android versions it was problematic so has been removed. + + // If we're on a dark theme then provide a blurred version of the node circles drawn beneath our upcoming non-blurred version + if (!LocalColors.current.isLight) { + BlurredImage( + drawableId = circlesDrawableId, + blurRadiusDp = 25f, + modifier = Modifier.matchParentSize() + ) + } + + // Final non-blurred copy of our node circles, tinted to match our theme accent colour + Image( + painter = painterResource(id = circlesDrawableId), + contentDescription = null, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(LocalColors.current.accent) + ) + } + } +} + +// This box shows "Session nodes in your swarm" and "Session Nodes securing your messages" details. +@Composable +fun NodeDetailsBox( + showNodeCountsAsRefreshing: Boolean, + numNodesInSwarm: String, + numNodesSecuringMessages: String, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val appName = context.getString(R.string.app_name) + + val nodesInSwarmAS = annotatedStringResource( + highlightColor = LocalColors.current.accentText, + text = Phrase.from(context, R.string.sessionNetworkNodesSwarm) + .put(APP_NAME_KEY, appName) + .format() + ) + + val nodesSecuringMessagesAS = annotatedStringResource( + highlightColor = LocalColors.current.accentText, + text = Phrase.from(context, R.string.sessionNetworkNodesSecuring) + .put(APP_NAME_KEY, appName) + .format() + ) + + // This Node Details Box consists of a single column.. + Column( + modifier = modifier.fillMaxWidth() + ) { + // ..with two rows inside it. + NodeDetailRow( + label = nodesInSwarmAS, + amount = numNodesInSwarm, + isLoading = showNodeCountsAsRefreshing, + qaTag = "Your swarm amount" + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + NodeDetailRow( + label = nodesSecuringMessagesAS, + amount = numNodesSecuringMessages, + isLoading = showNodeCountsAsRefreshing, + qaTag = "Nodes securing amount" + ) + } +} + +@Composable +fun NodeDetailRow( + label: AnnotatedString, + amount: String, + isLoading: Boolean, + qaTag: String, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + // Each row consists of the text on the left (e.g., "Session Nodes in your swarm").. + Text( + text = label, + style = LocalType.current.h8, + color = LocalColors.current.text, + modifier = Modifier.fillMaxWidth(0.62f) + ) + + // ..add a spacer with a weight to push the last element to the far right.. + Spacer(modifier = Modifier.weight(1f)) + + // ..and then the actual number of nodes in the swarm on the right. + if (isLoading) { + SmallCircularProgressIndicator( + modifier = Modifier.size(LocalDimensions.current.iconMedium), + color = LocalColors.current.text + ) + Spacer(modifier = Modifier.width(LocalDimensions.current.xxsSpacing)) + } else { + + // logic to determine if we should use the short hand version + var useShort by remember(amount) { mutableStateOf(false) } + var maxLines by remember(amount) { mutableStateOf(1) } + val display = if (useShort) amount.toBigDecimal().formatAbbreviated(maxFractionDigits = 0) + else amount + + Text( + text = display, + style = LocalType.current.h3, + color = LocalColors.current.accentText, + maxLines = maxLines, + onTextLayout = { result -> + if (result.hasVisualOverflow && !useShort) { + useShort = true // trigger recomposition with short text + } else if(result.hasVisualOverflow && useShort) { + // if we still overflow with the shorthand, break into multiple lines... + maxLines = 2 + } + }, + textAlign = TextAlign.End, + modifier = Modifier.qaTag(qaTag) + ) + } + } +} + +// Method to grab the relevant pair of images for the StatsImageBox showing the number of nodes in your swarm +fun getNodeImageForSwarmSize(numNodesInOurSwarm: Int): Pair { + when (numNodesInOurSwarm) { + 1 -> return Pair(R.drawable.session_node_lines_1, R.drawable.session_nodes_1) + 2 -> return Pair(R.drawable.session_node_lines_2, R.drawable.session_nodes_2) + 3 -> return Pair(R.drawable.session_node_lines_3, R.drawable.session_nodes_3) + 4 -> return Pair(R.drawable.session_node_lines_4, R.drawable.session_nodes_4) + 5 -> return Pair(R.drawable.session_node_lines_5, R.drawable.session_nodes_5) + 6 -> return Pair(R.drawable.session_node_lines_6, R.drawable.session_nodes_6) + 7 -> return Pair(R.drawable.session_node_lines_7, R.drawable.session_nodes_7) + 8 -> return Pair(R.drawable.session_node_lines_8, R.drawable.session_nodes_8) + 9 -> return Pair(R.drawable.session_node_lines_9, R.drawable.session_nodes_9) + 10 -> return Pair(R.drawable.session_node_lines_10, R.drawable.session_nodes_10) + else -> { + Log.w( + "TokenPage", + "Somehow got an illegal numNodesInOurSwarm value: $numNodesInOurSwarm - using 5 as a fallback" + ) + return Pair(R.drawable.session_node_lines_5, R.drawable.session_nodes_5) + } + } +} + +// Stats section that shows the number of nodes in your swarm (along with a visual representation), the +// number of nodes securing your messages, the current SENT token price, and the total USD value securing +// the network. +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StatsSection( + currentSessionNodesInSwarm: Int, + currentSessionNodesSecuringMessages: Int, + showNodeCountsAsRefreshing: Boolean, + currentSentPriceUSDString: String, + networkSecuredBySENTString: String, + networkSecuredByUSDString: String, + priceDataPopupText: String, + modifier: Modifier = Modifier +) { + // First row contains the `StatsImageBox` with the number of nodes in your swap and the text + // details with that number and the number of nodes securing your messages. + Row(modifier = modifier.fillMaxWidth()) { + + // On the left we have the node image showing how many nodes are in the user's swarm.. + val (linesDrawable, circlesDrawable) = getNodeImageForSwarmSize(currentSessionNodesInSwarm) + StatsImageBox( + showNodeCountsAsRefreshing = showNodeCountsAsRefreshing, + lineDrawableId = linesDrawable, + circlesDrawableId = circlesDrawable, + modifier = Modifier + .fillMaxWidth(0.45f) + .qaTag("Swarm image") + ) + + Spacer(modifier = Modifier.width(LocalDimensions.current.xsSpacing)) + + // ..and on the right we have the text details of num nodes in swarm and total nodes securing your messages. + NodeDetailsBox( + showNodeCountsAsRefreshing = showNodeCountsAsRefreshing, + numNodesInSwarm = currentSessionNodesInSwarm.toString(), + numNodesSecuringMessages = currentSessionNodesSecuringMessages.toString(), + modifier = Modifier + .fillMaxWidth(1.0f) + .align(Alignment.CenterVertically) + ) + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + Row(modifier = Modifier.fillMaxWidth()) { + var cellHeight by remember( + currentSentPriceUSDString, + networkSecuredBySENTString, + networkSecuredByUSDString + ) { mutableStateOf(0.dp) } + + val density = LocalDensity.current + + // On the left we have the node image showing how many nodes are in the user's swarm.. + val currentPriceString = + Phrase.from(LocalContext.current, R.string.sessionNetworkCurrentPrice) + .put(TOKEN_NAME_SHORT_KEY, TOKEN_NAME_SHORT) + .format().toString() + val setOneLineOne = currentPriceString + val setOneLineTwo = currentSentPriceUSDString + val setOneLineThree = TOKEN_NAME_LONG + + ThreeLineTextCell( + setOneLineOne, + setOneLineTwo, + setOneLineThree, + qaTag = "SESH price", + modifier = Modifier + .fillMaxWidth(0.45f) // 45% width + .onGloballyPositioned { coordinates -> + // Calculate this cell's height in dp + val heightInDp = with(density) { coordinates.size.height.toDp() } + // Update cellHeight if this cell is taller + if (heightInDp > cellHeight) { + cellHeight = heightInDp + } + } + .height(if (cellHeight > 0.dp) cellHeight else androidx.compose.ui.unit.Dp.Unspecified) + ) { + Box( + modifier = Modifier + .padding(LocalDimensions.current.xxxsSpacing) + .size(15.dp) + .align(Alignment.TopEnd) + ) { + val tooltipState = rememberTooltipState(isPersistent = true) + val scope = rememberCoroutineScope() + + SpeechBubbleTooltip( + text = priceDataPopupText, + tooltipState = tooltipState + ) { + Image( + painter = painterResource(id = R.drawable.ic_circle_help), + contentDescription = null, + colorFilter = ColorFilter.tint(LocalColors.current.text), + modifier = Modifier + .size(LocalDimensions.current.iconXSmall) + .clickable { + scope.launch { + if (tooltipState.isVisible) tooltipState.dismiss() else tooltipState.show() + } + } + .qaTag("Tooltip") + ) + } + } + } + + Spacer(modifier = Modifier.width(LocalDimensions.current.xsSpacing)) + + // ..and on the right we have the text details of num nodes in swarm and total nodes securing your messages. + val setTwoLineOne = LocalContext.current.getString(R.string.sessionNetworkSecuredBy) + val setTwoLineTwo = networkSecuredBySENTString + val setTwoLineThree = networkSecuredByUSDString + ThreeLineTextCell( + setTwoLineOne, + setTwoLineTwo, + setTwoLineThree, + qaTag = "Network secured amount", + modifier = Modifier.fillMaxWidth(1.0f) + .onGloballyPositioned { coordinates -> + // Calculate this cell's height in dp + val heightInDp = with(density) { coordinates.size.height.toDp() } + // Update cellHeight if this cell is taller + if (heightInDp > cellHeight) { + cellHeight = heightInDp + } + } + .height(if (cellHeight > 0.dp) cellHeight else androidx.compose.ui.unit.Dp.Unspecified) + ) + } +} + +@Composable +fun RewardPoolAndMarketCapRows( + showNodeCountsAsRefreshing: Boolean, + currentStakingRewardPoolString: String, + currentMarketCapUSDString: String, + modifier: Modifier = Modifier +) { + val valueTextColour = + if (showNodeCountsAsRefreshing) LocalColors.current.textSecondary else LocalColors.current.text + + Column( + modifier = modifier + .background(LocalColors.current.background) + ) { + // Staking reward pool row + Row(modifier = Modifier.padding(vertical = LocalDimensions.current.smallSpacing)) { + Text( + text = STAKING_REWARD_POOL, + style = LocalType.current.sessionNetworkHeading.bold(), + color = LocalColors.current.text, + modifier = Modifier + .fillMaxWidth(0.45f) + ) + Spacer(modifier = Modifier.width(LocalDimensions.current.spacing)) + + Text( + text = currentStakingRewardPoolString, + style = LocalType.current.sessionNetworkHeading, + color = valueTextColour, + modifier = Modifier + .weight(1f) + .qaTag("Staking reward pool amount") + ) + } + + // Thin separator line + HorizontalDivider( + thickness = 1.dp, + color = LocalColors.current.borders + ) + + // Market cap row + Row(modifier = Modifier.padding(vertical = LocalDimensions.current.smallSpacing)) { + Text( + text = LocalContext.current.getString(R.string.sessionNetworkMarketCap), + color = LocalColors.current.text, + style = LocalType.current.sessionNetworkHeading.bold(), + modifier = Modifier + .fillMaxWidth(0.45f) + ) + Spacer(modifier = Modifier.width(LocalDimensions.current.spacing)) + Text( + text = currentMarketCapUSDString, + style = LocalType.current.sessionNetworkHeading, + color = valueTextColour, + modifier = Modifier + .weight(1f) + .qaTag("Market cap amount") + ) + } + } +} + +// Section that shows the current size of the staking reward pool & the market cap +@Composable +fun SessionTokenSection( + showNodeCountsAsRefreshing: Boolean, + currentStakingRewardPoolString: String, + currentMarketCapUSDString: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + ) { + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // 1.) "Session Token" small heading + Text( + text = TOKEN_NAME_LONG, + style = LocalType.current.sessionNetworkHeading, + color = LocalColors.current.textSecondary + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + + val sessionTokenDescription = + Phrase.from(LocalContext.current, R.string.sessionNetworkTokenDescription) + .put(TOKEN_NAME_LONG_KEY, TOKEN_NAME_LONG) + .put(TOKEN_NAME_SHORT_KEY, TOKEN_NAME_SHORT) + .put(STAKING_REWARD_POOL_KEY, STAKING_REWARD_POOL) + .format().toString() + + // Session token description text + Text( + text = sessionTokenDescription, + style = LocalType.current.large, + color = LocalColors.current.text + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + + // Display the rows that show "Staking Reward Pool" and "Market Cap" + RewardPoolAndMarketCapRows( + showNodeCountsAsRefreshing = showNodeCountsAsRefreshing, + currentStakingRewardPoolString = currentStakingRewardPoolString, + currentMarketCapUSDString = currentMarketCapUSDString + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + + // Finally, add a button that links us to the staging page to learn more + var showTheOpenUrlModal by remember { mutableStateOf(false) } + AccentOutlineButtonRect( + text = LocalContext.current.getString(R.string.sessionNetworkLearnAboutStaking), + modifier = Modifier + .fillMaxWidth() + .qaTag("Learn about staking link"), + onClick = { showTheOpenUrlModal = true } + ) + + if (showTheOpenUrlModal) { + OpenURLAlertDialog( + url = "https://docs.getsession.org/session-network/staking", + onDismissRequest = { showTheOpenUrlModal = false } + ) + } + } +} + +// A cell that contains 3 lines of text, such as "Current SESH Price:", then the string with the price such as "$2.57 USD", and +// finally some footer text like "Session Token ("SESH"). It also has an optional question-mark button which will display a pop-up +// saying "Price information provided by CoinGecko" +@Composable +fun ThreeLineTextCell( + firstLine: String, + secondLine: String, + thirdLine: String, + qaTag: String, + modifier: Modifier = Modifier, + extraContent: @Composable BoxScope.() -> Unit = {}, +) { + // Box that contains everything (text and optional question mark) + Box( + modifier = modifier + .background( + color = LocalColors.current.backgroundSecondary, + shape = RoundedCornerShape(LocalDimensions.current.xsSpacing) + ) + ) { + extraContent() + + Column( + modifier = Modifier + .padding( + horizontal = LocalDimensions.current.xsSpacing, + vertical = LocalDimensions.current.smallSpacing + ) + ) { + // Display string 1 of 3 + Text( + text = firstLine, + style = LocalType.current.sessionNetworkHeading, + color = LocalColors.current.text, + textAlign = TextAlign.Start, + modifier = Modifier + .fillMaxWidth() + ) + + // Display string 2 of 3 + Text( + text = secondLine, + style = LocalType.current.h5, + color = LocalColors.current.accentText, + textAlign = TextAlign.Start, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = LocalDimensions.current.xxxsSpacing) + .qaTag(qaTag) // QA tag goes directly on the value line + ) + + Spacer(modifier = Modifier.weight(1f)) + + // Display string 3 of 3 + Text( + text = thirdLine, + style = LocalType.current.sessionNetworkHeading, + color = LocalColors.current.textSecondary, + textAlign = TextAlign.Start, + modifier = Modifier + .fillMaxWidth() + ) + } + } +} + +// ---------- PREVIEWS ONLY BELOW THIS POINT ---------- + +@Preview +@Composable +fun PreviewTokenPage() { + PreviewTheme { + TokenPage( + uiState = TokenPageUIState( + currentSessionNodesInSwarm = 5, + currentSessionNodesSecuringMessages = 125349, + currentSentPriceUSDString = "$1,472.22 USD", + networkSecuredBySENTString = "12M SENT", + networkSecuredByUSDString = "$1,234,567 USD", + currentStakingRewardPool = SerializableBigDecimal(40_000_000), + currentMarketCapUSDString = "$20,456,259 USD", + currentStakingRewardPoolString = "40,567,789,654,789 SESH", + lastUpdatedString = "Last updated 1min ago" + ), + sendCommand = { }, + modifier = Modifier, + onClose = { } + ) + } +} + +@Preview +@Composable +fun PreviewTokenPageLoading() { + PreviewTheme { + TokenPage( + uiState = TokenPageUIState( + currentSessionNodesInSwarm = 5, + currentSessionNodesSecuringMessages = 123, + networkSecuredBySENTString = "12M SENT", + networkSecuredByUSDString = "$1,234,567 USD", + currentStakingRewardPool = SerializableBigDecimal(40_000_000), + showNodeCountsAsRefreshing = true + ), + sendCommand = { }, + modifier = Modifier, + onClose = { } + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox1() { + PreviewTheme { + val data = TokenPageUIState() + StatsImageBox( + showNodeCountsAsRefreshing = data.showNodeCountsAsRefreshing, + R.drawable.session_node_lines_1, + R.drawable.session_nodes_1 + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox2() { + PreviewTheme { + StatsImageBox( + showNodeCountsAsRefreshing = TokenPageUIState().showNodeCountsAsRefreshing, + R.drawable.session_node_lines_2, + R.drawable.session_nodes_2 + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox3() { + PreviewTheme { + StatsImageBox( + showNodeCountsAsRefreshing = TokenPageUIState().showNodeCountsAsRefreshing, + R.drawable.session_node_lines_3, + R.drawable.session_nodes_3 + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox4() { + PreviewTheme { + StatsImageBox( + showNodeCountsAsRefreshing = TokenPageUIState().showNodeCountsAsRefreshing, + R.drawable.session_node_lines_4, + R.drawable.session_nodes_4 + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox5() { + PreviewTheme { + StatsImageBox( + showNodeCountsAsRefreshing = TokenPageUIState().showNodeCountsAsRefreshing, + R.drawable.session_node_lines_5, + R.drawable.session_nodes_5 + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox6() { + PreviewTheme { + StatsImageBox( + showNodeCountsAsRefreshing = TokenPageUIState().showNodeCountsAsRefreshing, + R.drawable.session_node_lines_6, + R.drawable.session_nodes_6 + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox7() { + PreviewTheme { + StatsImageBox( + showNodeCountsAsRefreshing = TokenPageUIState().showNodeCountsAsRefreshing, + R.drawable.session_node_lines_7, + R.drawable.session_nodes_7 + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox8() { + PreviewTheme { + StatsImageBox( + showNodeCountsAsRefreshing = TokenPageUIState().showNodeCountsAsRefreshing, + R.drawable.session_node_lines_8, + R.drawable.session_nodes_8 + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox9() { + PreviewTheme { + StatsImageBox( + showNodeCountsAsRefreshing = TokenPageUIState().showNodeCountsAsRefreshing, + R.drawable.session_node_lines_9, + R.drawable.session_nodes_9 + ) + } +} + +@Preview +@Composable +fun PreviewStatsImageBox10() { + PreviewTheme { + StatsImageBox( + showNodeCountsAsRefreshing = TokenPageUIState().showNodeCountsAsRefreshing, + R.drawable.session_node_lines_10, + R.drawable.session_nodes_10 + ) + } +} + +@Preview +@Composable +fun PreviewNodeDetailsBox() { + // Note: The entire text for both entries shows up in white in the preview, + // but the "your swarm" and "your messages" parts are displayed in the accent + // colour in-app. + PreviewTheme { + val data = TokenPageUIState() + NodeDetailsBox( + showNodeCountsAsRefreshing = data.showNodeCountsAsRefreshing, + numNodesInSwarm = "5", + numNodesSecuringMessages = "115", + ) + } +} + +@Preview +@Composable +fun PreviewSessionTokenSection() { + PreviewTheme { + val data = TokenPageUIState() + SessionTokenSection( + showNodeCountsAsRefreshing = data.showNodeCountsAsRefreshing, + currentStakingRewardPoolString = data.currentStakingRewardPoolString, + currentMarketCapUSDString = data.currentMarketCapUSDString + ) + } +} + +@Preview +@Composable +fun PreviewCurrentSentPriceCell() { + val firstLine = "Current SENT Price:" + val secondLine = "\$1.23 USD" + val thirdLine = "Session Token (SENT)" + PreviewTheme { + ThreeLineTextCell(firstLine, secondLine, thirdLine, qaTag = "Some QA tag") + } +} + +@Preview +@Composable +fun PreviewSessionNetworkSection() { + PreviewTheme { + SessionNetworkInfoSection() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageActivity.kt new file mode 100644 index 0000000000..fcbc37d0cd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageActivity.kt @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.tokenpage + +import android.os.Bundle +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import dagger.hilt.android.AndroidEntryPoint +import org.thoughtcrime.securesms.FullComposeActivity +import org.thoughtcrime.securesms.FullComposeScreenLockActivity +import org.thoughtcrime.securesms.ScreenLockActionBarActivity +import org.thoughtcrime.securesms.ui.setComposeContent + +@AndroidEntryPoint +class TokenPageActivity : FullComposeScreenLockActivity() { + private val viewModel: TokenPageViewModel by viewModels() + + @Composable + override fun ComposeContent() { + TokenPageScreen( + tokenPageViewModel = viewModel, + onClose = { finish() } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageCommand.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageCommand.kt new file mode 100644 index 0000000000..7c039a544d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageCommand.kt @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.tokenpage + +// Commands that we can ask the Token Page to perform +sealed class TokenPageCommand { + + // Refresh current data / or pretend to + data object RefreshData: TokenPageCommand() +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageDataTypes.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageDataTypes.kt new file mode 100644 index 0000000000..c33c93e5bc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageDataTypes.kt @@ -0,0 +1,77 @@ +package org.thoughtcrime.securesms.tokenpage + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.thoughtcrime.securesms.util.NumberUtil.formatWithDecimalPlaces +import java.math.BigDecimal + +/*** + * IMPORTANT: THe server gives us timestamps in SECONDS rather than milliseconds (and expects any + * timestamps we provide to be in seconds) - so if we want to compare a server timestamp to now (e.g, + * `System.currentTimeMillis()`) then we must first either multiply the server's timestamp by 1000, + * or use our millisecond timestamp `inWholeSeconds` to get everything aligned. + */ + +// Note: We use BigDecimal in these classes for numeric values so that there are no rounding errors +// and we can guarantee 9 decimals of precision at all times. +// Also: We can't serialize BigDecimals natively so I've wrapped it (see BigDecimalSerializer.kt) +typealias SerializableBigDecimal = @Serializable(with = BigDecimalSerializer::class) BigDecimal + +// Data class to hold details provided by the `GET /info` endpoint. +@Serializable +data class InfoResponse( + @SerialName("t") val infoResponseTimestampSecs: Long, + @SerialName("price") val priceData: PriceData, + @SerialName("token") val tokenData: TokenData, + @SerialName("network") val networkData: NetworkData +) + +// Data class to wrap up details regarding the current SENT token price +@Serializable +data class PriceData( + // The token price in US dollars + @SerialName("usd") val tokenPriceUSD: SerializableBigDecimal, + + // Current market cap value in US dollars + @SerialName("usd_market_cap") val marketCapUSD: SerializableBigDecimal?, + + // The timestamp (in seconds) of when the server's CoinGecko-sourced token price data was last updated + @SerialName("t_price") val priceTimestampSecs: Long, + + // The timestamp (in seconds) of when the server's CoinGecko-sourced token price data will be + // considered stale and we'll allow the user to poll the server for fresh data. The server only + // polls CoinGecko once every 5 mins - so what we can do on the client side is check if the + // current time is lower than `t_stale`, and if it is then we don't poll the server again as + // we'd just be getting the same data. + @SerialName("t_stale") val staleTimestampSecs: Long +) + +// Data class to hold details provided in a InfoResponse or via the `GET /token` endpoint +@Serializable +data class TokenData( + // How many tokens must be staked to run a Session Node + @SerialName("staking_requirement") val nodeStakingRequirement: SerializableBigDecimal, + + // The number of tokens currently in the staking reward pool. While this value starts + // at 40,000,000 it will decrease as tokens are handed out as rewards, and will + // increase when we (Session) top up the pool. + @SerialName("staking_reward_pool") val stakingRewardPool: SerializableBigDecimal, + + // The ethereum contract address for the SENT token. This is 42 chars in length - being + // "0x" followed by 40 hexadecimal chars. + @SerialName("contract_address") val tokenContractAddress: String +) { + // Get staking reward pool in a locale-aware manner to ZERO decimal places (while the reward pool may not be a + // a whole number, we don't care about fractions when the staking pool is in the range of millions of tokens). + fun getLocaleFormattedStakingRewardPool(): String { + return stakingRewardPool.formatWithDecimalPlaces(0) + } +} + +// Small data class included as part of an InfoResponse +@Serializable +data class NetworkData( + @SerialName("network_size") val networkSize: Int, + @SerialName("network_staked_tokens") val networkTokens: SerializableBigDecimal, + @SerialName("network_staked_usd") val networkUSD: SerializableBigDecimal, +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageNotificationManager.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageNotificationManager.kt new file mode 100644 index 0000000000..78822c41eb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageNotificationManager.kt @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.tokenpage + +import android.content.Context +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import dagger.hilt.android.qualifiers.ApplicationContext +import org.session.libsession.utilities.TextSecurePreferences +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds + +@Singleton +class TokenPageNotificationManager @Inject constructor( + @ApplicationContext private val context: Context, + private val tokenDataManager: TokenDataManager, + private val prefs: TextSecurePreferences +) { + + companion object { + // WorkManager tags for the local-origin notification about network page + const val productionNotificationWorkName = "SESSION_TOKEN_DROP_INITIAL_NOTIFICATION" + const val debugNotificationWorkName = "SESSION_TOKEN_DROP_DEBUG_NOTIFICATION" + } + + // Method to schedule a notification to be shown at a specific time in the future. + // IMPORTANT: If `constructDebugNotification` is true then we can schedule the notification over + // and over (and we do so with a 10 second delay), however if it's not then we can only schedule + // the notification once - which is what we want for production. + fun scheduleTokenPageNotification(constructDebugNotification: Boolean) { + // Bail early if we are this isn't a debug notification and we've already shown the notification + if (prefs.hasSeenTokenPageNotification() && !constructDebugNotification) return + + // The notification is scheduled for 10 seconds after opening for debug notifications & 1 hour after opening for production + val scheduleDelayMS = if (constructDebugNotification) { + 10.seconds.inWholeMilliseconds + } else { + 1.hours.inWholeMilliseconds + } + + // Create the one-time work request for our notification. If we are constructing a debug + // notification we set the delay for 10 seconds and we DO NOT tag the notification.. + val notificationWork: OneTimeWorkRequest = + OneTimeWorkRequestBuilder() + .setInitialDelay(scheduleDelayMS, TimeUnit.MILLISECONDS) + .addTag(if (constructDebugNotification) debugNotificationWorkName else productionNotificationWorkName) // Add the tag to differentiate between a debug and a production notification! + .build() + + // Either enqueue a debug notification if asked to (this can be shown multiple times).. + if (constructDebugNotification) { + WorkManager.getInstance(context).enqueueUniqueWork( + debugNotificationWorkName, + ExistingWorkPolicy.REPLACE, + notificationWork + ) + } else { + // ..or enqueue a production notification which is one-time only (i.e., it will not be updated should one already be enqueued) - and + // ONLY do this if we haven't already shown the token page notification (there's no point in asking the work manager to show it again + // - it won't because the ExistingWorkPolicy is KEEP). + // Note: Should the device be powered off when the scheduled notification is due to fire then WorkManager will fire + // the notification immediately on next boot (i.e., it won't get lost or forgotten). + WorkManager.getInstance(context).enqueueUniqueWork( + productionNotificationWorkName, + ExistingWorkPolicy.KEEP, + notificationWork + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageScreen.kt new file mode 100644 index 0000000000..fa57ec3abd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageScreen.kt @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.tokenpage + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +fun TokenPageScreen( + modifier: Modifier = Modifier, + tokenPageViewModel: TokenPageViewModel = viewModel(), + onClose: () -> Unit +) { + val uiState by tokenPageViewModel.uiState.collectAsState() + + // Remember callbacks to prevent recomposition when functions change + val rememberedOnCommand = remember { tokenPageViewModel::onCommand } + val rememberedOnClose = remember { onClose } + + TokenPage( + modifier = modifier, + uiState = uiState, + sendCommand = rememberedOnCommand, + onClose = rememberedOnClose + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageUIState.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageUIState.kt new file mode 100644 index 0000000000..af44890f11 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageUIState.kt @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.tokenpage + +import org.session.libsession.utilities.NonTranslatableStringConstants +import java.math.BigDecimal + +// Data class to hold a collection of variables used in our UI state +data class TokenPageUIState( + + // true during 'pull to refresh' + val isRefreshing: Boolean = false, + + // Details for how many nodes are in our swarm, and how many nodes are securing our messages. + // See: `TokenPageViewModel.populateNodeData()` for calculation details. + val currentSessionNodesInSwarm: Int = 0, + val currentSessionNodesSecuringMessages: Int = 0, + + // info response data + val infoResponseData: InfoResponseStateData? = null, + + // When we get a new InfoResponse we update the session node counts - and during the refresh we + // show the loading animation rather than the nodes in swarm & securing-messages values. + val showNodeCountsAsRefreshing: Boolean = false, + + // ----- PriceResponse / PriceData UI representations ----- + + // Number so we can perform calculation (this value is obtained from PriceData.usd) + var currentSentPriceUSDString: String = "", + + // Number so we can perform calculations (this value is obtained from PriceData.usd_market_cap) + val currentMarketCapUSDString: String = "", + + // ----- TokenResponse / TokenData UI representations ----- + + // At the time of the token-generation event this is 40 million (this value is obtained from TokenData.staking_reward_pool) + val currentStakingRewardPool: SerializableBigDecimal = SerializableBigDecimal(0), + val currentStakingRewardPoolString: String = "", + + // The amount of SENT securing the network is the total number of nodes multiplied by the staking requirement per node.. + val networkSecuredBySENTString: String = "", + + // ..and the total amount of USD securing the network is the SENT count multiplied by the current token price. + val networkSecuredByUSDString: String = "\$- ${NonTranslatableStringConstants.USD_NAME_SHORT}", + + val priceDataPopupText: String = "", + + // string for the tooltip + val lastUpdatedString: String = "", +) + +data class InfoResponseStateData( + val tokenContractAddress: String, + val canCopyTokenContractAddress: Boolean, +) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt new file mode 100644 index 0000000000..e6eb68c84c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt @@ -0,0 +1,320 @@ +package org.thoughtcrime.securesms.tokenpage + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.squareup.phrase.Phrase +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import network.loki.messenger.R +import nl.komponents.kovenant.Promise +import org.session.libsession.LocalisedTimeUtil.toShortSinglePartString +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.utilities.await +import org.session.libsession.utilities.NonTranslatableStringConstants.SESSION_NETWORK_DATA_PRICE +import org.session.libsession.utilities.NonTranslatableStringConstants.TOKEN_NAME_SHORT +import org.session.libsession.utilities.NonTranslatableStringConstants.USD_NAME_SHORT +import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.DATE_TIME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.RELATIVE_TIME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.NetworkConnectivity +import org.thoughtcrime.securesms.util.NumberUtil.formatAbbreviated +import org.thoughtcrime.securesms.util.NumberUtil.formatWithDecimalPlaces +import javax.inject.Inject +import kotlin.math.min +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes + +@HiltViewModel +class TokenPageViewModel @Inject constructor( + @ApplicationContext val context: Context, + private val tokenRepository: TokenRepository, + private val tokenDataManager: TokenDataManager, + private val dateUtils: DateUtils, + private val prefs: TextSecurePreferences +) : ViewModel() { + private val TAG = "TokenPageVM" + + @Inject + lateinit var internetConnectivity: NetworkConnectivity + + private val _uiState: MutableStateFlow = MutableStateFlow(TokenPageUIState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val infoResponse: InfoResponse? + get() = tokenDataManager.getInfoResponse() + + private val unavailableString = context.getString(R.string.unavailable) + + init { + // grab info data from manager + viewModelScope.launch { + tokenDataManager.infoResponse.collect { infoResponseResult -> + when(infoResponseResult){ + is TokenDataManager.InfoResponseState.Loading -> showLoading() + + is TokenDataManager.InfoResponseState.Data -> handleInfoResponse(infoResponseResult.data) + + is TokenDataManager.InfoResponseState.Failure -> resetDisplayedValuesToDefault() + } + } + } + + // on launch of the token page, check if we have new info data, and acquire node data + viewModelScope.launch { + // get node data + getNodeData() + + tokenDataManager.fetchInfoDataIfNeeded() + } + + // update label when update time changes + viewModelScope.launch { + tokenDataManager.lastUpdateTimeMillis.collect { + updateLastUpdatedText() + } + } + + startLastUpdateTimer() + } + + private fun startLastUpdateTimer() { + viewModelScope.launch { + while (true) { + updateLastUpdatedText() + delay(1.minutes) + } + } + } + + private fun updateLastUpdatedText() { + val currentTimeMillis = System.currentTimeMillis() + val durationSinceLastUpdate = (currentTimeMillis - tokenDataManager.getLastUpdateTimeMillis()).milliseconds + val shortLastUpdateString = durationSinceLastUpdate.toShortSinglePartString() + + val lastUpdatedTxt = Phrase.from(context, R.string.updated) + .put(RELATIVE_TIME_KEY, shortLastUpdateString) + .format().toString() + + // Update only the lastUpdatedString field in the UI state + _uiState.update { currentState -> + currentState.copy(lastUpdatedString = lastUpdatedTxt) + } + } + + // Handler to instigate certain actions when we receive a TokenPageCommand + fun onCommand(command: TokenPageCommand) { + + when (command) { + is TokenPageCommand.RefreshData -> refreshData() + } + } + + private fun showLoading() { + _uiState.update { state -> + val loadingString = context.getString(R.string.loading) + state.copy( + showNodeCountsAsRefreshing = true, + currentSentPriceUSDString = loadingString, + currentMarketCapUSDString = loadingString, + currentStakingRewardPoolString = loadingString, + networkSecuredBySENTString = loadingString, + networkSecuredByUSDString = "\$- ${USD_NAME_SHORT}" + ) + } + } + + private fun handleInfoResponse(infoResponse: InfoResponse?) { + // update the rest of the UI with details like token price, market cap etc. + if (infoResponse != null) { + // Calculate price data time text + val priceTimeMS = + infoResponse.priceData.priceTimestampSecs * 1000L // Multiply by 1000 to get timestamp in milliseconds + + // Note: If we do not have data then `lastPriceUpdateDate" will be "-" and `lastPriceUpdateTime` will be "" + val priceDataText = Phrase.from(SESSION_NETWORK_DATA_PRICE) + .put(DATE_TIME_KEY, dateUtils.getLocaleFormattedDateTime(priceTimeMS)) + .format().toString() + + _uiState.update { state -> + state.copy( + infoResponseData = InfoResponseStateData( + tokenContractAddress = infoResponse.tokenData.tokenContractAddress, + canCopyTokenContractAddress = infoResponse.tokenData.tokenContractAddress.isNotEmpty(), + ), + + priceDataPopupText = priceDataText, + + showNodeCountsAsRefreshing = false, + + currentSentPriceUSDString = "\$" + infoResponse.priceData.tokenPriceUSD.formatWithDecimalPlaces(2) + " $USD_NAME_SHORT", // Formatted token price value "$1.23 USD" etc. + currentMarketCapUSDString = if(infoResponse.priceData.marketCapUSD == null) unavailableString + else "\$" + infoResponse.priceData.marketCapUSD.formatWithDecimalPlaces( 0) + " $USD_NAME_SHORT", // Formatted market cap value "$1,234,567 USD" etc. + + currentStakingRewardPool = infoResponse.tokenData.stakingRewardPool, + currentStakingRewardPoolString = infoResponse.tokenData.getLocaleFormattedStakingRewardPool() + " $TOKEN_NAME_SHORT", + + currentSessionNodesSecuringMessages = min(infoResponse.networkData.networkSize, state.currentSessionNodesSecuringMessages), // we now apply the 'min' from the formula defined in getNodeData + networkSecuredBySENTString = infoResponse.networkData.networkTokens + .formatAbbreviated( + minFractionDigits = 0, + maxFractionDigits = 0 + ) + " " + TOKEN_NAME_SHORT, + + networkSecuredByUSDString = "\$" + infoResponse.networkData.networkUSD + .formatWithDecimalPlaces(0) + " ${USD_NAME_SHORT}" + ) + } + } else { + Log.w(TAG, "Received null InfoResponse - unable to proceed.") + resetDisplayedValuesToDefault() + } + } + + // sets the data back to its default, likely due to a null info response + private fun resetDisplayedValuesToDefault() { + _uiState.update { state -> + state.copy( + showNodeCountsAsRefreshing = true, + currentSentPriceUSDString = unavailableString, + currentMarketCapUSDString = unavailableString, + currentStakingRewardPoolString = unavailableString, + networkSecuredBySENTString = unavailableString, + networkSecuredByUSDString = "\$- ${USD_NAME_SHORT}", + infoResponseData = null, + priceDataPopupText = Phrase.from(SESSION_NETWORK_DATA_PRICE) + .put(DATE_TIME_KEY, "-") + .format().toString() + ) + } + } + + private fun refreshData() { + _uiState.update { + it.copy(isRefreshing = true) + } + + viewModelScope.launch { + // if the data isn't stale then we don't need to refresh it, instead we fake a small wait + try { + if (!tokenDataManager.fetchInfoDataIfNeeded()) { + // If there is no fresh server data then we'll update the UI elements to show their loading + // state for half a second then put them back as they were. + showLoading() + delay(timeMillis = 500) + handleInfoResponse(infoResponse) + } + } catch (e: Exception){ /* exception can be ignored here as the infoResponse can return a wrapped failure object */ } + + // Reset the refreshing state when done + delay(100) // it seems there's a bug in compose where the refresh does not go away if hidden too quickly + _uiState.update { state -> + state.copy( + isRefreshing = false + ) + } + } + } + + // Method to populate both the number of nodes in our swarm and the number of nodes protecting our messages. + // Note: We pass this in to the token page so we can call it when we refresh the page. + private suspend fun getNodeData() { + withContext(Dispatchers.Default) { + val myPublicKey = prefs.getLocalNumber() ?: return@withContext + + val getSwarmSetPromise: Promise, Exception> = + SnodeAPI.getSwarm(myPublicKey) + + val numSessionNodesInOurSwarm = try { + // Get the count of Session nodes in our swarm (technically in the range 1..10, but + // even a new account seems to start with a nodes-in-swarm count of 4). + getSwarmSetPromise.await().size + } catch (e: Exception) { + Log.w(TAG, "Couldn't get nodes in swarm count.", e) + 5 // Pick a sane middle-ground should we error for any reason + } + + // 2.) Session nodes protecting our messages + var num1to1Convos = 0 + var numLegacyGroupConvos = 0 + var numGroupV2Convos = 0 + + // Grab the database and reader details we need to count the conversations / groups + val threadDatabase = DatabaseComponent.get(context).threadDatabase() + val cursor = threadDatabase.approvedConversationList + val result = mutableSetOf() + + // Look through the database to build up our conversation & group counts (still on Dispatchers.IO not the main thread) + threadDatabase.readerFor(cursor).use { reader -> + while (reader.next != null) { + val thread = reader.current + val recipient = thread.recipient + result.add(recipient) + + if (recipient.is1on1) { + num1to1Convos += 1 + } else if (recipient.isGroupV2Recipient) { + numGroupV2Convos += 1 + } else if (recipient.isLegacyGroupRecipient) { + numLegacyGroupConvos += 1 + } + } + } + + // This is hard-coded to 2 on Android but may vary on other platforms + val pathCount = OnionRequestAPI.paths.value.size + + /* + Note: Num session nodes securing you messages formula is: + min( + total_service_node_cache_size, << this part comes from the networkData in infoResponse: networkSize + ( + num_swarm_nodes + + (num_paths * 3) + + ( + (num_1_to_1_convos * 6) + + (num_legacy_group_convos * 6) + + (num_group_v2_convos * 6) + ) + ) + ) + */ + var nodeFormula = numSessionNodesInOurSwarm + + (pathCount * 3) + + (num1to1Convos * 6) + + (numLegacyGroupConvos * 6) + + (numGroupV2Convos * 6) + + // if we already have some server data though, we should apply the cap + // if not this cap will be applied once we get the server data + if(infoResponse?.networkData?.networkSize != null){ + nodeFormula = min(infoResponse!!.networkData.networkSize, nodeFormula) + } + + _uiState.update { state -> + state.copy( + currentSessionNodesInSwarm = numSessionNodesInOurSwarm, + currentSessionNodesSecuringMessages = nodeFormula + ) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt new file mode 100644 index 0000000000..041e64a1cb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt @@ -0,0 +1,116 @@ +package org.thoughtcrime.securesms.tokenpage + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import okhttp3.Headers.Companion.toHeaders +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.file_server.FileServerApi +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.utilities.await +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.toHexString +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +interface TokenRepository { + suspend fun getInfoResponse(): InfoResponse? +} + +@Singleton +class TokenRepositoryImpl @Inject constructor( + @param:ApplicationContext val context: Context, + private val storage: StorageProtocol, + private val json: Json, +): TokenRepository { + private val TAG = "TokenRepository" + + private val TOKEN_SERVER_URL = "http://networkv1.getsession.org" + private val TOKEN_SERVER_INFO_ENDPOINT = "$TOKEN_SERVER_URL/info" + private val SERVER_PUBLIC_KEY = "cbf461a4431dc9174dceef4421680d743a2a0e1a3131fc794240bcb0bc3dd449" + + private val secretKey by lazy { + storage.getUserED25519KeyPair()?.secretKey?.data + ?: throw (FileServerApi.Error.NoEd25519KeyPair) + } + + private val userBlindedKeys by lazy { + BlindKeyAPI.blindVersionKeyPair(secretKey) + } + + private fun defaultErrorHandling(e: Exception): T? { + Log.e("TokenRepo", "Server error getting data: $e") + return null + } + + // Method to access the /info endpoint and retrieve a InfoResponse via onion-routing. + override suspend fun getInfoResponse(): InfoResponse? { + return sendOnionRequest( + path = "info", + url = TOKEN_SERVER_INFO_ENDPOINT + ) + } + + private suspend inline fun sendOnionRequest( + path: String, url: String, body: ByteArray? = null, + customCatch: (Exception) -> T? = { e -> defaultErrorHandling(e) } + ): T? { + val timestampSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds + val signature = BlindKeyAPI.blindVersionSignRequest( + ed25519SecretKey = secretKey, // Important: Use the ED25519 secret key here and NOT the blinded secret key! + timestamp = timestampSeconds, + path = ("/$path"), + body = body, + method = if (body == null) "GET" else "POST" + ) + + val headersMap = mapOf( + "X-FS-Pubkey" to "07" + userBlindedKeys.pubKey.data.toHexString(), + "X-FS-Timestamp" to timestampSeconds.toString(), + "X-FS-Signature" to Base64.encodeBytes(signature) // Careful: Do NOT add `android.util.Base64.NO_WRAP` to this - it breaks it. + ) + + var requestBuilder = Request.Builder() + requestBuilder = if (body == null) { + requestBuilder.get() + } else { + requestBuilder.post(body.toRequestBody()) + } + val request = requestBuilder + .url(url) + .headers(headersMap.toHeaders()) + .build() + + var response: T? = null + try { + val rawResponse = OnionRequestAPI.sendOnionRequest( + request = request, + server = TOKEN_SERVER_URL, // Note: The `request` contains the actual endpoint we'll hit + x25519PublicKey = SERVER_PUBLIC_KEY + ).await() + + val resultJsonString = rawResponse.body?.decodeToString() + if (resultJsonString == null) { + Log.w(TAG, "${T::class.java} decoded to null") + } else { + response = json.decodeFromString(resultJsonString) + } + } + catch (se: SerializationException) { + Log.e(TAG, "Got a serialization exception attempting to decode ${T::class.java}", se) + } + catch (e: Exception) { + val catchResponse = customCatch(e) + Log.e(TAG, "Got an error: $catchResponse") + return catchResponse + } + + return response + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt index 155082ad32..c59281b506 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.takeOrElse @@ -34,11 +35,8 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.max -import androidx.compose.ui.unit.times import com.squareup.phrase.Phrase import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.URL_KEY @@ -52,15 +50,31 @@ import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.bold - -class DialogButtonModel( +data class DialogButtonData( val text: GetString, + val qaTag: String? = null, val color: Color = Color.Unspecified, val dismissOnClick: Boolean = true, val enabled: Boolean = true, val onClick: () -> Unit = {}, ) +/** + * Data to display a simple dialog + */ +data class SimpleDialogData( + val title: String, + val message: CharSequence, + val positiveText: String? = null, + val positiveStyleDanger: Boolean = true, + val showXIcon: Boolean = false, + val negativeText: String? = null, + val positiveQaTag: String? = null, + val negativeQaTag: String? = null, + val onPositive: () -> Unit = {}, + val onNegative: () -> Unit = {} +) + @Composable fun AlertDialog( onDismissRequest: () -> Unit, @@ -68,7 +82,7 @@ fun AlertDialog( title: String? = null, text: String? = null, maxLines: Int? = null, - buttons: List? = null, + buttons: List? = null, showCloseButton: Boolean = false, content: @Composable () -> Unit = {} ) { @@ -92,7 +106,7 @@ fun AlertDialog( title: AnnotatedString? = null, text: AnnotatedString? = null, maxLines: Int? = null, - buttons: List? = null, + buttons: List? = null, showCloseButton: Boolean = false, content: @Composable () -> Unit = {} ) { @@ -100,86 +114,110 @@ fun AlertDialog( modifier = modifier, onDismissRequest = onDismissRequest, content = { - DialogBg { - // only show the 'x' button is required - if (showCloseButton) { - IconButton( - onClick = onDismissRequest, - modifier = Modifier.align(Alignment.TopEnd) - ) { - Icon( - painter = painterResource(id = R.drawable.ic_dialog_x), - tint = LocalColors.current.text, - contentDescription = "back" - ) - } - } + AlertDialogContent( + onDismissRequest = onDismissRequest, + title = title, + text = text, + maxLines = maxLines, + buttons = buttons, + showCloseButton = showCloseButton, + content = content + ) + } + ) +} - Column(modifier = Modifier.fillMaxWidth()) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .padding(top = LocalDimensions.current.spacing) - .padding(horizontal = LocalDimensions.current.smallSpacing) - ) { - title?.let { - Text( - text = it, - textAlign = TextAlign.Center, - style = LocalType.current.h7, - modifier = Modifier - .padding(bottom = LocalDimensions.current.xxsSpacing) - .qaTag(stringResource(R.string.AccessibilityId_modalTitle)) - ) - } - text?.let { - val textStyle = LocalType.current.large - var textModifier = Modifier.padding(bottom = LocalDimensions.current.xxsSpacing) +@Composable +fun AlertDialogContent( + onDismissRequest: () -> Unit, + title: AnnotatedString? = null, + text: AnnotatedString? = null, + maxLines: Int? = null, + buttons: List? = null, + showCloseButton: Boolean = false, + content: @Composable () -> Unit = {} +) { + DialogBg { + // only show the 'x' button is required + if (showCloseButton) { + IconButton( + onClick = onDismissRequest, + modifier = Modifier.align(Alignment.TopEnd) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_x), + tint = LocalColors.current.text, + contentDescription = "back" + ) + } + } - // if we have a maxLines, make the text scrollable - if(maxLines != null) { - val textHeight = with(LocalDensity.current) { - textStyle.lineHeight.toDp() - } * maxLines + Column(modifier = Modifier.fillMaxWidth()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(top = LocalDimensions.current.spacing) + .padding(horizontal = LocalDimensions.current.smallSpacing) + ) { + title?.let { + Text( + text = it, + textAlign = TextAlign.Center, + style = LocalType.current.h7, + modifier = Modifier + .padding(bottom = LocalDimensions.current.xxsSpacing) + .qaTag(R.string.AccessibilityId_modalTitle) + ) + } + text?.let { + val textStyle = LocalType.current.large + var textModifier = Modifier.padding(bottom = LocalDimensions.current.xxsSpacing) - textModifier = textModifier - .height(textHeight) - .verticalScroll(rememberScrollState()) - } + // if we have a maxLines, make the text scrollable + if(maxLines != null) { + val textHeight = with(LocalDensity.current) { + textStyle.lineHeight.toDp() + } * maxLines - Text( - text = it, - textAlign = TextAlign.Center, - style = textStyle, - modifier = textModifier - .qaTag(stringResource(R.string.AccessibilityId_modalMessage)) - ) - } - content() + textModifier = textModifier + .height(textHeight) + .verticalScroll(rememberScrollState()) } - buttons?.takeIf { it.isNotEmpty() }?.let { - Row(Modifier.height(IntrinsicSize.Min)) { - it.forEach { - DialogButton( - text = it.text(), - modifier = Modifier - .fillMaxHeight() - .qaTag(it.text.string()) - .weight(1f), - color = it.color, - enabled = it.enabled - ) { - it.onClick() - if (it.dismissOnClick) onDismissRequest() - } - } + + Text( + text = it, + textAlign = TextAlign.Center, + style = textStyle, + modifier = textModifier + .qaTag(R.string.AccessibilityId_modalMessage) + ) + } + content() + } + if(buttons?.isNotEmpty() == true) { + Row(Modifier.height(IntrinsicSize.Min)) { + buttons.forEach { + DialogButton( + text = it.text(), + modifier = Modifier + .fillMaxHeight() + .qaTag(it.qaTag ?: it.text.string()) + .weight(1f), + color = it.color, + enabled = it.enabled + ) { + it.onClick() + if (it.dismissOnClick) onDismissRequest() } } } + } else { + Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) } } - ) + } + } @Composable @@ -201,12 +239,12 @@ fun OpenURLAlertDialog( maxLines = 5, showCloseButton = true, // display the 'x' button buttons = listOf( - DialogButtonModel( + DialogButtonData( text = GetString(R.string.open), color = LocalColors.current.danger, onClick = { context.openUrl(url) } ), - DialogButtonModel( + DialogButtonData( text = GetString(android.R.string.copyUrl), onClick = { context.copyURLToClipboard(url) @@ -266,6 +304,7 @@ fun DialogBg( color = LocalColors.current.borders, shape = MaterialTheme.shapes.small ) + .clip(MaterialTheme.shapes.small) ) { content() @@ -286,10 +325,7 @@ fun LoadingDialog( Box { CircularProgressIndicator( modifier = Modifier.align(Alignment.Center), - //TODO: Leave this as hardcoded color for now as the dialog background (scrim) - // always seems to be dark. Can can revisit later when we have more control over - // the scrim color. - color = Color.White + color = LocalColors.current.accent ) } } else { @@ -309,7 +345,7 @@ fun LoadingDialog( title, modifier = Modifier .align(Alignment.CenterHorizontally) - .qaTag(stringResource(R.string.AccessibilityId_modalTitle)), + .qaTag(R.string.AccessibilityId_modalTitle), style = LocalType.current.large ) } @@ -328,13 +364,13 @@ fun PreviewSimpleDialog() { title = stringResource(R.string.warning), text = stringResource(R.string.onboardingBackAccountCreation), buttons = listOf( - DialogButtonModel( + DialogButtonData( GetString(stringResource(R.string.cancel)), color = LocalColors.current.danger, onClick = { } ), - DialogButtonModel( - GetString(stringResource(R.string.ok)) + DialogButtonData( + GetString(stringResource(android.R.string.ok)) ) ) ) @@ -350,11 +386,11 @@ fun PreviewXCloseDialog() { text = stringResource(R.string.urlOpenBrowser), showCloseButton = true, // display the 'x' button buttons = listOf( - DialogButtonModel( + DialogButtonData( text = GetString(R.string.onboardingTos), onClick = {} ), - DialogButtonModel( + DialogButtonData( text = GetString(R.string.onboardingPrivacy), onClick = {} ) @@ -364,6 +400,19 @@ fun PreviewXCloseDialog() { } } +@Preview +@Composable +fun PreviewXCloseNoButtonsDialog() { + PreviewTheme { + AlertDialog( + title = stringResource(R.string.urlOpen), + text = stringResource(R.string.urlOpenBrowser), + showCloseButton = true, // display the 'x' button + onDismissRequest = {} + ) + } +} + @Preview @Composable fun PreviewOpenURLDialog() { @@ -383,4 +432,4 @@ fun PreviewLoadingDialog() { title = stringResource(R.string.warning) ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt index d94cfc929d..6cd950c7fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt @@ -182,13 +182,13 @@ private fun HorizontalPagerIndicator( @OptIn(ExperimentalFoundationApi::class) @Composable fun RowScope.CarouselPrevButton(pagerState: PagerState) { - CarouselButton(pagerState, pagerState.canScrollBackward, R.drawable.ic_prev, -1) + CarouselButton(pagerState, pagerState.canScrollBackward, R.drawable.ic_chevron_left, -1) } @OptIn(ExperimentalFoundationApi::class) @Composable fun RowScope.CarouselNextButton(pagerState: PagerState) { - CarouselButton(pagerState, pagerState.canScrollForward, R.drawable.ic_next, 1) + CarouselButton(pagerState, pagerState.canScrollForward, R.drawable.ic_chevron_right, 1) } @OptIn(ExperimentalFoundationApi::class) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 0cda62e133..bd796d94d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -2,117 +2,241 @@ package org.thoughtcrime.securesms.ui import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.appcompat.content.res.AppCompatResources import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ButtonColors import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.TooltipState +import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import com.google.accompanist.drawablepainter.rememberDrawablePainter +import androidx.compose.ui.unit.times import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import network.loki.messenger.R -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.AccountId -import org.thoughtcrime.securesms.components.ProfilePictureView -import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.OptionsCardData -import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton +import org.thoughtcrime.securesms.ui.components.AccentOutlineButton import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator import org.thoughtcrime.securesms.ui.components.TitledRadioButton +import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.primaryBlue +import org.thoughtcrime.securesms.ui.theme.primaryGreen +import org.thoughtcrime.securesms.ui.theme.primaryOrange +import org.thoughtcrime.securesms.ui.theme.primaryPink +import org.thoughtcrime.securesms.ui.theme.primaryPurple +import org.thoughtcrime.securesms.ui.theme.primaryRed +import org.thoughtcrime.securesms.ui.theme.primaryYellow import org.thoughtcrime.securesms.ui.theme.transparentButtonColors import kotlin.math.roundToInt -interface Callbacks { - fun onSetClick(): Any? - fun setValue(value: T) +@Composable +fun AccountIdHeader( + modifier: Modifier = Modifier, + text: String = stringResource(R.string.accountId), + textStyle: TextStyle = LocalType.current.base, + textPaddingValues: PaddingValues = PaddingValues( + horizontal = LocalDimensions.current.contentSpacing, + vertical = LocalDimensions.current.xxsSpacing + ) +){ + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ){ + Box( + modifier = Modifier + .weight(1f) + .height(1.dp) + .background(color = LocalColors.current.borders) + ) + + Text( + modifier = Modifier + .border( + shape = MaterialTheme.shapes.large + ) + .padding(textPaddingValues) + , + text = text, + style = textStyle.copy(color = LocalColors.current.textSecondary) + ) + + Box( + modifier = Modifier + .weight(1f) + .height(1.dp) + .background(color = LocalColors.current.borders) + ) + } +} + +@Composable +fun PathDot( + modifier: Modifier = Modifier, + dotSize: Dp = LocalDimensions.current.iconMedium, + glowSize: Dp = LocalDimensions.current.xxsSpacing, + color: Color = primaryGreen +) { + val fullSize = dotSize + 2 * glowSize + Box( + modifier = modifier.size(fullSize), + contentAlignment = Alignment.Center + ) { + // Glow effect (outer circle with radial gradient) + Canvas(modifier = Modifier.fillMaxSize()) { + val center = Offset(this.size.width / 2, this.size.height / 2) + val radius = (fullSize * 0.5f).toPx() + + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + color, // Start color (opaque) + color.copy(alpha = 0f) // End color (transparent) + ), + center = center, + radius = radius + ), + center = center, + radius = radius + ) + } + + // Inner solid dot + Box( + modifier = Modifier + .size(dotSize) + .background( + color = color, + shape = CircleShape + ) + ) + } } -object NoOpCallbacks: Callbacks { - override fun onSetClick() {} - override fun setValue(value: Any) {} +@Preview +@Composable +fun PreviewPathDot(){ + PreviewTheme { + Box( + modifier = Modifier.padding(20.dp) + ) { + PathDot() + } + } } + data class RadioOption( val value: T, val title: GetString, val subtitle: GetString? = null, - val contentDescription: GetString = title, + @DrawableRes val iconRes: Int? = null, + val qaTag: GetString? = null, val selected: Boolean = false, val enabled: Boolean = true, ) +data class OptionsCardData( + val title: GetString?, + val options: List> +) { + constructor(title: GetString, vararg options: RadioOption): this(title, options.asList()) + constructor(@StringRes title: Int, vararg options: RadioOption): this(GetString(title), options.asList()) +} + @Composable -fun OptionsCard(card: OptionsCardData, callbacks: Callbacks) { +fun OptionsCard(card: OptionsCardData, onOptionSelected: (T) -> Unit) { Column { - Text( - modifier = Modifier.padding(start = LocalDimensions.current.smallSpacing), - text = card.title(), - style = LocalType.current.base, - color = LocalColors.current.textSecondary - ) + if (card.title != null && card.title.string().isNotEmpty()) { + Text( + modifier = Modifier.padding(start = LocalDimensions.current.smallSpacing), + text = card.title.string(), + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) - Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + } Cell { LazyColumn( @@ -120,7 +244,7 @@ fun OptionsCard(card: OptionsCardData, callbacks: Callbacks) { ) { itemsIndexed(card.options) { i, it -> if (i != 0) Divider() - TitledRadioButton(option = it) { callbacks.setValue(it.value) } + TitledRadioButton(option = it) { onOptionSelected(it.value) } } } } @@ -129,41 +253,69 @@ fun OptionsCard(card: OptionsCardData, callbacks: Callbacks) { @Composable fun LargeItemButtonWithDrawable( - @StringRes textId: Int, + text: GetString, @DrawableRes icon: Int, + iconTint: Color? = null, + iconSize: Dp? = null, modifier: Modifier = Modifier, + subtitle: String? = null, + @StringRes subtitleQaTag: Int? = null, colors: ButtonColors = transparentButtonColors(), + shape: Shape = RectangleShape, onClick: () -> Unit ) { ItemButtonWithDrawable( - textId, icon, modifier, - LocalType.current.h8, colors, onClick + text = text, + icon = icon, + iconTint = iconTint, + iconSize = iconSize, + modifier = modifier, + subtitle = subtitle, + subtitleQaTag = subtitleQaTag, + textStyle = LocalType.current.h8, + colors = colors, + shape = shape, + onClick = onClick ) } @Composable fun ItemButtonWithDrawable( - @StringRes textId: Int, + text: GetString, @DrawableRes icon: Int, + iconSize: Dp? = null, + iconTint: Color? = null, modifier: Modifier = Modifier, + subtitle: String? = null, + @StringRes subtitleQaTag: Int? = null, textStyle: TextStyle = LocalType.current.xl, colors: ButtonColors = transparentButtonColors(), + shape: Shape = RectangleShape, onClick: () -> Unit ) { - val context = LocalContext.current - ItemButton( - text = stringResource(textId), + annotatedStringText = AnnotatedString(text.string()), modifier = modifier, icon = { Image( - painter = rememberDrawablePainter(drawable = AppCompatResources.getDrawable(context, icon)), + painter = painterResource(id = icon), contentDescription = null, + colorFilter = iconTint?.let { ColorFilter.tint(it) }, modifier = Modifier.align(Alignment.Center) + .then( + if(iconSize != null) { + Modifier.size(iconSize) + } else { + Modifier + } + ) ) }, textStyle = textStyle, + subtitle = subtitle, + subtitleQaTag = subtitleQaTag, colors = colors, + shape = shape, onClick = onClick ) } @@ -173,16 +325,24 @@ fun LargeItemButton( @StringRes textId: Int, @DrawableRes icon: Int, modifier: Modifier = Modifier, + subtitle: String? = null, + @StringRes subtitleQaTag: Int? = null, + enabled: Boolean = true, colors: ButtonColors = transparentButtonColors(), + shape: Shape = RectangleShape, onClick: () -> Unit ) { ItemButton( textId = textId, icon = icon, modifier = modifier, + subtitle = subtitle, + subtitleQaTag = subtitleQaTag, + enabled = enabled, minHeight = LocalDimensions.current.minLargeItemButtonHeight, textStyle = LocalType.current.h8, colors = colors, + shape = shape, onClick = onClick ) } @@ -192,16 +352,47 @@ fun LargeItemButton( text: String, @DrawableRes icon: Int, modifier: Modifier = Modifier, + subtitle: String? = null, + @StringRes subtitleQaTag: Int? = null, + enabled: Boolean = true, colors: ButtonColors = transparentButtonColors(), + shape: Shape = RectangleShape, onClick: () -> Unit ) { ItemButton( text = text, icon = icon, modifier = modifier, + subtitle = subtitle, + subtitleQaTag = subtitleQaTag, + enabled = enabled, + minHeight = LocalDimensions.current.minLargeItemButtonHeight, + textStyle = LocalType.current.h8, + colors = colors, + shape = shape, + onClick = onClick + ) +} + +@Composable +fun LargeItemButton( + annotatedStringText: AnnotatedString, + icon: @Composable BoxScope.() -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: ButtonColors = transparentButtonColors(), + shape: Shape = RectangleShape, + onClick: () -> Unit +) { + ItemButton( + modifier = modifier, + annotatedStringText = annotatedStringText, + icon = icon, + enabled = enabled, minHeight = LocalDimensions.current.minLargeItemButtonHeight, textStyle = LocalType.current.h8, colors = colors, + shape = shape, onClick = onClick ) } @@ -209,15 +400,19 @@ fun LargeItemButton( @Composable fun ItemButton( text: String, - icon: Int, + @DrawableRes icon: Int, modifier: Modifier, + subtitle: String? = null, + @StringRes subtitleQaTag: Int? = null, + enabled: Boolean = true, minHeight: Dp = LocalDimensions.current.minItemButtonHeight, textStyle: TextStyle = LocalType.current.xl, colors: ButtonColors = transparentButtonColors(), + shape: Shape = RectangleShape, onClick: () -> Unit ) { ItemButton( - text = text, + annotatedStringText = AnnotatedString(text), modifier = modifier, icon = { Icon( @@ -228,8 +423,12 @@ fun ItemButton( }, minHeight = minHeight, textStyle = textStyle, + shape = shape, colors = colors, - onClick = onClick + subtitle = subtitle, + subtitleQaTag = subtitleQaTag, + enabled = enabled, + onClick = onClick, ) } @@ -241,14 +440,50 @@ fun ItemButton( @StringRes textId: Int, @DrawableRes icon: Int, modifier: Modifier = Modifier, + subtitle: String? = null, + @StringRes subtitleQaTag: Int? = null, + enabled: Boolean = true, + minHeight: Dp = LocalDimensions.current.minItemButtonHeight, + textStyle: TextStyle = LocalType.current.xl, + colors: ButtonColors = transparentButtonColors(), + shape: Shape = RectangleShape, + onClick: () -> Unit +) { + ItemButton( + annotatedStringText = AnnotatedString(stringResource(textId)), + modifier = modifier, + icon = icon, + minHeight = minHeight, + textStyle = textStyle, + shape = shape, + colors = colors, + subtitle = subtitle, + subtitleQaTag = subtitleQaTag, + enabled = enabled, + onClick = onClick + ) +} + +@Composable +fun ItemButton( + annotatedStringText: AnnotatedString, + icon: Int, + modifier: Modifier, + subtitle: String? = null, + @StringRes subtitleQaTag: Int? = null, + enabled: Boolean = true, minHeight: Dp = LocalDimensions.current.minItemButtonHeight, textStyle: TextStyle = LocalType.current.xl, colors: ButtonColors = transparentButtonColors(), + shape: Shape = RectangleShape, onClick: () -> Unit ) { ItemButton( - text = stringResource(textId), + annotatedStringText = annotatedStringText, modifier = modifier, + subtitle = subtitle, + subtitleQaTag = subtitleQaTag, + enabled = enabled, icon = { Icon( painter = painterResource(id = icon), @@ -259,23 +494,29 @@ fun ItemButton( minHeight = minHeight, textStyle = textStyle, colors = colors, + shape = shape, onClick = onClick ) } /** -* Base [ItemButton] implementation. + * Base [ItemButton] implementation using an AnnotatedString rather than a plain String. * * A button to be used in a list of buttons, usually in a [Cell] or [Card] -*/ + */ +// THIS IS THE FINAL DEEP LEVEL ANNOTATED STRING BUTTON @Composable fun ItemButton( - text: String, + annotatedStringText: AnnotatedString, icon: @Composable BoxScope.() -> Unit, modifier: Modifier = Modifier, + subtitle: String? = null, + @StringRes subtitleQaTag: Int? = null, + enabled: Boolean = true, minHeight: Dp = LocalDimensions.current.minLargeItemButtonHeight, textStyle: TextStyle = LocalType.current.xl, colors: ButtonColors = transparentButtonColors(), + shape: Shape = RectangleShape, onClick: () -> Unit ) { TextButton( @@ -283,7 +524,8 @@ fun ItemButton( colors = colors, onClick = onClick, contentPadding = PaddingValues(), - shape = RectangleShape, + enabled = enabled, + shape = shape, ) { Box( modifier = Modifier @@ -293,13 +535,28 @@ fun ItemButton( content = icon ) - Text( - text, - Modifier + Column( + modifier = Modifier .fillMaxWidth() - .align(Alignment.CenterVertically), - style = textStyle - ) + .align(Alignment.CenterVertically) + ) { + Text( + annotatedStringText, + Modifier + .fillMaxWidth(), + style = textStyle + ) + + subtitle?.let { + Text( + text = it, + modifier = Modifier + .fillMaxWidth() + .qaTag(subtitleQaTag), + style = LocalType.current.small, + ) + } + } } } @@ -309,7 +566,7 @@ fun PreviewItemButton() { PreviewTheme { ItemButton( textId = R.string.groupCreate, - icon = R.drawable.ic_group, + icon = R.drawable.ic_users_group_custom, onClick = {} ) } @@ -321,7 +578,7 @@ fun PreviewLargeItemButton() { PreviewTheme { LargeItemButton( textId = R.string.groupCreate, - icon = R.drawable.ic_group, + icon = R.drawable.ic_users_group_custom, onClick = {} ) } @@ -334,35 +591,32 @@ fun Cell( ) { Box( modifier = modifier + .clip(MaterialTheme.shapes.small) .background( color = LocalColors.current.backgroundSecondary, - shape = MaterialTheme.shapes.small ) .wrapContentHeight() - .fillMaxWidth(), + .fillMaxWidth() ) { content() } } @Composable -fun Modifier.contentDescription(text: GetString?): Modifier { - return text?.let { - val context = LocalContext.current - semantics { contentDescription = it(context) } - } ?: this -} - -@Composable -fun Modifier.contentDescription(@StringRes id: Int?): Modifier { - val context = LocalContext.current - return id?.let { semantics { contentDescription = context.getString(it) } } ?: this -} +fun getCellTopShape() = RoundedCornerShape( + topStart = LocalDimensions.current.shapeSmall, + topEnd = LocalDimensions.current.shapeSmall, + bottomEnd = 0.dp, + bottomStart = 0.dp +) @Composable -fun Modifier.contentDescription(text: String?): Modifier { - return text?.let { semantics { contentDescription = it } } ?: this -} +fun getCellBottomShape() = RoundedCornerShape( + topStart = 0.dp, + topEnd = 0.dp, + bottomEnd = LocalDimensions.current.shapeSmall, + bottomStart = LocalDimensions.current.shapeSmall +) @Composable fun BottomFadingEdgeBox( @@ -382,7 +636,7 @@ fun BottomFadingEdgeBox( .background( Brush.verticalGradient( 0f to Color.Transparent, - 1f to fadingColor, + 0.9f to fadingColor, tileMode = TileMode.Repeated ) ) @@ -409,7 +663,7 @@ private fun BottomFadingEdgeBoxPreview() { }, ) - PrimaryOutlineButton( + AccentOutlineButton( modifier = Modifier .align(Alignment.CenterHorizontally), text = "Do stuff", onClick = {} @@ -427,105 +681,6 @@ fun Divider(modifier: Modifier = Modifier, startIndent: Dp = 0.dp) { ) } -//TODO This component should be fully rebuilt in Compose at some point ~~ -@Composable -private fun BaseAvatar( - modifier: Modifier = Modifier, - isAdmin: Boolean = false, - update: (ProfilePictureView)->Unit -){ - Box( - modifier = modifier - ) { - // image - if (LocalInspectionMode.current) { // this part is used for previews only - Image( - painterResource(id = R.drawable.ic_profile_default), - colorFilter = ColorFilter.tint(LocalColors.current.textSecondary), - contentScale = ContentScale.Inside, - contentDescription = null, - modifier = Modifier - .size(LocalDimensions.current.iconLarge) - .clip(CircleShape) - .border(1.dp, LocalColors.current.borders, CircleShape) - ) - } else { - AndroidView( - factory = { - ProfilePictureView(it) - }, - update = update - ) - } - - // badge - if (isAdmin) { - Image( - painter = painterResource(id = R.drawable.ic_crown_custom), - contentDescription = null, - modifier = Modifier - .align(Alignment.BottomEnd) - .offset(1.dp, 1.dp) // used to make up for trasparent padding in icon - .size(LocalDimensions.current.badgeSize) - ) - } - } -} - -@Preview -@Composable -fun PreviewAvatar() { - PreviewTheme { - Avatar( - modifier = Modifier.padding(20.dp), - isAdmin = true, - accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235") - ) - } -} - -@Composable -fun Avatar( - recipient: Recipient, - modifier: Modifier = Modifier, - isAdmin: Boolean = false -) { - BaseAvatar( - modifier = modifier, - isAdmin = isAdmin, - update = { - it.update(recipient) - } - ) -} - -@Composable -fun Avatar( - userAddress: Address, - modifier: Modifier = Modifier, - isAdmin: Boolean = false -) { - BaseAvatar( - modifier = modifier, - isAdmin = isAdmin, - update = { - it.update(userAddress) - } - ) -} - -@Composable -fun Avatar( - accountId: AccountId, - modifier: Modifier = Modifier, - isAdmin: Boolean = false -) { - Avatar(Address.fromSerialized(accountId.hexString), - modifier = modifier, - isAdmin = isAdmin - ) -} - @Composable fun ProgressArc(progress: Float, modifier: Modifier = Modifier) { val text = (progress * 100).roundToInt() @@ -545,7 +700,7 @@ fun ProgressArc(progress: Float, modifier: Modifier = Modifier) { fun Arc( modifier: Modifier = Modifier, percentage: Float = 0.25f, - fillColor: Color = LocalColors.current.primary, + fillColor: Color = LocalColors.current.accent, backgroundColor: Color = LocalColors.current.borders, strokeWidth: Dp = 18.dp, sweepAngle: Float = 310f, @@ -578,12 +733,14 @@ fun Arc( } @Composable -fun RowScope.SessionShieldIcon() { +fun SessionShieldIcon( + modifier: Modifier = Modifier +) { Icon( - painter = painterResource(R.drawable.session_shield), + painter = painterResource(R.drawable.ic_recovery_password_custom), contentDescription = null, - modifier = Modifier - .align(Alignment.CenterVertically) + modifier = modifier + .size(16.dp) .wrapContentSize(unbounded = true) ) } @@ -605,10 +762,50 @@ fun LoadingArcOr(loading: Boolean, content: @Composable () -> Unit) { } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SpeechBubbleTooltip( + text: CharSequence, + modifier: Modifier = Modifier, + maxWidth: Dp = LocalDimensions.current.maxTooltipWidth, + tooltipState: TooltipState = rememberTooltipState(), + content: @Composable () -> Unit, +) { + TooltipBox( + state = tooltipState, + modifier = modifier, + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + val bubbleColor = LocalColors.current.backgroundBubbleReceived + + Card( + modifier = Modifier.widthIn(max = maxWidth), + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors(containerColor = bubbleColor), + elevation = CardDefaults.elevatedCardElevation(4.dp) + ) { + Text( + text = annotatedStringResource(text), + modifier = Modifier.padding( + horizontal = LocalDimensions.current.xsSpacing, + vertical = LocalDimensions.current.xxsSpacing + ), + textAlign = TextAlign.Center, + style = LocalType.current.small, + color = LocalColors.current.text + ) + } + } + ) { + content() + } +} + @Composable fun SearchBar( query: String, onValueChanged: (String) -> Unit, + onClear: () -> Unit, modifier: Modifier = Modifier, placeholder: String? = null, enabled: Boolean = true, @@ -624,10 +821,11 @@ fun SearchBar( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .background(backgroundColor, RoundedCornerShape(100)) + .heightIn(min = LocalDimensions.current.minSearchInputHeight) + .background(backgroundColor, MaterialTheme.shapes.small) ) { Image( - painterResource(id = R.drawable.ic_search_24), + painterResource(id = R.drawable.ic_search), contentDescription = null, colorFilter = ColorFilter.tint( LocalColors.current.textSecondary @@ -637,23 +835,314 @@ fun SearchBar( horizontal = LocalDimensions.current.smallSpacing, vertical = LocalDimensions.current.xxsSpacing ) - .size(LocalDimensions.current.iconMedium) + .size(LocalDimensions.current.iconSmall) ) Box(modifier = Modifier.weight(1f)) { innerTextField() if (query.isEmpty() && placeholder != null) { Text( + modifier = Modifier.qaTag(R.string.qa_conversation_search_input), text = placeholder, color = LocalColors.current.textSecondary, style = LocalType.current.xl ) } } + + Image( + painterResource(id = R.drawable.ic_x), + contentDescription = stringResource(R.string.clear), + colorFilter = ColorFilter.tint( + LocalColors.current.textSecondary + ), + modifier = Modifier + .qaTag(R.string.qa_input_clear) + .padding( + horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xxsSpacing + ) + .size(LocalDimensions.current.iconSmall) + .clickable { + onClear() + } + ) } }, textStyle = LocalType.current.base.copy(color = LocalColors.current.text), modifier = modifier, cursorBrush = SolidColor(LocalColors.current.text) ) +} + +@Preview +@Composable +fun PreviewSearchBar() { + PreviewTheme { + SearchBar( + query = "", + onValueChanged = {}, + onClear = {}, + placeholder = "Search" + ) + } +} + +/** + * The convenience based expandable text which handles some internal state + */ +@Composable +fun ExpandableText( + text: String, + modifier: Modifier = Modifier, + textStyle: TextStyle = LocalType.current.base, + buttonTextStyle: TextStyle = LocalType.current.base, + textColor: Color = LocalColors.current.text, + buttonTextColor: Color = LocalColors.current.text, + textAlign: TextAlign = TextAlign.Start, + @StringRes qaTag: Int? = null, + collapsedMaxLines: Int = 2, + expandedMaxLines: Int = Int.MAX_VALUE, + expandButtonText: String = stringResource(id = R.string.viewMore), + collapseButtonText: String = stringResource(id = R.string.viewLess), +){ + var expanded by remember { mutableStateOf(false) } + var showButton by remember { mutableStateOf(false) } + var maxHeight by remember { mutableStateOf(Dp.Unspecified) } + + val density = LocalDensity.current + + val enableScrolling = expanded && maxHeight != Dp.Unspecified && expandedMaxLines != Int.MAX_VALUE + + BaseExpandableText( + text = text, + modifier = modifier, + textStyle = textStyle, + buttonTextStyle = buttonTextStyle, + textColor = textColor, + buttonTextColor = buttonTextColor, + textAlign = textAlign, + qaTag = qaTag, + collapsedMaxLines = collapsedMaxLines, + expandedMaxHeight = maxHeight ?: Dp.Unspecified, + expandButtonText = expandButtonText, + collapseButtonText = collapseButtonText, + showButton = showButton, + expanded = expanded, + showScroll = enableScrolling, + onTextMeasured = { textLayoutResult -> + showButton = expanded || textLayoutResult.hasVisualOverflow + val lastVisible = (expandedMaxLines - 1).coerceAtMost(textLayoutResult.lineCount - 1) + val px = textLayoutResult.getLineBottom(lastVisible) // bottom of that line in px + maxHeight = with(density) { px.toDp() } + }, + onTap = if(showButton){ // only expand if there is enough text + { expanded = !expanded } + } else null + ) +} + +@Preview +@Composable +private fun PreviewExpandedTextShort() { + PreviewTheme { + ExpandableText( + text = "This" + ) + } +} + +@Preview +@Composable +private fun PreviewExpandedTextLongExpanded() { + PreviewTheme { + ExpandableText( + text = "This is a long description with a lot of text that should be more than 2 lines and should be truncated but you never know, it depends on size and such things dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk lkasdjfalsdkfjasdklfj lsadkfjalsdkfjsadklf lksdjfalsdkfjasdlkfjasdlkf asldkfjasdlkfja and this is the end", + ) + } +} + +@Preview +@Composable +private fun PreviewExpandedTextLongMaxLinesExpanded() { + PreviewTheme { + ExpandableText( + text = "This is a long description with a lot of text that should be more than 2 lines and should be truncated but you never know, it depends on size and such things dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk lkasdjfalsdkfjasdklfj lsadkfjalsdkfjsadklf lksdjfalsdkfjasdlkfjasdlkf asldkfjasdlkfja and this is the end", + expandedMaxLines = 10 + ) + } +} + +/** + * The base stateless version of the expandable text + */ +@Composable +fun BaseExpandableText( + text: String, + modifier: Modifier = Modifier, + textStyle: TextStyle = LocalType.current.base, + buttonTextStyle: TextStyle = LocalType.current.base, + textColor: Color = LocalColors.current.text, + buttonTextColor: Color = LocalColors.current.text, + textAlign: TextAlign = TextAlign.Start, + @StringRes qaTag: Int? = null, + collapsedMaxLines: Int = 2, + expandedMaxHeight: Dp = Dp.Unspecified, + expandButtonText: String = stringResource(id = R.string.viewMore), + collapseButtonText: String = stringResource(id = R.string.viewLess), + showButton: Boolean = false, + expanded: Boolean = false, + showScroll: Boolean = false, + onTextMeasured: (TextLayoutResult) -> Unit = {}, + onTap: (() -> Unit)? = null +){ + var textModifier: Modifier = Modifier + if(qaTag != null) textModifier = textModifier.qaTag(qaTag) + if(expanded) textModifier = textModifier.height(expandedMaxHeight) + if(showScroll){ + val scrollState = rememberScrollState() + val scrollEdge = LocalDimensions.current.xxxsSpacing + val scrollWidth = 2.dp + textModifier = textModifier + .verticalScrollbar( + state = scrollState, + scrollbarWidth = scrollWidth, + edgePadding = scrollEdge + ) + .verticalScroll(scrollState) + .padding(end = scrollWidth + scrollEdge * 2) + } + + Column( + modifier = modifier.then( + if(onTap != null) Modifier.clickable { onTap() } else Modifier + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = textModifier.animateContentSize(), + onTextLayout = { + onTextMeasured(it) + }, + text = text, + textAlign = textAlign, + style = textStyle, + color = textColor, + maxLines = if (expanded) Int.MAX_VALUE else collapsedMaxLines, + overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis + ) + + if(showButton) { + Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) + Text( + text = if (expanded) collapseButtonText else expandButtonText, + style = buttonTextStyle, + color = buttonTextColor + ) + } + } +} + + +@Preview +@Composable +private fun PreviewBaseExpandedTextShort() { + PreviewTheme { + BaseExpandableText( + text = "This is a short description" + ) + } +} + +@Preview +@Composable +private fun PreviewBaseExpandedTextShortWithButton() { + PreviewTheme { + BaseExpandableText( + text = "Aaa", + showButton = true, + expanded = true + ) + } +} + +@Preview +@Composable +private fun PreviewBaseExpandedTextLong() { + PreviewTheme { + BaseExpandableText( + text = "This is a long description with a lot of text that should be more than 2 lines and should be truncated but you never know, it depends on size and such things dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk lkasdjfalsdkfjasdklfj lsadkfjalsdkfjsadklf lksdjfalsdkfjasdlkfjasdlkf asldkfjasdlkfja and this is the end", + showButton = true + ) + } +} + +@Preview +@Composable +private fun PreviewBaseExpandedTextLongExpanded() { + PreviewTheme { + BaseExpandableText( + text = "This is a long description with a lot of text that should be more than 2 lines and should be truncated but you never know, it depends on size and such things dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk lkasdjfalsdkfjasdklfj lsadkfjalsdkfjsadklf lksdjfalsdkfjasdlkfjasdlkf asldkfjasdlkfja and this is the end", + showButton = true, + expanded = true + ) + } +} + +@Preview +@Composable +private fun PreviewBaseExpandedTextLongExpandedMaxLines() { + PreviewTheme { + BaseExpandableText( + text = "This is a long description with a lot of text that should be more than 2 lines and should be truncated but you never know, it depends on size and such things dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk dfkjdfklj asjdlkj lkjdf lkjsa dlkfjlk asdflkjlksdfjklasdfjasdlkfjasdflk lkasdjfalsdkfjasdklfj lsadkfjalsdkfjsadklf lksdjfalsdkfjasdlkfjasdlkf asldkfjasdlkfja and this is the end", + showButton = true, + expanded = true, + expandedMaxHeight = 200.dp, + showScroll = true + ) + } +} + +/** + * Animated gradient drawable that cycle through the gradient colors in a linear animation + */ +@Composable +fun AnimatedGradientDrawable( + @DrawableRes vectorRes: Int, + modifier: Modifier = Modifier, + gradientColors: List = listOf( + primaryGreen, primaryBlue, primaryPurple, + primaryPink, primaryRed, primaryOrange, primaryYellow + ) +) { + val infiniteTransition = rememberInfiniteTransition(label = "vector_vertical") + val animatedOffset by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 200f, + animationSpec = infiniteRepeatable( + animation = tween(3000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ) + ) + + Icon( + painter = painterResource(id = vectorRes), + contentDescription = null, + modifier = modifier + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + val gradientBrush = Brush.linearGradient( + colors = gradientColors, + start = Offset(0f, animatedOffset), + end = Offset(0f, animatedOffset + 100f), + tileMode = TileMode.Mirror + ) + + drawContent() + drawRect( + brush = gradientBrush, + blendMode = BlendMode.SrcAtop + ) + } + ) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Modifiers.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Modifiers.kt new file mode 100644 index 0000000000..4c76695858 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Modifiers.kt @@ -0,0 +1,320 @@ +package org.thoughtcrime.securesms.ui + +import androidx.annotation.StringRes +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.StartOffsetType +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions + + +/** + * This is used to set the test tag that the QA team can use to retrieve an element in appium + * In order to do so we need to set the testTagsAsResourceId to true, which ideally should be done only once + * in the root composable, but our app is currently made up of multiple isolated composables + * set up in the old activity/fragment view system + * As such we need to repeat it for every component that wants to use testTag, until such + * a time as we have one root composable + */ +@Composable +fun Modifier.qaTag(tag: String?): Modifier { + if (tag == null) return this + return this.semantics { testTagsAsResourceId = true }.testTag(tag) +} + +@Composable +fun Modifier.qaTag(@StringRes tagResId: Int?): Modifier { + if (tagResId == null) return this + return this.semantics { testTagsAsResourceId = true }.testTag(stringResource(tagResId)) +} + +@Composable +fun Modifier.border( + shape: Shape = MaterialTheme.shapes.small +) = this.border( + width = LocalDimensions.current.borderStroke, + brush = SolidColor(LocalColors.current.borders), + shape = shape +) + + +@Composable +fun Modifier.contentDescription(text: GetString?): Modifier { + return text?.let { + val context = LocalContext.current + semantics { contentDescription = it(context) } + } ?: this +} + +@Composable +fun Modifier.contentDescription(@StringRes id: Int?): Modifier { + val context = LocalContext.current + return id?.let { semantics { contentDescription = context.getString(it) } } ?: this +} + +@Composable +fun Modifier.contentDescription(text: String?): Modifier { + return text?.let { semantics { contentDescription = it } } ?: this +} + +/** + * Applies an opinionated safety width on content based our design decisions: + * - Max width of maxContentWidth + * - Extra horizontal padding + * - Smaller extra padding for small devices (arbitrarily decided as devices below 380 width + */ +@Composable +fun Modifier.safeContentWidth( + regularExtraPadding: Dp = LocalDimensions.current.mediumSpacing, + smallExtraPadding: Dp = LocalDimensions.current.xsSpacing, +): Modifier { + val screenWidthDp = LocalConfiguration.current.screenWidthDp.dp + + return this + .widthIn(max = LocalDimensions.current.maxContentWidth) + .padding( + horizontal = when { + screenWidthDp < 380.dp -> smallExtraPadding + else -> regularExtraPadding + } + ) +} + +// Permanently visible vertical scrollbar. +// Note: This scrollbar modifier was adapted from Mardann's fantastic solution at: https://stackoverflow.com/a/78453760/24337669 +@Composable +fun Modifier.verticalScrollbar( + state: ScrollState, + scrollbarWidth: Dp = 6.dp, + barColour: Color = LocalColors.current.textSecondary, + backgroundColour: Color = LocalColors.current.borders, + edgePadding: Dp = LocalDimensions.current.xxsSpacing +): Modifier { + // Calculate the viewport and content heights + val viewHeight = state.viewportSize.toFloat() + val contentHeight = state.maxValue + viewHeight + + // Determine if the scrollbar is needed + val isScrollbarNeeded = contentHeight > viewHeight + + // Set the target alpha based on whether scrolling is possible + val alphaTarget = when { + !isScrollbarNeeded -> 0f // No scrollbar needed, set alpha to 0f + state.isScrollInProgress -> 1f + else -> 0.2f + } + + // Animate the alpha value smoothly + val alpha by animateFloatAsState( + targetValue = alphaTarget, + animationSpec = tween(400, delayMillis = if (state.isScrollInProgress) 0 else 700), + label = "VerticalScrollbarAnimation" + ) + + return this.then(Modifier.drawWithContent { + drawContent() + + // Only proceed if the scrollbar is needed + if (isScrollbarNeeded) { + val minScrollBarHeight = 10.dp.toPx() + val maxScrollBarHeight = viewHeight + val scrollbarHeight = (viewHeight * (viewHeight / contentHeight)).coerceIn( + minOf(minScrollBarHeight, maxScrollBarHeight)..maxOf(minScrollBarHeight, maxScrollBarHeight) + ) + val variableZone = viewHeight - scrollbarHeight + val scrollbarYoffset = (state.value.toFloat() / state.maxValue) * variableZone + + // Calculate the horizontal offset with padding + val scrollbarXOffset = size.width - scrollbarWidth.toPx() - edgePadding.toPx() + + // Draw the missing section of the scrollbar track + drawRoundRect( + color = backgroundColour, + topLeft = Offset(scrollbarXOffset, 0f), + size = Size(scrollbarWidth.toPx(), viewHeight), + cornerRadius = CornerRadius(scrollbarWidth.toPx() / 2), + alpha = alpha + ) + + // Draw the scrollbar thumb + drawRoundRect( + color = barColour, + topLeft = Offset(scrollbarXOffset, scrollbarYoffset), + size = Size(scrollbarWidth.toPx(), scrollbarHeight), + cornerRadius = CornerRadius(scrollbarWidth.toPx() / 2), + alpha = alpha + ) + } + }) +} + + +/** + * Creates a shimmer overlay effect that renders on top of existing content + * @param color The base shimmer color (usually semi-transparent) + * @param highlightColor The highlight color that moves across + * @param animationDuration Duration of one shimmer cycle in milliseconds + * @param delayBetweenCycles Delay between animation cycles in milliseconds (0 = continuous) + * @param initialDelay Delay before the very first animation starts in milliseconds (0 = start immediately) + */ +fun Modifier.shimmerOverlay( + color: Color = Color.White.copy(alpha = 0.0f), + highlightColor: Color = Color.White.copy(alpha = 0.6f), + animationDuration: Int = 1200, + delayBetweenCycles: Int = 3000, + initialDelay: Int = 0 +): Modifier = composed { + // Single transition with a one-off start delay + val progress by rememberInfiniteTransition(label = "shimmer") + .animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = animationDuration + delayBetweenCycles + 0f at 0 + 1f at animationDuration + 1f at animationDuration + delayBetweenCycles + }, + initialStartOffset = StartOffset( + initialDelay, + StartOffsetType.Delay + ), + repeatMode = RepeatMode.Restart + ), + label = "shimmerProgress" + ) + + graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + } + .drawWithCache { + // Work this out once per size change, not every frame + val diagonal = kotlin.math.hypot(size.width, size.height) + val bandWidth = diagonal * 0.30f + val travelDist = size.width + bandWidth * 2 + + onDrawWithContent { + drawContent() + + // Map 0-1 progress → current band centre + val centre = -bandWidth + progress * travelDist + + val brush = Brush.linearGradient( + colors = listOf( + Color.Transparent, + color, + highlightColor, + color, + Color.Transparent + ), + start = Offset(centre - bandWidth, centre - bandWidth), + end = Offset(centre + bandWidth, centre + bandWidth) + ) + + drawRect( + brush = brush, + size = size, + blendMode = BlendMode.SrcAtop // only where there’s content + ) + } + } +} + +private fun createShimmerBrush( + progress: Float, + color: Color, + highlightColor: Color, + width: Float, + height: Float +): Brush { + // Calculate the diagonal distance to ensure shimmer covers the entire component + val diagonal = kotlin.math.sqrt(width * width + height * height) + val shimmerWidth = diagonal * 0.3f // Width of the shimmer band + + // Start completely off-screen (left side) and end completely off-screen (right side) + val totalDistance = width + shimmerWidth * 2 + val currentPosition = -shimmerWidth + (progress * totalDistance) + + return Brush.linearGradient( + colors = listOf( + Color.Transparent, + color, + highlightColor, + color, + Color.Transparent + ), + start = Offset( + x = currentPosition - shimmerWidth, + y = currentPosition - shimmerWidth + ), + end = Offset( + x = currentPosition + shimmerWidth, + y = currentPosition + shimmerWidth + ) + ) +} + +private fun DrawScope.drawShimmerOverlay( + progress: Float, + color: Color, + highlightColor: Color +) { + val shimmerBrush = createShimmerBrush( + progress = progress, + color = color, + highlightColor = highlightColor, + width = size.width, + height = size.height + ) + + // Use SrcAtop blend mode to only draw shimmer where content already exists + drawRect( + brush = shimmerBrush, + size = size, + blendMode = BlendMode.SrcAtop + ) +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Navigation.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Navigation.kt index 09e710222e..f05cfe994e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Navigation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Navigation.kt @@ -2,27 +2,62 @@ package org.thoughtcrime.securesms.ui import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.core.EaseIn +import androidx.compose.animation.core.EaseOut +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable +const val ANIM_TIME = 300 + inline fun NavGraphBuilder.horizontalSlideComposable( noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit ){ composable( enterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left) + fadeIn( + animationSpec = tween( + ANIM_TIME, easing = LinearEasing + ) + ) + slideIntoContainer( + animationSpec = tween(ANIM_TIME, easing = EaseIn), + towards = AnimatedContentTransitionScope.SlideDirection.Start + ) }, exitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left) + fadeOut( + animationSpec = tween( + ANIM_TIME, easing = LinearEasing + ) + ) + slideOutOfContainer( + animationSpec = tween(ANIM_TIME, easing = EaseOut), + towards = AnimatedContentTransitionScope.SlideDirection.Start + ) }, popEnterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right) + fadeIn( + animationSpec = tween( + ANIM_TIME, easing = LinearEasing + ) + ) + slideIntoContainer( + animationSpec = tween(ANIM_TIME, easing = EaseIn), + towards = AnimatedContentTransitionScope.SlideDirection.End + ) }, popExitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right) + fadeOut( + animationSpec = tween( + ANIM_TIME, easing = LinearEasing + ) + ) + slideOutOfContainer( + animationSpec = tween(ANIM_TIME, easing = EaseOut), + towards = AnimatedContentTransitionScope.SlideDirection.End + ) }, content = content ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt new file mode 100644 index 0000000000..cfcf80e14f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -0,0 +1,1030 @@ +package org.thoughtcrime.securesms.ui + +import androidx.annotation.DrawableRes +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateOffsetAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.SegmentedButtonDefaults.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideSubcomposition +import com.bumptech.glide.integration.compose.RequestState +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.TOKEN_NAME_LONG_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.TOKEN_NAME_SHORT_KEY +import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect +import org.thoughtcrime.securesms.ui.components.Avatar +import org.thoughtcrime.securesms.ui.components.QrImage +import org.thoughtcrime.securesms.ui.components.TertiaryFillButtonRect +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.primaryBlue +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUIElement + + +@Composable +fun ProBadge( + modifier: Modifier = Modifier, + colors: ProBadgeColors = proBadgeColorStandard() +) { + Box( + modifier = modifier.clearAndSetSemantics{ + contentDescription = NonTranslatableStringConstants.APP_PRO + } + ) { + Image( + modifier = Modifier.align(Alignment.Center), + painter = painterResource(id = R.drawable.ic_pro_badge_bg), + contentDescription = null, + colorFilter = ColorFilter.tint(colors.backgroundColor) + ) + + Image( + modifier = Modifier.align(Alignment.Center), + painter = painterResource(id = R.drawable.ic_pro_badge_fg), + contentDescription = null, + colorFilter = ColorFilter.tint(colors.textColor) + ) + } +} + +data class ProBadgeColors( + val backgroundColor: Color, + val textColor: Color +) + +@Composable +fun proBadgeColorStandard() = ProBadgeColors( + backgroundColor = LocalColors.current.accent, + textColor = Color.Black +) + +@Composable +fun proBadgeColorOutgoing() = ProBadgeColors( + backgroundColor = Color.White, + textColor = Color.Black +) + +@Composable +fun proBadgeColorDisabled() = ProBadgeColors( + backgroundColor = LocalColors.current.disabled, + textColor = Color.Black +) + +@Composable +fun ProBadgeText( + text: String, + modifier: Modifier = Modifier, + textStyle: TextStyle = LocalType.current.h5, + showBadge: Boolean = true, + badgeColors: ProBadgeColors = proBadgeColorStandard(), + badgeAtStart: Boolean = false, + onBadgeClick: (() -> Unit)? = null +) { + Row( + modifier = modifier.qaTag(stringResource(R.string.qa_pro_badge_component)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(textStyle.lineHeight.value.dp * 0.2f) + ) { + val proBadgeContent = @Composable { + if (showBadge) { + var proBadgeModifier: Modifier = Modifier + if (onBadgeClick != null) { + proBadgeModifier = proBadgeModifier.clickable { + onBadgeClick() + } + } + ProBadge( + modifier = proBadgeModifier.height(textStyle.lineHeight.value.dp * 0.8f) + .qaTag(stringResource(R.string.qa_pro_badge_icon)), + colors = badgeColors + ) + } + } + + val textContent = @Composable { + Text( + modifier = Modifier.weight(1f, fill = false) + .qaTag(stringResource(R.string.qa_pro_badge_text)), + text = text, + style = textStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + if (badgeAtStart) { + proBadgeContent() + textContent() + } else { + textContent() + proBadgeContent() + } + } +} + +@Preview +@Composable +private fun PreviewProBadgeText( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + Column( + modifier = Modifier.fillMaxWidth().padding(LocalDimensions.current.smallSpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing) + ) { + ProBadgeText(text = "Hello Pro", textStyle = LocalType.current.base) + ProBadgeText(text = "Hello Pro") + ProBadgeText(text = "Outgoing Badge Color", badgeColors = proBadgeColorOutgoing()) + ProBadgeText(text = "Hello Pro with a very long name that should overflow") + ProBadgeText(text = "No Badge", showBadge = false) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SessionProCTA( + content: @Composable () -> Unit, + text: String, + features: List, + modifier: Modifier = Modifier, + onUpgrade: () -> Unit, + onCancel: () -> Unit, +){ + BasicAlertDialog( + modifier = modifier, + onDismissRequest = onCancel, + content = { + DialogBg { + Column(modifier = Modifier.fillMaxWidth()) { + // hero image + BottomFadingEdgeBox( + fadingEdgeHeight = 70.dp, + fadingColor = LocalColors.current.backgroundSecondary, + content = { _ -> + content() + }, + ) + + // content + Column( + modifier = Modifier + .fillMaxWidth() + .padding(LocalDimensions.current.smallSpacing) + ) { + // title + ProBadgeText( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(R.string.upgradeTo) + ) + + Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) + + // main message + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = text, + textAlign = TextAlign.Center, + style = LocalType.current.base.copy( + color = LocalColors.current.textSecondary + ) + ) + + Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) + + // features + features.forEachIndexed { index, feature -> + ProCTAFeature(data = feature) + if(index < features.size - 1){ + Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + } + } + + Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) + + // buttons + Row( + Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xsSpacing), + ) { + AccentFillButtonRect( + modifier = Modifier.weight(1f).shimmerOverlay(), + text = stringResource(R.string.theContinue), + onClick = onUpgrade + ) + + TertiaryFillButtonRect( + modifier = Modifier.weight(1f), + text = stringResource(R.string.cancel), + onClick = onCancel + ) + } + } + } + } + } + ) +} + +sealed interface CTAFeature { + val text: String + + data class Icon( + override val text: String, + @DrawableRes val icon: Int = R.drawable.ic_circle_check, + ): CTAFeature + + data class RainbowIcon( + override val text: String, + @DrawableRes val icon: Int = R.drawable.ic_pro_sparkle_custom, + ): CTAFeature +} + +@Composable +fun SimpleSessionProCTA( + @DrawableRes heroImage: Int, + text: String, + features: List, + modifier: Modifier = Modifier, + onUpgrade: () -> Unit, + onCancel: () -> Unit, +){ + SessionProCTA( + modifier = modifier, + text = text, + features = features, + onUpgrade = onUpgrade, + onCancel = onCancel, + content = { CTAImage(heroImage = heroImage) } + ) +} + +@Composable +fun CTAImage( + @DrawableRes heroImage: Int, +){ + Image( + modifier = Modifier + .fillMaxWidth() + .background(LocalColors.current.accent), + contentScale = ContentScale.FillWidth, + painter = painterResource(id = heroImage), + contentDescription = null, + ) +} + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +fun AnimatedSessionProCTA( + @DrawableRes heroImageBg: Int, + @DrawableRes heroImageAnimatedFg: Int, + text: String, + features: List, + modifier: Modifier = Modifier, + onUpgrade: () -> Unit, + onCancel: () -> Unit, +){ + SessionProCTA( + modifier = modifier, + text = text, + features = features, + onUpgrade = onUpgrade, + onCancel = onCancel, + content = { + CTAAnimatedImages( + heroImageBg = heroImageBg, + heroImageAnimatedFg = heroImageAnimatedFg + ) + }) +} + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +fun CTAAnimatedImages( + @DrawableRes heroImageBg: Int, + @DrawableRes heroImageAnimatedFg: Int, +){ + Image( + modifier = Modifier + .fillMaxWidth() + .background(LocalColors.current.accent), + contentScale = ContentScale.FillWidth, + painter = painterResource(id = heroImageBg), + contentDescription = null, + ) + + GlideSubcomposition( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + model = heroImageAnimatedFg, + ){ + when (state) { + is RequestState.Success -> { + Image( + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.FillWidth, + painter = painter, + contentDescription = null, + ) + } + + else -> {} + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SessionProActivatedCTA( + imageContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + title: String, + textContent: @Composable ColumnScope.() -> Unit, + onCancel: () -> Unit, +){ + BasicAlertDialog( + modifier = modifier, + onDismissRequest = onCancel, + content = { + DialogBg { + Column(modifier = Modifier.fillMaxWidth()) { + // hero image + BottomFadingEdgeBox( + fadingEdgeHeight = 70.dp, + fadingColor = LocalColors.current.backgroundSecondary, + content = { _ -> + imageContent() + }, + ) + + // content + Column( + modifier = Modifier + .fillMaxWidth() + .padding(LocalDimensions.current.smallSpacing) + ) { + // title + ProBadgeText( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = title, + textStyle = LocalType.current.h5, + badgeAtStart = true + ) + + Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) + + // already have pro + textContent() + + Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) + + // buttons + TertiaryFillButtonRect( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(R.string.close), + onClick = onCancel + ) + } + } + } + } + ) +} + +@Composable +fun SimpleSessionProActivatedCTA( + @DrawableRes heroImage: Int, + title: String, + onCancel: () -> Unit, + modifier: Modifier = Modifier, + textContent: @Composable ColumnScope.() -> Unit +){ + SessionProActivatedCTA( + modifier = modifier, + title = title, + textContent = textContent, + onCancel = onCancel, + imageContent = { CTAImage(heroImage = heroImage) } + ) +} + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +fun AnimatedSessionProActivatedCTA( + @DrawableRes heroImageBg: Int, + @DrawableRes heroImageAnimatedFg: Int, + title: String, + onCancel: () -> Unit, + modifier: Modifier = Modifier, + textContent: @Composable ColumnScope.() -> Unit +){ + SessionProActivatedCTA( + modifier = modifier, + title = title, + textContent = textContent, + onCancel = onCancel, + imageContent = { + CTAAnimatedImages( + heroImageBg = heroImageBg, + heroImageAnimatedFg = heroImageAnimatedFg + ) + }) +} + +// Reusable generic Pro CTA +@Composable +fun GenericProCTA( + onDismissRequest: () -> Unit, + onPostAction: (() -> Unit)? = null // a function for optional code once an action has been taken +){ + val context = LocalContext.current + AnimatedSessionProCTA( + heroImageBg = R.drawable.cta_hero_generic_bg, + heroImageAnimatedFg = R.drawable.cta_hero_generic_fg, + text = Phrase.from(context,R.string.proUserProfileModalCallToAction) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + .toString(), + features = listOf( + CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), + CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), + CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), + ), + onUpgrade = { + onDismissRequest() + onPostAction?.invoke() + //todo PRO go to screen once it exists + }, + onCancel = { + onDismissRequest() + } + ) +} + +// Reusable long message Pro CTA +@Composable +fun LongMessageProCTA( + onDismissRequest: () -> Unit, +){ + SimpleSessionProCTA( + heroImage = R.drawable.cta_hero_char_limit, + text = Phrase.from(LocalContext.current, R.string.proCallToActionLongerMessages) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + .toString(), + features = listOf( + CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), + CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), + CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), + ), + onUpgrade = { + onDismissRequest() + //todo PRO go to screen once it exists + }, + onCancel = { + onDismissRequest() + } + ) +} + +// Reusable animated profile pic Pro CTA +@Composable +fun AnimatedProfilePicProCTA( + onDismissRequest: () -> Unit, +){ + AnimatedSessionProCTA( + heroImageBg = R.drawable.cta_hero_animated_bg, + heroImageAnimatedFg = R.drawable.cta_hero_animated_fg, + text = Phrase.from(LocalContext.current, R.string.proAnimatedDisplayPictureCallToActionDescription) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + .toString(), + features = listOf( + CTAFeature.Icon(stringResource(R.string.proFeatureListAnimatedDisplayPicture)), + CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), + CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), + ), + onUpgrade = { + onDismissRequest() + //todo PRO go to screen once it exists + }, + onCancel = { + onDismissRequest() + } + ) +} + +/** + * Added here for reusability since multiple screens need this dialog + */ +@Composable +fun PinProCTA( + overTheLimit: Boolean, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +){ + val context = LocalContext.current + + SimpleSessionProCTA( + modifier = modifier, + heroImage = R.drawable.cta_hero_pins, + text = if(overTheLimit) + Phrase.from(context, R.string.proCallToActionPinnedConversations) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + .toString() + else + Phrase.from(context, R.string.proCallToActionPinnedConversationsMoreThan) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + .toString(), + features = listOf( + CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), + CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), + CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), + ), + onUpgrade = { + onDismissRequest() + //todo PRO go to screen once it exists + }, + onCancel = { + onDismissRequest() + } + ) +} + +@Preview +@Composable +private fun PreviewProCTA( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + SimpleSessionProCTA( + heroImage = R.drawable.cta_hero_char_limit, + text = "This is a description of this Pro feature", + features = listOf( + CTAFeature.Icon("Feature one"), + CTAFeature.Icon("Feature two", R.drawable.ic_eye), + CTAFeature.RainbowIcon("Feature three"), + ), + onUpgrade = {}, + onCancel = {} + ) + } +} + +@Preview +@Composable +private fun PreviewProActivatedCTA( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + SimpleSessionProActivatedCTA( + heroImage = R.drawable.cta_hero_char_limit, + title = stringResource(R.string.proActivated), + textContent = { + ProBadgeText( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(R.string.proAlreadyPurchased), + textStyle = LocalType.current.base.copy(color = LocalColors.current.textSecondary) + ) + + Spacer(Modifier.height(2.dp)) + + // main message + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(R.string.proAnimatedDisplayPicture), + textAlign = TextAlign.Center, + style = LocalType.current.base.copy( + color = LocalColors.current.textSecondary + ) + ) + }, + onCancel = {} + ) + } +} + +@Composable +fun ProCTAFeature( + data: CTAFeature, + modifier: Modifier = Modifier, + textStyle: TextStyle = LocalType.current.base, + padding: PaddingValues = PaddingValues(horizontal = LocalDimensions.current.xxxsSpacing) +){ + Row( + modifier = modifier + .fillMaxWidth() + .padding(padding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing) + ) { + when(data){ + is CTAFeature.Icon -> { + Image( + painter = painterResource(id = data.icon), + colorFilter = ColorFilter.tint(LocalColors.current.accentText ), + contentDescription = null, + modifier = Modifier.align(Alignment.CenterVertically) + ) + } + is CTAFeature.RainbowIcon -> { + AnimatedGradientDrawable( + vectorRes = data.icon + ) + } + } + + Text( + text = data.text, + style = textStyle + ) + } +} + +@Composable +fun AvatarQrWidget( + showQR: Boolean, + expandedAvatar: Boolean, + showBadge: Boolean, + avatarUIData: AvatarUIData, + address: String, + toggleQR: () -> Unit, + toggleAvatarExpand: () -> Unit, + modifier: Modifier = Modifier, +){ + val animationSpec = tween( + durationMillis = 400, + easing = FastOutSlowInEasing + ) + + val animationSpecFast = tween( + durationMillis = 200, + easing = FastOutSlowInEasing + ) + + val targetSize = when { + showQR -> LocalDimensions.current.iconXXLargeAvatar + expandedAvatar -> LocalDimensions.current.iconXXLargeAvatar + else -> LocalDimensions.current.iconXXLarge + } + + val animatedSize by animateDpAsState( + targetValue = targetSize, + animationSpec = animationSpec, + label = "unified_size" + ) + + val animatedCornerRadius by animateDpAsState( + targetValue = if (showQR) { + LocalDimensions.current.shapeSmall + } else { + animatedSize / 2 // round shape + }, + animationSpec = animationSpec, + label = "corner_radius" + ) + + // Scale animations for content + val avatarScale by animateFloatAsState( + targetValue = if (showQR) 0.8f else 1f, + animationSpec = animationSpecFast, + label = "avatar_scale" + ) + + val qrScale by animateFloatAsState( + targetValue = if (showQR) 1f else 0.8f, + animationSpec = animationSpecFast, + label = "qr_scale" + ) + + val avatarAlpha by animateFloatAsState( + targetValue = if (showQR) 0f else 1f, + animationSpec = animationSpecFast, + label = "avatar_alpha" + ) + + val qrAlpha by animateFloatAsState( + targetValue = if (showQR) 1f else 0f, + animationSpec = animationSpecFast, + label = "qr_alpha" + ) + + // Badge animations + val badgeSize by animateDpAsState( + targetValue = if (expandedAvatar && !showQR) { + 30.dp + } else { + LocalDimensions.current.iconMedium + }, + animationSpec = animationSpec + ) + + // animating the inner padding of the badge otherwise the icon looks too big within the background + val animatedBadgeInnerPadding by animateDpAsState( + targetValue = if (expandedAvatar) { + 6.dp + } else { + 5.dp + }, + animationSpec = animationSpec, + label = "badge_inner_pd_animation" + ) + + val badgeOffset by animateOffsetAsState( + targetValue = if (showQR) { + val cornerOffset = LocalDimensions.current.xsSpacing + Offset(cornerOffset.value, -cornerOffset.value) + } else if(expandedAvatar) { + Offset(- LocalDimensions.current.contentSpacing.value, 0f) + } else { + Offset.Zero + }, + animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), + label = "badge_offset" + ) + + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + // Main container + Box( + modifier = Modifier + .size(animatedSize) + .background( + color = if (showQR) Color.White else Color.Transparent, + shape = RoundedCornerShape(animatedCornerRadius) + ), + contentAlignment = Alignment.Center + ) { + // Avatar with scale and alpha + var avatarModifier: Modifier = Modifier + if(!showQR){ + avatarModifier = avatarModifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + toggleAvatarExpand() + } + } + Avatar( + modifier = avatarModifier + .size(animatedSize) + .graphicsLayer( + alpha = avatarAlpha, + scaleX = avatarScale, + scaleY = avatarScale + ) + , + size = animatedSize, + maxSizeLoad = LocalDimensions.current.iconXXLargeAvatar, + data = avatarUIData + ) + + // QR with scale and alpha + if(showBadge) { + Box( + modifier = Modifier + .size(animatedSize) + .graphicsLayer( + alpha = qrAlpha, + scaleX = qrScale, + scaleY = qrScale + ), + contentAlignment = Alignment.Center + ) { + QrImage( + string = address, + modifier = Modifier + .size(animatedSize) + .qaTag(R.string.AccessibilityId_qrCode), + icon = R.drawable.session + ) + } + } + } + + // Badge + if(showBadge) { + Crossfade( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = badgeOffset.x.dp, y = badgeOffset.y.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + toggleQR() + }, + targetState = showQR, + animationSpec = tween(durationMillis = 200), + label = "badge_icon" + ) { showQR -> + Image( + modifier = Modifier + .size(badgeSize) + .background( + shape = CircleShape, + color = LocalColors.current.accent + ) + .padding(animatedBadgeInnerPadding), + painter = painterResource( + id = when (showQR) { + true -> R.drawable.ic_user_filled_custom + false -> R.drawable.ic_qr_code + } + ), + contentDescription = null, + colorFilter = ColorFilter.tint(Color.Black) + ) + } + } + } +} + +@Composable +fun SessionProSettingsHeader( + modifier: Modifier = Modifier, + color: Color = LocalColors.current.accent +){ + val accentColourWithLowAlpha = color.copy(alpha = 0.15f) + + Column(modifier = modifier) { + // UI with radial gradient + var headerSize by remember { mutableStateOf(IntSize.Zero) } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + ) { + // Note: We add 20% to the ratio for the radial background so that it reaches the + // edges of the screen, or thereabouts. + val ratio = headerSize.width * 1.2f / headerSize.height + + androidx.compose.animation.AnimatedVisibility( + visible = headerSize != IntSize.Zero, + enter = fadeIn(), + exit = fadeOut() + ) { + if (!LocalColors.current.isLight) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .scale(ratio, 1.5f) + .background( + Brush.radialGradient( + // Gradient runs from our washed-out accent colour in the center to the background colour at the edges + colors = listOf(accentColourWithLowAlpha, LocalColors.current.background), + ) + ) + ) + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = LocalDimensions.current.spacing) + .onSizeChanged { newSizeDp -> + headerSize = newSizeDp + } + .clearAndSetSemantics{ + contentDescription = NonTranslatableStringConstants.APP_PRO + }, + horizontalAlignment = Alignment.CenterHorizontally + + ) { + Image( + modifier = Modifier.size(LocalDimensions.current.iconXXLarge), + painter = painterResource(id = R.drawable.session_logo), + contentDescription = null, + colorFilter = ColorFilter.tint(color) + ) + + Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + + Row( + modifier = Modifier.height(LocalDimensions.current.smallSpacing) + ) { + Image( + painter = painterResource(R.drawable.ic_session), + contentDescription = null, + ) + + Spacer(Modifier.width(LocalDimensions.current.xxxsSpacing)) + + ProBadge( + colors = proBadgeColorStandard().copy( + backgroundColor = color + ) + ) + } + } + } + + + } +} + +@Preview +@Composable +fun PreviewSessionHeader(){ + PreviewTheme { + Column { + Spacer(Modifier.height(LocalDimensions.current.xlargeSpacing)) + + SessionProSettingsHeader() + + Spacer(Modifier.height(LocalDimensions.current.xlargeSpacing)) + + SessionProSettingsHeader( + color = LocalColors.current.disabled + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Strings.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Strings.kt index 91500da7b2..64e373063a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Strings.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Strings.kt @@ -54,7 +54,7 @@ fun GetString(duration: Duration) = GetString.FromMap(duration, ExpirationUtil:: /** * 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) +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/UINavigator.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt new file mode 100644 index 0000000000..599873491d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.ui + +import android.content.Intent +import androidx.navigation.NavOptionsBuilder +import dagger.hilt.android.scopes.ActivityRetainedScoped +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import javax.inject.Inject + +@ActivityRetainedScoped +class UINavigator @Inject constructor() { + private val _navigationActions = Channel>() + val navigationActions = _navigationActions.receiveAsFlow() + + // simple system to avoid navigating too quickly + private var lastNavigationTime = 0L + private val navigationDebounceTime = 500L // 500ms debounce + + suspend fun navigate( + destination: T, + navOptions: NavOptionsBuilder.() -> Unit = {} + ) { + val currentTime = System.currentTimeMillis() + if (currentTime - lastNavigationTime > navigationDebounceTime) { + lastNavigationTime = currentTime + _navigationActions.send(NavigationAction.Navigate( + destination = destination, + navOptions = navOptions + )) + } + } + + suspend fun navigateUp() { + _navigationActions.send(NavigationAction.NavigateUp) + } + + suspend fun navigateToIntent(intent: Intent) { + _navigationActions.send(NavigationAction.NavigateToIntent(intent)) + } + + suspend fun returnResult(code: String, value: Boolean) { + _navigationActions.send(NavigationAction.ReturnResult(code, value)) + } +} + +sealed interface NavigationAction { + data class Navigate( + val destination: T, + val navOptions: NavOptionsBuilder.() -> Unit = {} + ) : NavigationAction + + data object NavigateUp : NavigationAction + + data class NavigateToIntent( + val intent: Intent + ) : NavigationAction + + data class ReturnResult( + val code: String, + val value: Boolean + ) : NavigationAction +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt new file mode 100644 index 0000000000..df601a8ef2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt @@ -0,0 +1,426 @@ +package org.thoughtcrime.securesms.ui + +import android.content.Intent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.squareup.phrase.Phrase +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2.Companion.ADDRESS +import org.thoughtcrime.securesms.ui.components.SlimAccentOutlineButton +import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.monospace +import org.thoughtcrime.securesms.ui.theme.primaryRed +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUIElement +import org.thoughtcrime.securesms.util.UserProfileModalCommands +import org.thoughtcrime.securesms.util.UserProfileModalData + +@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class, + ExperimentalFoundationApi::class +) +@Composable +fun UserProfileModal( + data: UserProfileModalData, + sendCommand: (UserProfileModalCommands) -> Unit, + onDismissRequest: () -> Unit, + onPostAction: (() -> Unit)? = null // a function for optional code once an action has been taken +){ + // the user profile modal + val context = LocalContext.current + AlertDialog( + onDismissRequest = onDismissRequest, + showCloseButton = true, + title = null as AnnotatedString?, + content = { + // avatar / QR + AvatarQrWidget( + showQR = data.showQR, + expandedAvatar = data.expandedAvatar, + showBadge = !data.isBlinded, + avatarUIData = data.avatarUIData, + address = data.rawAddress, + toggleQR = { sendCommand(UserProfileModalCommands.ToggleQR) }, + toggleAvatarExpand = { sendCommand(UserProfileModalCommands.ToggleAvatarExpand) } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // title + ProBadgeText( + text = data.name, + showBadge = data.showProBadge, + onBadgeClick = if(!data.currentUserPro){{ + sendCommand(UserProfileModalCommands.ShowProCTA) + }} else null + ) + + if(!data.subtitle.isNullOrEmpty()){ + Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + Text( + text = data.subtitle, + style = LocalType.current.small.copy(color = LocalColors.current.textSecondary) + ) + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // account ID + AccountIdHeader( + text = if(data.isBlinded) stringResource(R.string.blindedId) else stringResource(R.string.accountId), + textStyle = LocalType.current.small, + textPaddingValues = PaddingValues( + horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xxxsSpacing + ) + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + Row { + if(!data.tooltipText.isNullOrEmpty()){ + Spacer(modifier = Modifier.width(LocalDimensions.current.spacing)) + } + + Text( + modifier = Modifier.weight(1f, fill = false) + .qaTag(R.string.qa_conversation_settings_account_id), + text = data.displayAddress, + textAlign = TextAlign.Center, + style = LocalType.current.base.monospace(), + color = LocalColors.current.text + ) + + if(!data.tooltipText.isNullOrEmpty()){ + val tooltipState = rememberTooltipState(isPersistent = true) + val scope = rememberCoroutineScope() + + Spacer(modifier = Modifier.width(LocalDimensions.current.xsSpacing)) + + SpeechBubbleTooltip( + text = data.tooltipText, + tooltipState = tooltipState + ) { + Image( + painter = painterResource(id = R.drawable.ic_circle_help), + contentDescription = null, + colorFilter = ColorFilter.tint(LocalColors.current.text), + modifier = Modifier + .size(LocalDimensions.current.iconXSmall) + .clickable { + scope.launch { + if (tooltipState.isVisible) tooltipState.dismiss() else tooltipState.show() + } + } + .qaTag("Tooltip") + ) + } + } + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // show a message if the user can't be messaged + if(data.isBlinded && !data.enableMessage){ + Text( + modifier = Modifier.padding(horizontal = LocalDimensions.current.xsSpacing), + text = annotatedStringResource( + Phrase.from(LocalContext.current, R.string.messageRequestsTurnedOff) + .put(NAME_KEY, data.name) + .format() + ), + textAlign = TextAlign.Center, + style = LocalType.current.small.copy(color = LocalColors.current.textSecondary) + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + } + + // buttons + Row( + verticalAlignment = Alignment.CenterVertically, + ){ + var buttonModifier: Modifier = Modifier + if(data.isBlinded){ // this means there is no copy button so the message button should be full width + buttonModifier = buttonModifier.widthIn(LocalDimensions.current.minButtonWidth) + } else { // the copy button will be there so allow for a max stretch with weight = 1f + buttonModifier = buttonModifier.weight(1f) + } + + SlimAccentOutlineButton( + modifier = buttonModifier, + text = stringResource(R.string.message), + enabled = data.enableMessage, + onClick = { + // close dialog + onDismissRequest() + + // optional action + onPostAction?.invoke() + + // open conversation with user + context.startActivity(Intent(context, ConversationActivityV2::class.java) + .putExtra(ADDRESS, Address.fromSerialized(data.rawAddress)) + ) + } + ) + + if(!data.isBlinded){ + Spacer(modifier = Modifier.width(LocalDimensions.current.xsSpacing)) + SlimOutlineCopyButton( + Modifier.weight(1f), + color = LocalColors.current.accentText, + onClick = { + sendCommand(UserProfileModalCommands.CopyAccountId) + } + ) + } + } + } + ) + + // the pro CTA that comes with UPM + if(data.showProCTA){ + GenericProCTA( + onDismissRequest = { + sendCommand(UserProfileModalCommands.HideSessionProCTA) + }, + ) + } +} + +@Preview +@Composable +private fun PreviewUPM( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + var data by remember { + mutableStateOf( + UserProfileModalData( + name = "Atreyu", + subtitle = "(Neverending)", + showProBadge = true, + currentUserPro = false, + isBlinded = false, + tooltipText = null, + rawAddress = "053d30141d0d35d9c4b30a8f8880f8464e221ee71a8aff9f0dcefb1e60145cea5144", + displayAddress = "123456789112345678911234567891123\n123456789112345678911234567891123", + threadId = 0L, + enableMessage = true, + expandedAvatar = false, + showQR = false, + showProCTA = false, + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TO", + color = primaryRed + ) + ) + ) + ) + ) + } + + UserProfileModal( + data = data, + onDismissRequest = {}, + sendCommand = { command -> + when(command){ + UserProfileModalCommands.ShowProCTA -> { + data = data.copy(showProCTA = true) + } + UserProfileModalCommands.HideSessionProCTA -> { + data = data.copy(showProCTA = false) + } + UserProfileModalCommands.ToggleQR -> { + data = data.copy(showQR = !data.showQR) + } + UserProfileModalCommands.ToggleAvatarExpand -> { + data = data.copy(expandedAvatar = !data.expandedAvatar) + } + else -> {} + + } + } + ) + } +} + +@Preview +@Composable +private fun PreviewUPMResolved( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + var data by remember { + mutableStateOf( + UserProfileModalData( + name = "Atreyu", + subtitle = "(Neverending)", + showProBadge = true, + currentUserPro = false, + isBlinded = false, + tooltipText = "Some tooltip text that is long and should break into multiple line if necessary", + rawAddress = "053d30141d0d35d9c4b30a8f8880f8464e221ee71a8aff9f0dcefb1e60145cea5144", + displayAddress = "12345678911234567891123\n45678911231234567891123\n45678911234567891123", + threadId = 0L, + enableMessage = true, + expandedAvatar = false, + showQR = true, + showProCTA = false, + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TO", + color = primaryRed + ) + ) + ) + ) + ) + } + + UserProfileModal( + data = data, + onDismissRequest = {}, + sendCommand = { command -> + when(command){ + UserProfileModalCommands.ShowProCTA -> { + data = data.copy(showProCTA = true) + } + UserProfileModalCommands.HideSessionProCTA -> { + data = data.copy(showProCTA = false) + } + UserProfileModalCommands.ToggleQR -> { + data = data.copy(showQR = !data.showQR) + } + UserProfileModalCommands.ToggleAvatarExpand -> { + data = data.copy(expandedAvatar = !data.expandedAvatar) + } + else -> {} + + } + } + ) + } +} + + +@Preview +@Composable +private fun PreviewUPMQR( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + var data by remember { + mutableStateOf( + UserProfileModalData( + name = "Atreyu", + subtitle = "(Neverending)", + showProBadge = false, + currentUserPro = false, + isBlinded = true, + tooltipText = "Some tooltip", + rawAddress = "053d30141d0d35d9c4b30a8f8880f8464e221ee71a8aff9f0dcefb1e60145cea5144", + displayAddress = "1111111111...1111111111", + threadId = 0L, + enableMessage = false, + expandedAvatar = false, + showQR = false, + showProCTA = false, + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TO", + color = primaryRed + ) + ) + ) + ) + ) + } + + UserProfileModal( + data = data, + onDismissRequest = {}, + sendCommand = {} + ) + } +} + +@Preview +@Composable +private fun PreviewUPMCTA( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + UserProfileModal( + data = UserProfileModalData( + name = "Atreyu", + subtitle = "(Neverending)", + showProBadge = false, + currentUserPro = false, + isBlinded = true, + tooltipText = "Some tooltip", + rawAddress = "158342146b...c6ed734na5", + displayAddress = "158342146b...c6ed734na5", + threadId = 0L, + enableMessage = false, + expandedAvatar = true, + showQR = false, + showProCTA = true, + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TO", + color = primaryRed + ) + ) + ) + ), + onDismissRequest = {}, + sendCommand = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt index de2271162d..b987c831a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Util.kt @@ -5,19 +5,25 @@ import android.content.Context import android.content.ContextWrapper import android.view.View import android.view.ViewTreeObserver +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable -import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.fragment.app.Fragment -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.PermissionState -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.shouldShowRationale +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import com.squareup.phrase.Phrase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme fun Activity.setComposeContent(content: @Composable () -> Unit) { @@ -52,11 +58,6 @@ fun ComposeView.setThemedContent(content: @Composable () -> Unit) = setContent { } } -@ExperimentalPermissionsApi -fun PermissionState.isPermanentlyDenied(): Boolean { - return !status.shouldShowRationale && !status.isGranted -} - fun Context.findActivity(): Activity { var context = this while (context is ContextWrapper) { @@ -78,13 +79,40 @@ inline fun T.afterMeasured(crossinline block: T.() -> Unit) { } /** - * This is used to set the test tag that the QA team can use to retrieve an element in appium - * In order to do so we need to set the testTagsAsResourceId to true, which ideally should be done only once - * in the root composable, but our app is currently made up of multiple isolated composables - * set up in the old activity/fragment view system - * As such we need to repeat it for every component that wants to use testTag, until such - * a time as we have one root composable + * helper function to observe flows as events properly + * Including not losing events when the lifecycle gets destroyed by using Dispatchers.Main.immediate */ -@OptIn(ExperimentalComposeUiApi::class) @Composable -fun Modifier.qaTag(tag: String) = semantics { testTagsAsResourceId = true }.testTag(tag) \ No newline at end of file +fun ObserveAsEvents( + flow: Flow, + key1: Any? = null, + key2: Any? = null, + onEvent: (T) -> Unit +) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(key1 = lifecycleOwner.lifecycle, key1, key2) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + withContext(Dispatchers.Main.immediate) { + flow.collect(onEvent) + } + } + } +} + +@Composable +fun AnimateFade( + visible: Boolean, + modifier: Modifier = Modifier, + fadeInAnimationSpec: FiniteAnimationSpec = spring(stiffness = Spring.StiffnessMediumLow), + fadeOutAnimationSpec: FiniteAnimationSpec = spring(stiffness = Spring.StiffnessMediumLow), + content: @Composable() AnimatedVisibilityScope.() -> Unit +){ + AnimatedVisibility( + modifier = modifier, + visible = visible, + enter = fadeIn(animationSpec = fadeInAnimationSpec), + exit = fadeOut(animationSpec = fadeOutAnimationSpec) + ) { + content() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ActionSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ActionSheet.kt deleted file mode 100644 index 171539ab39..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ActionSheet.kt +++ /dev/null @@ -1,132 +0,0 @@ -package org.thoughtcrime.securesms.ui.components - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment.Companion.CenterVertically -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import org.thoughtcrime.securesms.ui.qaTag -import org.thoughtcrime.securesms.ui.theme.LocalColors -import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.LocalType - -/** - * A bottom sheet dialog that displays a list of options. - * - * @param options The list of options to display. - * @param onDismissRequest Callback to be invoked when the dialog is to be dismissed. - * @param onOptionClick Callback to be invoked when an option is clicked. - * @param optionTitle A function that returns the title of an option. - * @param optionIconRes A function that returns the icon resource of an option. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ActionSheet( - options: Collection, - onDismissRequest: () -> Unit, - onOptionClick: (T) -> Unit, - optionTitle: (T) -> String, - optionQaTag: (T) -> Int, - optionIconRes: (T) -> Int, -) { - val sheetState = rememberModalBottomSheetState() - - ModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = sheetState, - shape = RoundedCornerShape( - topStart = LocalDimensions.current.xsSpacing, - topEnd = LocalDimensions.current.xsSpacing - ), - dragHandle = {}, - containerColor = LocalColors.current.backgroundSecondary, - ) { - for (option in options) { - ActionSheetItem( - text = optionTitle(option), - leadingIcon = optionIconRes(option), - qaTag = optionQaTag(option).takeIf { it != 0 }?.let { stringResource(it) }, - onClick = { - onOptionClick(option) - onDismissRequest() - } - ) - } - } -} - -@Composable -private fun ActionSheetItem( - leadingIcon: Int, - text: String, - qaTag: String?, - onClick: () -> Unit -) { - Row( - modifier = Modifier - .clickable(onClick = onClick) - .padding(LocalDimensions.current.smallSpacing) - .let { modifier -> - qaTag?.let { modifier.qaTag(it) } ?: modifier - } - .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.spacing), - verticalAlignment = CenterVertically, - ) { - Icon( - painter = painterResource(leadingIcon), - modifier = Modifier.size(LocalDimensions.current.iconMedium), - tint = LocalColors.current.text, - contentDescription = null - ) - - Text( - modifier = Modifier.weight(1f), - style = LocalType.current.large, - text = text, - textAlign = TextAlign.Start, - color = LocalColors.current.text, - ) - } -} - - -data class ActionSheetItemData( - val title: String, - @DrawableRes val iconRes: Int, - @StringRes val qaTag: Int = 0, - val onClick: () -> Unit, -) - -/** - * A convenience function to display a [ActionSheet] with a collection of [ActionSheetItemData]. - */ -@Composable -fun ActionSheet( - items: Collection, - onDismissRequest: () -> Unit -) { - ActionSheet( - options = items, - onDismissRequest = onDismissRequest, - onOptionClick = { it.onClick() }, - optionTitle = { it.title }, - optionIconRes = { it.iconRes }, - optionQaTag = { it.qaTag } - ) -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AnnotatedString.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AnnotatedString.kt index 68587bc9e1..7aedf5027e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AnnotatedString.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AnnotatedString.kt @@ -2,9 +2,11 @@ package org.thoughtcrime.securesms.ui.components import android.content.res.Resources import android.graphics.Typeface +import android.text.SpannableStringBuilder import android.text.Spanned import android.text.style.AbsoluteSizeSpan import android.text.style.BulletSpan +import android.text.style.CharacterStyle import android.text.style.ForegroundColorSpan import android.text.style.RelativeSizeSpan import android.text.style.StrikethroughSpan @@ -15,27 +17,40 @@ import android.text.style.TypefaceSpan import android.text.style.UnderlineSpan import android.util.Log import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.theme.LocalColors -// Utilities for AnnotatedStrings, -// like converting the old view system's SpannableString to AnnotatedString +private val TAG = AnnotatedString::class.java.simpleName + +// Utilities for AnnotatedStrings, like converting the old view system's SpannableString to AnnotatedString @Composable @ReadOnlyComposable private fun resources(): Resources { @@ -44,127 +59,210 @@ private fun resources(): Resources { } @Composable -fun annotatedStringResource(@StringRes id: Int): AnnotatedString { +fun annotatedStringResource( + @StringRes id: Int, + highlightColor: Color = LocalColors.current.accent +): AnnotatedString { val resources = resources() val density = LocalDensity.current return remember(id) { val text = resources.getText(id) - spannableStringToAnnotatedString(text, density) + spannableStringToAnnotatedString(text, density, highlightColor) } } @Composable -fun annotatedStringResource(text: CharSequence): AnnotatedString { +fun annotatedStringResource( + text: CharSequence, + highlightColor: Color = LocalColors.current.accent +): AnnotatedString { val density = LocalDensity.current return remember(text.hashCode()) { - spannableStringToAnnotatedString(text, density) + spannableStringToAnnotatedString(text, density, highlightColor) } } private fun spannableStringToAnnotatedString( text: CharSequence, - density: Density + density: Density, + highlightColor: Color ): AnnotatedString { - return if (text is Spanned) { - with(density) { - buildAnnotatedString { - append((text.toString())) - text.getSpans(0, text.length, Any::class.java).forEach { - val start = text.getSpanStart(it) - val end = text.getSpanEnd(it) - when (it) { - is StyleSpan -> when (it.style) { - Typeface.NORMAL -> addStyle( - SpanStyle( - fontWeight = FontWeight.Normal, - fontStyle = FontStyle.Normal - ), - start, - end - ) - Typeface.BOLD -> addStyle( - SpanStyle( - fontWeight = FontWeight.Bold, - fontStyle = FontStyle.Normal - ), - start, - end - ) - Typeface.ITALIC -> addStyle( - SpanStyle( - fontWeight = FontWeight.Normal, - fontStyle = FontStyle.Italic - ), - start, - end - ) - Typeface.BOLD_ITALIC -> addStyle( - SpanStyle( - fontWeight = FontWeight.Bold, - fontStyle = FontStyle.Italic - ), - start, - end - ) - } - is TypefaceSpan -> addStyle( - SpanStyle( - fontFamily = when (it.family) { - FontFamily.SansSerif.name -> FontFamily.SansSerif - FontFamily.Serif.name -> FontFamily.Serif - FontFamily.Monospace.name -> FontFamily.Monospace - FontFamily.Cursive.name -> FontFamily.Cursive - else -> FontFamily.Default - } - ), - start, - end - ) - is BulletSpan -> { - Log.d("StringResources", "BulletSpan not supported yet") - addStyle(SpanStyle(), start, end) + val annotatedStringBuilder = AnnotatedString.Builder() + + // make sure we have a Spanned (a string without html tag but with the icon wouldn't be considered a Spanned) + val spannedText: Spanned = if (text is Spanned) text else SpannableStringBuilder.valueOf(text) + + var currentIndex = 0 + + // Build a regex pattern to match any of the placeholders + val placeholderPattern = Regex(inlineContentMap().keys.joinToString("|") { Regex.escape(it) }) + val matches = placeholderPattern.findAll(spannedText) + val matchIterator = matches.iterator() + + while (currentIndex < spannedText.length) { + val nextMatch = if (matchIterator.hasNext()) matchIterator.next() else null + val startOfPlaceholder = nextMatch?.range?.first ?: spannedText.length + val endOfPlaceholder = nextMatch?.range?.last?.plus(1) ?: spannedText.length + + // Append text before the placeholder + if (currentIndex < startOfPlaceholder) { + val textSegment = spannedText.subSequence(currentIndex, startOfPlaceholder) + appendAnnotatedTextSegment(annotatedStringBuilder, spannedText, textSegment, currentIndex, density, highlightColor) + } + + // Append inline content instead of the placeholder + if (nextMatch != null) { + val placeholderText = nextMatch.value + + if (inlineContentMap().containsKey(placeholderText)) { + // Use the placeholder text as the ID + annotatedStringBuilder.appendInlineContent(placeholderText, placeholderText) + } else { + // If no matching inline content, append the placeholder text as is + annotatedStringBuilder.append(placeholderText) + } + currentIndex = endOfPlaceholder + } else { + currentIndex = spannedText.length + } + } + + return annotatedStringBuilder.toAnnotatedString() +} + +private fun appendAnnotatedTextSegment( + builder: AnnotatedString.Builder, + spannedText: Spanned, + textSegment: CharSequence, + segmentStartIndex: Int, + density: Density, + highlightColor: Color +) { + val segmentLength = textSegment.length + val segmentEndIndex = segmentStartIndex + segmentLength + + builder.append(textSegment) + + // Process spans in the segment + val spans = spannedText.getSpans(segmentStartIndex, segmentEndIndex, CharacterStyle::class.java) + + for (span in spans) { + val spanStart = maxOf(spannedText.getSpanStart(span), segmentStartIndex) + val spanEnd = minOf(spannedText.getSpanEnd(span), segmentEndIndex) + + val start = builder.length - segmentLength + (spanStart - segmentStartIndex) + val end = builder.length - segmentLength + (spanEnd - segmentStartIndex) + + when (span) { + is StyleSpan -> { + val spanStyle = when (span.style) { + Typeface.BOLD -> SpanStyle(fontWeight = FontWeight.Bold) + Typeface.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic) + Typeface.BOLD_ITALIC -> SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic) + else -> SpanStyle() + } + builder.addStyle(spanStyle, start, end) + } + + is TypefaceSpan -> { + builder.addStyle( + SpanStyle( + fontFamily = when (span.family) { + FontFamily.SansSerif.name -> FontFamily.SansSerif + FontFamily.Serif.name -> FontFamily.Serif + FontFamily.Monospace.name -> FontFamily.Monospace + FontFamily.Cursive.name -> FontFamily.Cursive + else -> FontFamily.Default } - is AbsoluteSizeSpan -> addStyle( - SpanStyle(fontSize = if (it.dip) it.size.dp.toSp() else it.size.toSp()), - start, - end - ) - is RelativeSizeSpan -> addStyle( - SpanStyle(fontSize = it.sizeChange.em), - start, - end - ) - is StrikethroughSpan -> addStyle( - SpanStyle(textDecoration = TextDecoration.LineThrough), - start, - end - ) - is UnderlineSpan -> addStyle( - SpanStyle(textDecoration = TextDecoration.Underline), - start, - end - ) - is SuperscriptSpan -> addStyle( - SpanStyle(baselineShift = BaselineShift.Superscript), - start, - end - ) - is SubscriptSpan -> addStyle( - SpanStyle(baselineShift = BaselineShift.Subscript), - start, - end - ) - is ForegroundColorSpan -> addStyle( - SpanStyle(color = Color(it.foregroundColor)), - start, - end - ) - else -> addStyle(SpanStyle(), start, end) + ), + start, + end + ) + } + + is BulletSpan -> { + Log.d("StringResources", "BulletSpan not supported yet") + builder.addStyle(SpanStyle(), start, end) + } + + is AbsoluteSizeSpan -> { + val fontSize = with (density) { + if (span.dip) { + // Convert Dp to Sp + (span.size.dp.toPx() / fontScale).sp + + //dpToSp(span.size.dp.value) + + } else { + // Size is already in pixels; convert pixels to Sp + (span.size / fontScale).sp } } + // if (span.dip) span.size.dp.toSp() else it.size.toSp()), + builder.addStyle(SpanStyle(fontSize = fontSize), start, end) + } + + is RelativeSizeSpan -> builder.addStyle( + SpanStyle(fontSize = span.sizeChange.em), + start, + end + ) + + is StrikethroughSpan -> builder.addStyle( + SpanStyle(textDecoration = TextDecoration.LineThrough), + start, + end + ) + is UnderlineSpan -> builder.addStyle( + SpanStyle(textDecoration = TextDecoration.Underline), + start, + end + ) + is SuperscriptSpan -> builder.addStyle( + SpanStyle(baselineShift = BaselineShift.Superscript), + start, + end + ) + + is SubscriptSpan -> builder.addStyle( + SpanStyle(baselineShift = BaselineShift.Subscript), + start, + end + ) + + // Note: We take anything like `foo` and use the current + // theme accent colour for it (the colour specified in the font tag is ignored). + is ForegroundColorSpan -> { + builder.addStyle(SpanStyle(color = highlightColor), start, end) + } + + else -> { + Log.w(TAG, "Unrecognised span: " + span + " - using default style.") + builder.addStyle(SpanStyle(), start, end) } } - } else { - AnnotatedString(text.toString()) } -} \ No newline at end of file +} + +// External link icon ID & inline content. +// When we see "{icon}" in the string we substitute with the external link icon, or +// whichever icon is suitable for the given string. +val iconExternalLink = "[external-icon]" + +// Add any additional mappings between a given tag and an icon or image here. +fun inlineContentMap(textSize: TextUnit = 15.sp) = mapOf( + iconExternalLink to InlineTextContent( + Placeholder( + width = textSize, + height = textSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + Image( + painter = painterResource(id = R.drawable.ic_square_arrow_up_right), + colorFilter = ColorFilter.tint(LocalColors.current.accentText), + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + } +) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt index 4961b1f0a0..386677bab2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -17,6 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import network.loki.messenger.R @@ -29,6 +31,7 @@ import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors +import kotlin.math.sin @OptIn(ExperimentalMaterial3Api::class) @Preview @@ -53,7 +56,7 @@ fun AppBarPreview( actionModeActions = { IconButton(onClick = {}) { Icon( - painter = painterResource(id = R.drawable.check), + painter = painterResource(id = R.drawable.ic_check), contentDescription = "check" ) } @@ -71,6 +74,8 @@ fun AppBarPreview( fun BasicAppBar( title: String, modifier: Modifier = Modifier, + singleLine: Boolean = false, + windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, scrollBehavior: TopAppBarScrollBehavior? = null, backgroundColor: Color = LocalColors.current.background, navigationIcon: @Composable () -> Unit = {}, @@ -78,8 +83,9 @@ fun BasicAppBar( ) { CenterAlignedTopAppBar( modifier = modifier, + windowInsets = windowInsets, title = { - AppBarText(title = title) + AppBarText(title = title, singleLine = singleLine) }, colors = appBarColors(backgroundColor), navigationIcon = navigationIcon, @@ -97,6 +103,7 @@ fun BackAppBar( title: String, onBack: () -> Unit, modifier: Modifier = Modifier, + windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, scrollBehavior: TopAppBarScrollBehavior? = null, backgroundColor: Color = LocalColors.current.background, actions: @Composable RowScope.() -> Unit = {}, @@ -104,6 +111,7 @@ fun BackAppBar( BasicAppBar( modifier = modifier, title = title, + windowInsets = windowInsets, navigationIcon = { AppBarBackIcon(onBack = onBack) }, @@ -113,11 +121,13 @@ fun BackAppBar( ) } -@ExperimentalMaterial3Api +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ActionAppBar( title: String, modifier: Modifier = Modifier, + windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, + singleLine: Boolean = false, scrollBehavior: TopAppBarScrollBehavior? = null, backgroundColor: Color = LocalColors.current.background, actionMode: Boolean = false, @@ -128,9 +138,10 @@ fun ActionAppBar( ) { CenterAlignedTopAppBar( modifier = modifier, + windowInsets = windowInsets, title = { if (!actionMode) { - AppBarText(title = title) + AppBarText(title = title, singleLine = singleLine) } }, navigationIcon = { @@ -140,7 +151,7 @@ fun ActionAppBar( verticalAlignment = Alignment.CenterVertically ) { navigationIcon() - AppBarText(title = actionModeTitle) + AppBarText(title = actionModeTitle, singleLine = singleLine) } } else { navigationIcon() @@ -159,19 +170,30 @@ fun ActionAppBar( } @Composable -fun AppBarText(title: String) { - Text(text = title, style = LocalType.current.h4) +fun AppBarText( + title: String, + modifier: Modifier = Modifier, + singleLine: Boolean = false +) { + Text( + modifier = modifier, + text = title, + style = LocalType.current.h4, + maxLines = if(singleLine) 1 else Int.MAX_VALUE, + overflow = if(singleLine) TextOverflow.Ellipsis else TextOverflow.Clip + ) } @Composable fun AppBarBackIcon(onBack: () -> Unit) { IconButton( modifier = Modifier.contentDescription(stringResource(R.string.back)) - .qaTag(stringResource(R.string.AccessibilityId_navigateBack)), + .qaTag(R.string.AccessibilityId_navigateBack), onClick = onBack ) { Icon( - painter = painterResource(id = R.drawable.ic_arrow_left), + painter = painterResource(id = R.drawable.ic_chevron_left), + tint = LocalColors.current.text, contentDescription = null ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Avatar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Avatar.kt new file mode 100644 index 0000000000..9bc89335ef --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Avatar.kt @@ -0,0 +1,342 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import com.bumptech.glide.integration.compose.placeholder +import network.loki.messenger.R +import org.session.libsession.avatars.ProfileContactPhoto +import org.session.libsession.utilities.Address +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.classicDark3 +import org.thoughtcrime.securesms.ui.theme.primaryBlue +import org.thoughtcrime.securesms.ui.theme.primaryGreen +import org.thoughtcrime.securesms.util.AvatarBadge +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUIElement +import org.thoughtcrime.securesms.util.avatarOptions + + +@Composable +fun BaseAvatar( + size: Dp, + data: AvatarUIData, + modifier: Modifier = Modifier, + clip: Shape = CircleShape, + maxSizeLoad: Dp = LocalDimensions.current.iconLarge, + badge: (@Composable () -> Unit)? = null, +) { + Box(modifier = modifier.size(size)) { + + when { + data.elements.isEmpty() -> { + // Do nothing when there is no avatar data. + } + data.elements.size == 1 -> { + // Only one element, occupy the full parent's size. + AvatarElement( + size = size, + data = data.elements.first(), + clip = clip, + maxSizeLoad = maxSizeLoad + ) + } + else -> { + // Two or more elements: show the first two in a staggered layout. + val avatarSize = size * 0.78f + AvatarElement( + modifier = Modifier.align(Alignment.TopStart), + size = avatarSize, + data = data.elements[0], + clip = clip, + maxSizeLoad = maxSizeLoad + ) + AvatarElement( + modifier = Modifier.align(Alignment.BottomEnd), + size = avatarSize, + data = data.elements[1], + clip = clip, + maxSizeLoad = maxSizeLoad + ) + } + } + + // Badge content, if any. + if (badge != null) { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(1.dp, 1.dp) // Used to make up for transparent padding in icon. + .size(size * 0.4f) + ) { + badge() + } + } + } +} + +@Composable +fun Avatar( + size: Dp, + data: AvatarUIData, + modifier: Modifier = Modifier, + clip: Shape = CircleShape, + maxSizeLoad: Dp = LocalDimensions.current.iconLarge, + badge: AvatarBadge = AvatarBadge.None, +){ + BaseAvatar( + size = size, + modifier = modifier, + data = data, + clip = clip, + maxSizeLoad = maxSizeLoad, + badge = when (badge) { + AvatarBadge.None -> null + + else -> { + { + Image( + painter = painterResource(id = badge.icon), + contentDescription = null, + ) + } + } + } + ) +} + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +private fun AvatarElement( + size: Dp, + modifier: Modifier = Modifier, + data: AvatarUIElement, + clip: Shape = CircleShape, + maxSizeLoad: Dp = LocalDimensions.current.iconLarge, +){ + Box( + modifier = modifier.size(size) + .background( + color = data.color ?: classicDark3, + shape = clip, + ) + .clip(clip), + ) { + // first attempt to display the custom image if there is one + if(data.contactPhoto != null){ + val maxSizePx = with(LocalDensity.current) { maxSizeLoad.toPx().toInt() } + GlideImage( + model = data.contactPhoto, + modifier = Modifier.fillMaxSize(), + contentDescription = null, + loading = placeholder(R.drawable.ic_user_filled_custom_padded), + requestBuilderTransform = { + it.avatarOptions(sizePx = maxSizePx, freezeFrame = data.freezeFrame) + } + ) + } // second attempt to use the custom icon if there is one + else if(data.icon != null){ + Image( + modifier = Modifier.fillMaxSize().padding(size * 0.2f), + painter = painterResource(id = data.icon), + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null, + ) + } // third, try to use the name if there is one + else if(!data.name.isNullOrEmpty()){ + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + BasicText( + modifier = Modifier.padding(size * 0.2f), + autoSize = TextAutoSize.StepBased( + minFontSize = 6.sp + ), + text = data.name, + style = LocalType.current.base.copy( + color = Color.White, + textAlign = TextAlign.Center, + ), + maxLines = 1 + ) + } + } else { // no name nor image data > show the default unknown icon + Image( + modifier = Modifier.fillMaxSize().padding(size * 0.2f), + painter = painterResource(id = R.drawable.ic_user_filled_custom), + contentDescription = null, + ) + } + } +} + +@Preview +@Composable +fun PreviewAvatarElement( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +){ + PreviewTheme(colors) { + AvatarElement( + size = LocalDimensions.current.iconLarge, + data = AvatarUIElement( + name = "TO", + color = primaryGreen, + contactPhoto = null + ) + ) + } +} + +@Preview +@Composable +fun PreviewAvatarSingleAdmin(){ + PreviewTheme { + Avatar( + size = LocalDimensions.current.iconLarge, + data = AvatarUIData( + listOf(AvatarUIElement( + name = "AT", + color = primaryGreen, + contactPhoto = null + ))), + badge = AvatarBadge.Admin + ) + } +} + +@Preview +@Composable +fun PreviewAvatarDouble(){ + PreviewTheme { + Avatar( + size = LocalDimensions.current.iconLarge, + data = AvatarUIData( + listOf(AvatarUIElement( + name = "FR", + color = primaryGreen, + contactPhoto = null + ), + AvatarUIElement( + name = "AT", + color = primaryBlue, + contactPhoto = null + ) + )) + ) + } +} + +@Preview +@Composable +fun PreviewAvatarSingleUnknown(){ + PreviewTheme { + Avatar( + size = LocalDimensions.current.iconLarge, + data = AvatarUIData( + listOf(AvatarUIElement( + name = "", + color = null, + contactPhoto = null + ))) + ) + } +} + +@Preview +@Composable +fun PreviewAvatarIconNoName(){ + PreviewTheme { + Avatar( + size = LocalDimensions.current.iconLarge, + data = AvatarUIData( + listOf(AvatarUIElement( + name = "", + icon = R.drawable.session_logo, + color = null, + contactPhoto = null + ))) + ) + } +} + +@Preview +@Composable +fun PreviewAvatarIconWithName(){ + PreviewTheme { + Avatar( + size = LocalDimensions.current.iconLarge, + data = AvatarUIData( + listOf(AvatarUIElement( + name = "TO", + icon = R.drawable.session_logo, + color = null, + contactPhoto = null + ))) + ) + } +} + +@Preview +@Composable +fun PreviewAvatarSinglePhoto(){ + PreviewTheme { + Avatar( + size = LocalDimensions.current.iconLarge, + data = AvatarUIData( + listOf(AvatarUIElement( + name = "AT", + color = primaryGreen, + contactPhoto = ProfileContactPhoto( + Address.fromSerialized("05c0d6db0f2d400c392a745105dc93b666642b9dd43993e97c2c4d7440c453b620"), + "305422957" + ) + ))) + ) + } +} + +@Preview +@Composable +fun PreviewAvatarElementUnclipped(){ + PreviewTheme { + AvatarElement( + size = LocalDimensions.current.iconLarge, + data = AvatarUIElement( + name = "TO", + color = primaryGreen, + contactPhoto = null + ), + clip = RectangleShape + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/BlurredImage.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/BlurredImage.kt new file mode 100644 index 0000000000..be0546e8b9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/BlurredImage.kt @@ -0,0 +1,104 @@ +package org.thoughtcrime.securesms.ui.components + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Build +import android.renderscript.Allocation +import android.renderscript.RenderScript +import android.renderscript.ScriptIntrinsicBlur +import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp + +/** + * A composable that displays a blurred image, including with legacy compatibility + * for devices running android < 12 + */ +@Composable +fun BlurredImage( + drawableId: Int, + blurRadiusDp: Float, + modifier: Modifier = Modifier, + alpha: Float = 0.8f +) { + val context = LocalContext.current + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Use Compose's built-in blur for newer devices. + Image( + painter = painterResource(id = drawableId), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = modifier.blur(blurRadiusDp.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded), + alpha = alpha + ) + } else { + // Convert and blur vector drawable for legacy devices. + val bitmap = getBlurredBitmapFromVector(context, drawableId, blurRadiusDp, applyBlur = true) + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = modifier, + alpha = alpha + ) + } else { + // Fallback: show unblurred image. + Image( + painter = painterResource(id = drawableId), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = modifier, + alpha = alpha + ) + } + } +} + +private fun getBlurredBitmapFromVector( + context: Context, + drawableId: Int, + blurRadius: Float, + applyBlur: Boolean +): Bitmap? { + val drawable = AppCompatResources.getDrawable(context, drawableId) ?: return null + val bitmap = Bitmap.createBitmap( + drawable.intrinsicWidth, + drawable.intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) + Canvas(bitmap).apply { + drawable.setBounds(0, 0, width, height) + drawable.draw(this) + } + return if (applyBlur) { + blurBitmapLegacy(context, bitmap, blurRadius) + } else { + bitmap + } +} + +private fun blurBitmapLegacy(context: Context, bitmap: Bitmap, blurRadius: Float): Bitmap { + val mutableBitmap = if (bitmap.isMutable) bitmap else bitmap.copy(Bitmap.Config.ARGB_8888, true) + val renderScript = RenderScript.create(context) ?: return mutableBitmap + try { + val allocation = Allocation.createFromBitmap(renderScript, mutableBitmap) + val blurScript = ScriptIntrinsicBlur.create(renderScript, allocation.element) + blurScript.setRadius(blurRadius) + blurScript.setInput(allocation) + blurScript.forEach(allocation) + allocation.copyTo(mutableBitmap) + } finally { + renderScript.destroy() + } + return mutableBitmap +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Border.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Border.kt deleted file mode 100644 index 4420e91e78..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Border.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.thoughtcrime.securesms.ui.components - -import androidx.compose.foundation.border -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.SolidColor -import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.LocalColors - -@Composable -fun Modifier.border() = this.border( - width = LocalDimensions.current.borderStroke, - brush = SolidColor(LocalColors.current.borders), - shape = MaterialTheme.shapes.small -) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/BottomSheets.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/BottomSheets.kt new file mode 100644 index 0000000000..ee08945b9e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/BottomSheets.kt @@ -0,0 +1,206 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors + + +/** + * The base bottom sheet with our app's styling + */ +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun BaseBottomSheet( + sheetState: SheetState, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + content: @Composable ColumnScope.() -> Unit, +){ + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismissRequest, + sheetState = sheetState, + shape = RoundedCornerShape( + topStart = LocalDimensions.current.xsSpacing, + topEnd = LocalDimensions.current.xsSpacing + ), + dragHandle = dragHandle, + containerColor = LocalColors.current.backgroundSecondary, + content = content + ) +} + + +/** + * A bottom sheet dialog that displays a list of options. + * + * @param options The list of options to display. + * @param onDismissRequest Callback to be invoked when the dialog is to be dismissed. + * @param onOptionClick Callback to be invoked when an option is clicked. + * @param optionTitle A function that returns the title of an option. + * @param optionIconRes A function that returns the icon resource of an option. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ActionSheet( + options: Collection, + onDismissRequest: () -> Unit, + onOptionClick: (T) -> Unit, + optionTitle: (T) -> String, + optionQaTag: (T) -> Int, + optionIconRes: (T) -> Int, + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(), +) { + BaseBottomSheet( + modifier = modifier, + sheetState = sheetState, + dragHandle = null, + onDismissRequest = onDismissRequest + ){ + for (option in options) { + ActionSheetItem( + text = optionTitle(option), + leadingIcon = optionIconRes(option), + qaTag = optionQaTag(option).takeIf { it != 0 }?.let { stringResource(it) }, + onClick = { + onOptionClick(option) + onDismissRequest() + } + ) + } + } +} + +@Composable +fun ActionSheetItem( + leadingIcon: Int, + text: String, + qaTag: String?, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .clickable(onClick = onClick) + .padding(LocalDimensions.current.smallSpacing) + .let { modifier -> + qaTag?.let { modifier.qaTag(it) } ?: modifier + } + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.spacing), + verticalAlignment = CenterVertically, + ) { + Icon( + painter = painterResource(leadingIcon), + modifier = Modifier.size(LocalDimensions.current.iconMedium), + tint = LocalColors.current.text, + contentDescription = null + ) + + Text( + modifier = Modifier.weight(1f), + style = LocalType.current.large, + text = text, + textAlign = TextAlign.Start, + color = LocalColors.current.text, + ) + } +} + + +data class ActionSheetItemData( + val title: String, + @DrawableRes val iconRes: Int, + @StringRes val qaTag: Int = 0, + val onClick: () -> Unit, +) + +/** + * A convenience function to display a [ActionSheet] with a collection of [ActionSheetItemData]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ActionSheet( + items: Collection, + sheetState: SheetState = rememberModalBottomSheetState(), + onDismissRequest: () -> Unit +) { + ActionSheet( + options = items, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + onOptionClick = { it.onClick() }, + optionTitle = { it.title }, + optionIconRes = { it.iconRes }, + optionQaTag = { it.qaTag } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +fun ActionSheetPreview( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +){ + PreviewTheme(colors) { + val sheetState: SheetState = rememberStandardBottomSheetState( + initialValue = SheetValue.PartiallyExpanded, + ) + + ActionSheet( + sheetState = sheetState, + items = listOf( + ActionSheetItemData( + title = "Option 1", + iconRes = R.drawable.ic_trash_2, + onClick = {} + ), + ActionSheetItemData( + title = "Option 2", + iconRes = R.drawable.ic_pencil, + onClick = {} + ), + ActionSheetItemData( + title = "Option 3", + iconRes = R.drawable.ic_arrow_down_to_line, + onClick = {} + ) + ), + onDismissRequest = {} + ) + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt index a65b341a54..344c696bfd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.material3.ButtonColors @@ -31,20 +32,23 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter import network.loki.messenger.R import org.thoughtcrime.securesms.ui.LaunchedEffectAsync -import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.ui.theme.bold import org.thoughtcrime.securesms.ui.theme.buttonShape +import org.thoughtcrime.securesms.ui.theme.sessionShapes import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -59,6 +63,7 @@ fun Button( enabled: Boolean = true, style: ButtonStyle = ButtonStyle.Large, shape: Shape = buttonShape, + minWidth: Dp = LocalDimensions.current.minButtonWidth, border: BorderStroke? = type.border(enabled), colors: ButtonColors = type.buttonColors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, @@ -68,7 +73,8 @@ fun Button( style.applyButtonConstraints { androidx.compose.material3.Button( onClick = onClick, - modifier = modifier.heightIn(min = style.minHeight), + modifier = modifier.heightIn(min = style.minHeight) + .defaultMinSize(minWidth = minWidth), enabled = enabled, interactionSource = interactionSource, elevation = null, @@ -97,9 +103,10 @@ fun Button( enabled: Boolean = true, style: ButtonStyle = ButtonStyle.Large, shape: Shape = buttonShape, + minWidth: Dp = LocalDimensions.current.minButtonWidth, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { - Button(onClick, type, modifier, enabled, style, shape, interactionSource = interactionSource) { + Button(onClick, type, modifier, enabled, style, shape, minWidth = minWidth, interactionSource = interactionSource) { Text(text) } } @@ -108,14 +115,35 @@ fun Button( Button(text, onClick, ButtonType.Fill, modifier, enabled) } -@Composable fun PrimaryFillButton(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { - Button(text, onClick, ButtonType.PrimaryFill, modifier, enabled) +@Composable fun AccentFillButton(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.AccentFill, modifier, enabled) +} + +@Composable fun FillButtonRect(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.Fill, modifier, enabled, shape = sessionShapes().extraSmall) +} + +@Composable fun TertiaryFillButtonRect(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.TertiaryFill, modifier, enabled, shape = sessionShapes().extraSmall) +} + +@Composable fun AccentFillButtonRect(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.AccentFill, modifier, enabled, shape = sessionShapes().extraSmall) + +} + +@Composable fun AccentFillButtonRect(modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { + Button(onClick = onClick, ButtonType.AccentFill, modifier = modifier, enabled = enabled, shape = sessionShapes().extraSmall, content = content) } @Composable fun OutlineButton(text: String, modifier: Modifier = Modifier, color: Color = LocalColors.current.text, enabled: Boolean = true, onClick: () -> Unit) { Button(text, onClick, ButtonType.Outline(color), modifier, enabled) } +@Composable fun AccentOutlineButtonRect(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.Outline(LocalColors.current.accentText), modifier, enabled, shape = sessionShapes().extraSmall) +} + @Composable fun OutlineButton(modifier: Modifier = Modifier, color: Color = LocalColors.current.text, enabled: Boolean = true, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { Button( onClick = onClick, @@ -126,12 +154,13 @@ fun Button( ) } -@Composable fun PrimaryOutlineButton(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { - Button(text, onClick, ButtonType.Outline(LocalColors.current.primaryButtonFill), modifier, enabled) +@Composable fun AccentOutlineButton(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, + minWidth: Dp = LocalDimensions.current.minButtonWidth, onClick: () -> Unit) { + Button(text, onClick, ButtonType.Outline(LocalColors.current.accentText), modifier, enabled, minWidth = minWidth) } -@Composable fun PrimaryOutlineButton(modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { - Button(onClick, ButtonType.Outline(LocalColors.current.primaryButtonFill), modifier, enabled, content = content) +@Composable fun AccentOutlineButton(modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { + Button(onClick, ButtonType.Outline(LocalColors.current.accentText), modifier, enabled, content = content) } @Composable fun SlimOutlineButton(modifier: Modifier = Modifier, color: Color = LocalColors.current.text, enabled: Boolean = true, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { @@ -145,17 +174,17 @@ fun Button( Button(text, onClick, ButtonType.Outline(color), modifier, enabled, ButtonStyle.Slim) } -@Composable fun SlimPrimaryOutlineButton(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { - Button(text, onClick, ButtonType.Outline(LocalColors.current.primaryButtonFill), modifier, enabled, ButtonStyle.Slim) +@Composable fun SlimAccentOutlineButton(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button(text, onClick, ButtonType.Outline(LocalColors.current.accentText), modifier, enabled, ButtonStyle.Slim) } @Composable -fun PrimaryOutlineCopyButton( +fun AcccentOutlineCopyButton( modifier: Modifier = Modifier, style: ButtonStyle = ButtonStyle.Large, onClick: () -> Unit ) { - OutlineCopyButton(modifier, style, LocalColors.current.primaryButtonFill, onClick) + OutlineCopyButton(modifier, style, LocalColors.current.accentText, onClick) } @Composable @@ -177,7 +206,7 @@ fun OutlineCopyButton( val interactionSource = remember { MutableInteractionSource() } Button( - modifier = modifier.contentDescription(R.string.AccessibilityId_copy), + modifier = modifier.qaTag(R.string.AccessibilityId_copy), interactionSource = interactionSource, style = style, type = ButtonType.Outline(color), @@ -267,7 +296,12 @@ fun BorderlessButtonWithIcon( color = color, onClick = onClick ) { - AnnotatedTextWithIcon(text, iconRes, style = style) + AnnotatedTextWithIcon( + text = text, + iconRes = iconRes, + color = color, + style = style + ) } } @@ -302,20 +336,27 @@ private fun VariousButtons( verticalArrangement = Arrangement.spacedBy(8.dp), maxItemsInEachRow = 2 ) { - PrimaryFillButton("Primary Fill") {} - PrimaryFillButton("Primary Fill Disabled", enabled = false) {} + AccentFillButton("Accent Fill") {} + AccentFillButton("Accent Fill Disabled", enabled = false) {} FillButton("Fill Button") {} FillButton("Fill Button Disabled", enabled = false) {} - PrimaryOutlineButton("Primary Outline Button") {} - PrimaryOutlineButton("Primary Outline Disabled", enabled = false) {} + AccentOutlineButton("Accent Outline Button") {} + AccentOutlineButton("Accent Outline Disabled", enabled = false) {} OutlineButton("Outline Button") {} OutlineButton("Outline Button Disabled", enabled = false) {} SlimOutlineButton("Slim Outline") {} SlimOutlineButton("Slim Outline Disabled", enabled = false) {} - SlimPrimaryOutlineButton("Slim Primary") {} + SlimAccentOutlineButton("Slim Accent") {} SlimOutlineButton("Slim Danger", color = LocalColors.current.danger) {} BorderlessButton("Borderless Button") {} BorderlessButton("Borderless Secondary", color = LocalColors.current.textSecondary) {} + FillButtonRect("Fill Rect") {} + FillButtonRect("Fill Rect Disabled", enabled = false) {} + TertiaryFillButtonRect("Tertiary Fill Rect") {} + AccentFillButtonRect("Accent Fill Rect") {} + AccentFillButtonRect("Accent Fill Rect Disabled", enabled = false) {} + AccentOutlineButtonRect("Outline Button Rect") {} + AccentOutlineButtonRect("Outline ButtonDisabled", enabled = false) {} } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt index 54478e69b5..2453c4663c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonType.kt @@ -53,13 +53,25 @@ interface ButtonType { ) } - object PrimaryFill: ButtonType { + object AccentFill: ButtonType { @Composable override fun border(enabled: Boolean) = if (enabled) null else disabledBorder @Composable override fun buttonColors() = ButtonDefaults.buttonColors( - contentColor = LocalColors.current.primaryButtonFillText, - containerColor = LocalColors.current.primaryButtonFill, + contentColor = LocalColors.current.accentButtonFillText, + containerColor = LocalColors.current.accent, + disabledContentColor = LocalColors.current.disabled, + disabledContainerColor = Color.Transparent + ) + } + + object TertiaryFill: ButtonType { + @Composable + override fun border(enabled: Boolean) = if (enabled) null else disabledBorder + @Composable + override fun buttonColors() = ButtonDefaults.buttonColors( + contentColor = LocalColors.current.text, + containerColor = LocalColors.current.backgroundTertiary, disabledContentColor = LocalColors.current.disabled, disabledContainerColor = Color.Transparent ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt index f555c82426..7852a407cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt @@ -14,8 +14,7 @@ fun CircularProgressIndicator( ) { androidx.compose.material3.CircularProgressIndicator( modifier = modifier.size(40.dp), - color = color, - strokeWidth = 2.dp + color = color ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt new file mode 100644 index 0000000000..ca20f21918 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt @@ -0,0 +1,483 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.Spacer +import androidx.compose.foundation.layout.WindowInsets +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.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +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 org.thoughtcrime.securesms.ui.ProBadgeText +import org.thoughtcrime.securesms.ui.SearchBar +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.primaryBlue +import org.thoughtcrime.securesms.ui.theme.primaryOrange +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUIElement + +/** + * A fully Compose implementation of the conversation top bar + * with HorizontalPager for settings and dot indicators + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConversationAppBar( + data: ConversationAppBarData, + searchQuery: String, + onSearchQueryChanged: (String) -> Unit, + onSearchQueryClear: () -> Unit, + onSearchCanceled: () -> Unit, + onBackPressed: () -> Unit, + onCallPressed: () -> Unit, + onAvatarPressed: () -> Unit, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + // cross fade between the default app bar and the search bar + Crossfade(targetState = data.showSearch) { showSearch -> + when(showSearch){ + false -> { + val pagerState = rememberPagerState(pageCount = { data.pagerData.size }) + + CenterAlignedTopAppBar( + title = { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + var titleModifier: Modifier = Modifier + // we want the title to also open the UCS, but we will follow + // the logic for the avatar, so if the avatar isn't showing, + // do not apply the onClick + if(data.showAvatar) { + titleModifier = titleModifier.clickable{ onAvatarPressed() } + } + + ProBadgeText( + modifier = titleModifier.qaTag(R.string.AccessibilityId_conversationTitle), + text = data.title, + showBadge = data.showProBadge + ) + + if (data.pagerData.isNotEmpty()) { + // Settings content pager + ConversationSettingsPager( + modifier = Modifier.padding(top = 2.dp) + .fillMaxWidth(0.8f), + pages = data.pagerData, + pagerState = pagerState + ) + + // Dot indicators + PagerIndicator( + modifier = Modifier.padding(top = 2.dp), + pageCount = data.pagerData.size, + currentPage = pagerState.currentPage + ) + } + } + }, + navigationIcon = { + AppBarBackIcon(onBack = onBackPressed) + }, + actions = { + if (data.showCall) { + IconButton( + onClick = onCallPressed + ) { + Icon( + painter = painterResource(id = R.drawable.ic_phone), + contentDescription = stringResource(id = R.string.AccessibilityId_call), + tint = LocalColors.current.text, + modifier = Modifier.size(LocalDimensions.current.iconMedium) + ) + } + } + + // Avatar + if (data.showAvatar) { + Avatar( + modifier = Modifier.qaTag(R.string.qa_conversation_avatar) + .padding( + start = if(data.showCall) 0.dp else LocalDimensions.current.xsSpacing, + end = LocalDimensions.current.xsSpacing + ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false, radius = LocalDimensions.current.iconLargeAvatar/2), + onClick = onAvatarPressed + ), + size = LocalDimensions.current.iconLargeAvatar, + data = data.avatarUIData + ) + } + }, + colors = appBarColors(LocalColors.current.background) + ) + } + + true -> { + Row( + modifier = Modifier + .windowInsetsTopHeight(WindowInsets.systemBars) + .padding(horizontal = LocalDimensions.current.smallSpacing) + .heightIn(min = LocalDimensions.current.appBarHeight), + verticalAlignment = Alignment.CenterVertically, + ) { + + val focusRequester = remember { FocusRequester() } + LaunchedEffect (Unit) { + focusRequester.requestFocus() + } + + SearchBar( + query = searchQuery, + onValueChanged = onSearchQueryChanged, + onClear = onSearchQueryClear, + placeholder = stringResource(R.string.search), + modifier = Modifier.weight(1f) + .focusRequester(focusRequester), + backgroundColor = LocalColors.current.backgroundSecondary, + ) + + Spacer(Modifier.width(LocalDimensions.current.xsSpacing)) + + Text( + modifier = Modifier.qaTag(R.string.qa_conversation_search_cancel) + .clickable { + onSearchCanceled() + }, + text = stringResource(R.string.cancel), + style = LocalType.current.large, + ) + } + } + } + + } + } +} + +/** + * Overall data class for convo app bar data + */ +data class ConversationAppBarData( + val title: String, + val pagerData: List, + val showProBadge: Boolean = false, + val showAvatar: Boolean = false, + val showCall: Boolean = false, + val showSearch: Boolean = false, + val avatarUIData: AvatarUIData +) + +/** + * Data class representing a pager item data + */ +data class ConversationAppBarPagerData( + val title: String, + val action: () -> Unit, + @DrawableRes val icon: Int? = null, + val qaTag: String? = null +) + +/** + * Horizontal pager for app bar + */ +@Composable +private fun ConversationSettingsPager( + pages: List, + pagerState: PagerState, + modifier: Modifier = Modifier +) { + HorizontalPager( + state = pagerState, + modifier = modifier, + ) { page -> + Row ( + modifier = Modifier.fillMaxWidth() + .qaTag(pages[page].qaTag ?: pages[page].title) + .clickable { + pages[page].action() + }, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + // '<' icon + if(pages.size > 1) { + Image( + modifier = Modifier.size(12.dp), + painter = painterResource(id = R.drawable.ic_chevron_left), + colorFilter = ColorFilter.tint(LocalColors.current.text), + contentDescription = null, + ) + } + + // optional icon + if(pages[page].icon != null) { + Spacer(modifier = Modifier.size(LocalDimensions.current.xxxsSpacing)) + Image( + modifier = Modifier.size(LocalDimensions.current.iconXXSmall), + painter = painterResource(id = pages[page].icon!!), + colorFilter = ColorFilter.tint(LocalColors.current.text), + contentDescription = null, + ) + } + + // Page name + Text( + modifier = Modifier.padding(horizontal = LocalDimensions.current.xxxsSpacing), + text = pages[page].title, + textAlign = TextAlign.Center, + color = LocalColors.current.text, + style = LocalType.current.extraSmall + ) + + // '>' icon + if(pages.size > 1) { + Image( + modifier = Modifier.size(12.dp).rotate(180f), + painter = painterResource(id = R.drawable.ic_chevron_left), + colorFilter = ColorFilter.tint(LocalColors.current.text), + contentDescription = null, + ) + } + } + } +} + +/** + * Dots indicator for the pager + */ +@Composable +private fun PagerIndicator( + pageCount: Int, + currentPage: Int, + modifier: Modifier = Modifier +) { + if (pageCount <= 1) return + + Row( + modifier = modifier + .height(LocalDimensions.current.xsSpacing), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + repeat(pageCount) { page -> + val isSelected = page == currentPage + + Box( + modifier = Modifier + .size( + width = 4.dp, + height = 4.dp + ) + .clip(CircleShape) + .background( + if (isSelected) + LocalColors.current.text + else + LocalColors.current.text.copy(alpha = 0.3f) + ) + ) + } + } +} + + +/** + * Preview parameters for ConversationTopBar + */ +class ConversationTopBarPreviewParams( + val title: String, + val settingsPagesCount: Int, + val isCallAvailable: Boolean, + val showAvatar: Boolean, + val showSearch: Boolean = false, + val showProBadge: Boolean = false +) + +/** + * Provider for ConversationTopBar preview parameters + */ +class ConversationTopBarParamsProvider : PreviewParameterProvider { + override val values = sequenceOf( + // Basic conversation with no settings + ConversationTopBarPreviewParams( + title = "Alice Smith", + settingsPagesCount = 0, + isCallAvailable = false, + showAvatar = true + ), + // Basic conversation with no settings + Pro + ConversationTopBarPreviewParams( + title = "Alice Smith", + settingsPagesCount = 0, + isCallAvailable = false, + showAvatar = true, + showProBadge = true + ), + // Basic conversation with no settings + ConversationTopBarPreviewParams( + title = "Alice Smith", + settingsPagesCount = 0, + isCallAvailable = true, + showAvatar = true + ), + // Basic conversation with no settings + ConversationTopBarPreviewParams( + title = "Alice Smith", + settingsPagesCount = 3, + isCallAvailable = true, + showAvatar = true + ), + // Long title without call button + ConversationTopBarPreviewParams( + title = "Really Long Conversation Title That Should Ellipsize", + settingsPagesCount = 0, + isCallAvailable = false, + showAvatar = true + ), + // Long title without call button + Pro + ConversationTopBarPreviewParams( + title = "Really Long Conversation Title That Should Ellipsize", + settingsPagesCount = 0, + isCallAvailable = false, + showAvatar = true, + showProBadge = true + ), + // Long title with call button + ConversationTopBarPreviewParams( + title = "Really Long Conversation Title That Should Ellipsize", + settingsPagesCount = 0, + isCallAvailable = true, + showAvatar = true + ), + // Long title with call button + Pro + ConversationTopBarPreviewParams( + title = "Really Long Conversation Title That Should Ellipsize", + settingsPagesCount = 0, + isCallAvailable = true, + showAvatar = true, + showProBadge = true + ), + // With settings pages and all options + ConversationTopBarPreviewParams( + title = "Group Chat", + settingsPagesCount = 3, + isCallAvailable = true, + showAvatar = true + ), + // No avatar + ConversationTopBarPreviewParams( + title = "New Contact", + settingsPagesCount = 0, + isCallAvailable = false, + showAvatar = false + ), + // search + ConversationTopBarPreviewParams( + title = "Alice Smith", + settingsPagesCount = 0, + isCallAvailable = false, + showAvatar = true, + showSearch = true + ), + ) +} + + +/** + * Preview for ConversationTopBar with different configurations + */ +@Preview(showBackground = true) +@Composable +fun ConversationTopBarPreview( + @PreviewParameter(ConversationTopBarParamsProvider::class) params: ConversationTopBarPreviewParams +) { + PreviewTheme { + // Create sample settings pages + val settingsPages = List(params.settingsPagesCount) { index -> + ConversationAppBarPagerData( + title = "Settings $index", + icon = R.drawable.ic_clock_11, + action = {} + ) + } + + ConversationAppBar( + data = ConversationAppBarData( + title = params.title, + pagerData = settingsPages, + showAvatar = params.showAvatar, + showCall = params.isCallAvailable, + showSearch = params.showSearch, + showProBadge = params.showProBadge, + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TO", + color = primaryBlue + ), + AvatarUIElement( + name = "TA", + color = primaryOrange + ) + ) + ) + ), + onBackPressed = { /* no-op for preview */ }, + onCallPressed = { /* no-op for preview */ }, + onAvatarPressed = { /* no-op for preview */ }, + searchQuery = "", + onSearchQueryChanged = {}, + onSearchQueryClear = {}, + onSearchCanceled = {}, + ) + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt index de7f437356..f2608d7cb5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/DropDown.kt @@ -60,10 +60,10 @@ fun DropDown( errorIndicatorColor = Color.Transparent, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, - disabledTrailingIconColor = LocalColors.current.primary, - errorTrailingIconColor = LocalColors.current.primary, - focusedTrailingIconColor = LocalColors.current.primary, - unfocusedTrailingIconColor = LocalColors.current.primary, + disabledTrailingIconColor = LocalColors.current.accent, + errorTrailingIconColor = LocalColors.current.accent, + focusedTrailingIconColor = LocalColors.current.accent, + unfocusedTrailingIconColor = LocalColors.current.accent, disabledTextColor = LocalColors.current.text, errorTextColor = LocalColors.current.text, focusedTextColor = LocalColors.current.text, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt index a3b508f481..12921f5f72 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt @@ -2,9 +2,7 @@ package org.thoughtcrime.securesms.ui.components import android.Manifest import android.annotation.SuppressLint -import android.app.Activity import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri import android.provider.Settings import androidx.camera.core.CameraSelector @@ -47,8 +45,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState @@ -68,7 +64,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.ui.AlertDialog -import org.thoughtcrime.securesms.ui.DialogButtonModel +import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.findActivity import org.thoughtcrime.securesms.ui.getSubbedString @@ -120,7 +116,7 @@ fun QRScannerScreen( textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - PrimaryOutlineButton( + AccentOutlineButton( stringResource(R.string.cameraGrantAccess), modifier = Modifier.fillMaxWidth(), onClick = { @@ -150,11 +146,11 @@ fun QRScannerScreen( text = context.getSubbedString(R.string.permissionsCameraDenied, APP_NAME_KEY to context.getString(R.string.app_name)), buttons = listOf( - DialogButtonModel( + DialogButtonData( text = GetString(stringResource(id = R.string.sessionSettings)), onClick = onClickSettings ), - DialogButtonModel( + DialogButtonData( GetString(stringResource(R.string.cancel)) ) ) @@ -226,7 +222,7 @@ fun ScanQrCode(errors: Flow, onScan: (String) -> Unit) { } } ) { padding -> - Box(modifier = Modifier.padding(padding)) { + Box { AndroidView( modifier = Modifier.fillMaxSize(), factory = { PreviewView(it).apply { preview.setSurfaceProvider(surfaceProvider) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QrImage.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QrImage.kt index c5f927b4ab..61ae3651dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QrImage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QrImage.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -40,8 +41,8 @@ import org.thoughtcrime.securesms.util.QRCodeUtilities fun QrImage( string: String?, modifier: Modifier = Modifier, - contentPadding: Dp = LocalDimensions.current.smallSpacing, - icon: Int = R.drawable.session_shield + contentPadding: Dp = LocalDimensions.current.xxsSpacing, + icon: Int = R.drawable.ic_recovery_password_custom ) { var bitmap: Bitmap? by remember { mutableStateOf(null) @@ -98,11 +99,10 @@ private fun Content( painter = painterResource(id = icon), contentDescription = "", tint = qrColor, - modifier = Modifier - .size(62.dp) - .align(Alignment.Center) + modifier = Modifier.align(Alignment.Center) .background(color = backgroundColor) - .size(66.dp) + .fillMaxSize(0.25f) + .padding(LocalDimensions.current.xxxsSpacing) ) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt index 1855925840..5aed11093a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.ui.components +import androidx.annotation.DrawableRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut @@ -13,13 +14,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -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.selection.selectable import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -27,15 +27,18 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp 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.ui.GetString import org.thoughtcrime.securesms.ui.RadioOption -import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -49,6 +52,7 @@ fun RadioButton( modifier: Modifier = Modifier, selected: Boolean = false, enabled: Boolean = true, + @DrawableRes iconRes: Int? = null, contentPadding: PaddingValues = PaddingValues(), content: @Composable RowScope.() -> Unit = {} ) { @@ -67,9 +71,20 @@ fun RadioButton( shape = RectangleShape, contentPadding = contentPadding ) { + if(iconRes != null){ + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + modifier = Modifier + .size(LocalDimensions.current.iconMedium), + ) + + Spacer(modifier = Modifier.width(LocalDimensions.current.spacing)) + } + content() - Spacer(modifier = Modifier.width(20.dp)) + Spacer(modifier = Modifier.width(LocalDimensions.current.spacing)) RadioButtonIndicator( selected = selected, enabled = enabled, @@ -84,7 +99,7 @@ fun RadioButtonIndicator( selected: Boolean, enabled: Boolean, modifier: Modifier = Modifier, - size: Dp = 22.dp + size: Dp = LocalDimensions.current.iconMedium ) { Box(modifier = modifier) { AnimatedVisibility( @@ -100,7 +115,7 @@ fun RadioButtonIndicator( modifier = Modifier .fillMaxSize() .background( - color = if (enabled) LocalColors.current.primary else LocalColors.current.disabled, + color = if (enabled) LocalColors.current.accent else LocalColors.current.disabled, shape = CircleShape ) ) @@ -118,6 +133,27 @@ fun RadioButtonIndicator( } } +/** + * Convenience access for a TitledRadiobutton used in dialogs + */ +@Composable +fun DialogTitledRadioButton( + modifier: Modifier = Modifier, + option: RadioOption, + onClick: () -> Unit +) { + TitledRadioButton( + modifier = modifier, + contentPadding = PaddingValues( + horizontal = LocalDimensions.current.xxsSpacing, + vertical = 0.dp + ), + titleStyle = LocalType.current.large, + option = option, + onClick = onClick + ) +} + @Composable fun TitledRadioButton( modifier: Modifier = Modifier, @@ -125,15 +161,17 @@ fun TitledRadioButton( horizontal = LocalDimensions.current.spacing, vertical = LocalDimensions.current.smallSpacing ), + titleStyle: TextStyle = LocalType.current.h8, + subtitleStyle: TextStyle = LocalType.current.extraSmall, option: RadioOption, onClick: () -> Unit ) { RadioButton( - modifier = modifier - .contentDescription(option.contentDescription), + modifier = modifier.qaTag(option.qaTag?.string()), onClick = onClick, selected = option.selected, enabled = option.enabled, + iconRes = option.iconRes, contentPadding = contentPadding, content = { Column( @@ -143,12 +181,12 @@ fun TitledRadioButton( ) { Text( text = option.title(), - style = LocalType.current.large + style = titleStyle ) option.subtitle?.let { Text( text = it(), - style = LocalType.current.extraSmall + style = subtitleStyle ) } } @@ -172,6 +210,23 @@ fun PreviewTextRadioButton() { } } +@Preview +@Composable +fun PreviewTextIconRadioButton() { + PreviewTheme { + TitledRadioButton( + option = RadioOption( + value = ExpiryType.AFTER_SEND.mode(7.days), + title = GetString(7.days), + subtitle = GetString("This is a subtitle"), + iconRes = R.drawable.ic_users_group_custom, + enabled = true, + selected = true + ) + ) {} + } +} + @Preview @Composable fun PreviewDisabledTextRadioButton() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionSwitch.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionSwitch.kt new file mode 100644 index 0000000000..baccc515a4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionSwitch.kt @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import org.thoughtcrime.securesms.ui.theme.LocalColors + +// todo Get proper styling that works well with ax on all themes and then move this composable in the components file +@Composable +fun SessionSwitch( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Switch( + checked = checked, + modifier = modifier, + onCheckedChange = onCheckedChange, + enabled = enabled, + colors = SwitchDefaults.colors( + checkedThumbColor = LocalColors.current.accent, + checkedTrackColor = LocalColors.current.accent.copy(alpha = 0.3f), + uncheckedThumbColor = LocalColors.current.disabled, + uncheckedTrackColor = LocalColors.current.disabled.copy(alpha = 0.3f), + uncheckedBorderColor = Color.Transparent, + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionTabRow.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionTabRow.kt index 5dae714380..632cb49b0c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionTabRow.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/SessionTabRow.kt @@ -38,7 +38,7 @@ fun SessionTabRow(pagerState: PagerState, titles: List) { indicator = { tabPositions -> TabRowDefaults.SecondaryIndicator( Modifier.tabIndicatorOffset(tabPositions[pagerState.currentPage]), - color = LocalColors.current.primary, + color = LocalColors.current.accent, height = LocalDimensions.current.indicatorHeight ) }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt index f18069f080..45d8bfa897 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt @@ -1,23 +1,24 @@ package org.thoughtcrime.securesms.ui.components import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image import androidx.compose.foundation.border -import androidx.compose.foundation.interaction.InteractionSource -import androidx.compose.foundation.interaction.MutableInteractionSource +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.PaddingValues -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.KeyboardActionScope import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.appendInlineContent @@ -25,34 +26,41 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.max import androidx.compose.ui.unit.sp import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme -import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.bold import org.thoughtcrime.securesms.ui.theme.borders import org.thoughtcrime.securesms.ui.theme.text import org.thoughtcrime.securesms.ui.theme.textSecondary -import org.thoughtcrime.securesms.ui.contentDescription -import org.thoughtcrime.securesms.ui.theme.LocalType -import org.thoughtcrime.securesms.ui.theme.bold -import kotlin.math.sin @Preview @Composable @@ -67,11 +75,37 @@ fun PreviewSessionOutlinedTextField() { placeholder = "", ) + SessionOutlinedTextField( + text = "text with clear", + placeholder = "", + showClear = true + ) + + SessionOutlinedTextField( + text = "text with clear", + placeholder = "", + showClear = true, + innerPadding = PaddingValues(LocalDimensions.current.smallSpacing) + ) + SessionOutlinedTextField( + text = "text with clear \ntest\ntest\ntest\ntest", + placeholder = "", + showClear = true, + innerPadding = PaddingValues(LocalDimensions.current.smallSpacing) + ) + + SessionOutlinedTextField( text = "", placeholder = "placeholder" ) + SessionOutlinedTextField( + text = "", + placeholder = "placeholder no clear", + showClear = true + ) + SessionOutlinedTextField( text = "text", placeholder = "", @@ -84,6 +118,13 @@ fun PreviewSessionOutlinedTextField() { error = "error", isTextErrorColor = false ) + + SessionOutlinedTextField( + text = "Disabled", + placeholder = "", + isTextErrorColor = false, + enabled = false + ) } } } @@ -95,21 +136,45 @@ fun SessionOutlinedTextField( onChange: (String) -> Unit = {}, textStyle: TextStyle = LocalType.current.base, innerPadding: PaddingValues = PaddingValues(LocalDimensions.current.spacing), + borderShape: Shape = MaterialTheme.shapes.small, placeholder: String = "", onContinue: () -> Unit = {}, error: String? = null, isTextErrorColor: Boolean = error != null, enabled: Boolean = true, singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + showClear: Boolean = false, + @StringRes clearQaTag: Int = R.string.qa_input_clear, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default.copy(imeAction = if (singleLine) ImeAction.Done else ImeAction.Default) ) { + // in order to allow the cursor to be at the end of the text by default + // we need o handle the TextFieldValue manually here + var fieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(text, TextRange(text.length))) + } + + // If caller changes 'text', mirror it and move the caret to end + LaunchedEffect(text) { + if (text != fieldValue.text) { + fieldValue = TextFieldValue(text, TextRange(text.length)) + } + } + BasicTextField( - value = text, - onValueChange = onChange, + value = fieldValue, + onValueChange = { newValue -> + fieldValue = newValue + onChange(newValue.text) // propagate only the text outward + }, modifier = modifier, - textStyle = textStyle.copy(color = LocalColors.current.text(isTextErrorColor)), + textStyle = textStyle.copy( + color = if (enabled) LocalColors.current.text(isTextErrorColor) else LocalColors.current.textSecondary), cursorBrush = SolidColor(LocalColors.current.text(isTextErrorColor)), enabled = enabled, - keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), + keyboardOptions = keyboardOptions, + keyboardActions = KeyboardActions( onDone = { onContinue() }, onGo = { onContinue() }, @@ -117,6 +182,8 @@ fun SessionOutlinedTextField( onSend = { onContinue() }, ), singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, decorationBox = { innerTextField -> Column(modifier = Modifier.animateContentSize()) { Box( @@ -124,33 +191,60 @@ fun SessionOutlinedTextField( .border( width = LocalDimensions.current.borderStroke, color = LocalColors.current.borders(error != null), - shape = MaterialTheme.shapes.small + shape = borderShape ) .fillMaxWidth() .wrapContentHeight() - .padding(innerPadding) + .padding(innerPadding), ) { - innerTextField() + Row( + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier.weight(1f), + ) { + innerTextField() + } + + if(showClear && text.isNotEmpty()){ + Image( + painterResource(id = R.drawable.ic_x), + contentDescription = stringResource(R.string.clear), + colorFilter = ColorFilter.tint( + LocalColors.current.textSecondary + ), + modifier = Modifier.qaTag(clearQaTag) + .padding(start = LocalDimensions.current.smallSpacing) + .size(textStyle.fontSize.value.dp) + .clickable { + onChange("") + } + ) + } + } if (placeholder.isNotEmpty() && text.isEmpty()) { Text( text = placeholder, - style = textStyle.copy(color = LocalColors.current.textSecondary), + style = textStyle.copy(fontFamily = null), + color = LocalColors.current.textSecondary(isTextErrorColor) ) } } - error?.let { - Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) - Text( - it, - modifier = Modifier - .fillMaxWidth() - .contentDescription(R.string.AccessibilityId_theError), - textAlign = TextAlign.Center, - style = LocalType.current.base.bold(), - color = LocalColors.current.danger - ) + AnimatedContent (error) { errorText -> + if (errorText != null) { + Text( + errorText, + modifier = Modifier + .fillMaxWidth() + .padding(top = LocalDimensions.current.xsSpacing) + .qaTag(R.string.qa_input_error), + textAlign = TextAlign.Center, + style = LocalType.current.base.bold(), + color = LocalColors.current.danger + ) + } } } } @@ -160,37 +254,56 @@ fun SessionOutlinedTextField( @Composable fun AnnotatedTextWithIcon( text: String, - @DrawableRes iconRes: Int, + @DrawableRes iconRes: Int?, modifier: Modifier = Modifier, style: TextStyle = LocalType.current.base, color: Color = Color.Unspecified, - iconSize: TextUnit = 12.sp + iconSize: Pair = 12.sp to 12.sp, + iconPaddingValues: PaddingValues = PaddingValues(1.dp), + onIconClick: (() -> Unit)? = null ) { - val myId = "inlineContent" - val annotated = buildAnnotatedString { + var inlineContent: Map = mapOf() + + var annotated = buildAnnotatedString { append(text) - appendInlineContent(myId, "[icon]") } - val inlineContent = mapOf( - Pair( - myId, - InlineTextContent( - Placeholder( - width = iconSize, - height = iconSize, - placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter - ) - ) { - Icon( - painter = painterResource(id = iconRes), - contentDescription = null, - modifier = Modifier.padding(1.dp), - tint = color - ) - } + if(iconRes != null) { + val myId = "inlineContent" + + annotated = buildAnnotatedString { + append(text) + appendInlineContent(myId, "[icon]") + } + + inlineContent = mapOf( + Pair( + myId, + InlineTextContent( + Placeholder( + width = iconSize.first, + height = iconSize.second, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter + ) + ) { + var iconModifier: Modifier = Modifier + if(onIconClick != null) { + iconModifier = iconModifier.clickable { + onIconClick() + } + } + + Icon( + modifier = iconModifier.fillMaxSize() + .padding(iconPaddingValues), + painter = painterResource(id = iconRes), + contentDescription = null, + tint = color + ) + } + ) ) - ) + } Text( text = annotated, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt index bcadaac1d4..61678c1d04 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Dimensions.kt @@ -11,19 +11,37 @@ data class Dimensions( val xxsSpacing: Dp = 8.dp, val xsSpacing: Dp = 12.dp, val smallSpacing: Dp = 16.dp, + val contentSpacing: Dp = 20.dp, val spacing: Dp = 24.dp, val mediumSpacing: Dp = 36.dp, val xlargeSpacing: Dp = 64.dp, val appBarHeight: Dp = 64.dp, + val minSearchInputHeight: Dp = 35.dp, val minItemButtonHeight: Dp = 50.dp, val minLargeItemButtonHeight: Dp = 60.dp, val minButtonWidth: Dp = 160.dp, + val minSmallButtonWidth: Dp = 50.dp, val indicatorHeight: Dp = 4.dp, + val borderStroke: Dp = 1.dp, - val badgeSize: Dp = 20.dp, + val iconXXSmall: Dp = 9.dp, + val iconXSmall: Dp = 14.dp, + val iconSmall: Dp = 20.dp, val iconMedium: Dp = 24.dp, + val iconMediumAvatar: Dp = 26.dp, + val iconLargeAvatar: Dp = 36.dp, val iconLarge: Dp = 46.dp, + val iconXLarge: Dp = 60.dp, + val iconXXLarge: Dp = 90.dp, + val iconXXLargeAvatar: Dp = 190.dp, + + val shapeExtraSmall: Dp = 8.dp, + val shapeSmall: Dp = 12.dp, + val shapeMedium: Dp = 16.dp, + + val maxContentWidth: Dp = 410.dp, + val maxTooltipWidth: Dp = 280.dp, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/SessionTypography.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/SessionTypography.kt index 50c3957cbd..cbfc642992 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/SessionTypography.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/SessionTypography.kt @@ -7,7 +7,6 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp - fun TextStyle.bold() = copy( fontWeight = FontWeight.Bold ) @@ -109,6 +108,12 @@ data class SessionTypography( fontSize = 14.sp, lineHeight = 16.8.sp, fontWeight = FontWeight.Bold + ), + + val sessionNetworkHeading: TextStyle = TextStyle( + fontSize = 15.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.Normal ) ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt index 273f66f79a..e2fd221faa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt @@ -17,14 +17,15 @@ import androidx.compose.ui.tooling.preview.PreviewParameter interface ThemeColors { // properties to override for each theme val isLight: Boolean - val primary: Color - val onInvertedBackgroundPrimary: Color + val accent: Color + val onInvertedBackgroundAccent: Color val textAlert: Color val danger: Color val warning: Color val disabled: Color val background: Color val backgroundSecondary: Color + val backgroundTertiary: Color val text: Color val textSecondary: Color val borders: Color @@ -33,15 +34,15 @@ interface ThemeColors { val textBubbleReceived: Color val qrCodeContent: Color val qrCodeBackground: Color - val primaryButtonFill: Color - val primaryButtonFillText: Color + val accentButtonFillText: Color + val accentText: Color } // extra functions and properties that work for all themes val ThemeColors.textSelectionColors get() = TextSelectionColors( - handleColor = primary, - backgroundColor = primary.copy(alpha = 0.5f) + handleColor = accent, + backgroundColor = accent.copy(alpha = 0.5f) ) fun ThemeColors.text(isError: Boolean): Color = if (isError) danger else text @@ -95,19 +96,29 @@ fun transparentButtonColors() = ButtonDefaults.buttonColors( @Composable fun dangerButtonColors() = ButtonDefaults.buttonColors( containerColor = Color.Transparent, - contentColor = LocalColors.current.danger + contentColor = LocalColors.current.danger, + disabledContainerColor = Color.Transparent, + disabledContentColor = LocalColors.current.disabled ) +@Composable +fun accentTextButtonColors() = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = LocalColors.current.accentText, + disabledContainerColor = Color.Transparent, + disabledContentColor = LocalColors.current.disabled +) // Our themes -data class ClassicDark(override val primary: Color = primaryGreen) : ThemeColors { +data class ClassicDark(override val accent: Color = primaryGreen) : ThemeColors { override val isLight = false override val danger = dangerDark override val warning = primaryOrange override val disabled = disabledDark override val background = classicDark0 override val backgroundSecondary = classicDark1 - override val onInvertedBackgroundPrimary = background + override val backgroundTertiary = classicDark2 + override val onInvertedBackgroundAccent = background override val text = classicDark6 override val textSecondary = classicDark5 override val borders = classicDark3 @@ -116,19 +127,20 @@ data class ClassicDark(override val primary: Color = primaryGreen) : ThemeColors override val textBubbleReceived = Color.White override val qrCodeContent = background override val qrCodeBackground = text - override val primaryButtonFill = primary - override val primaryButtonFillText = Color.Black + override val accentButtonFillText = Color.Black + override val accentText = accent override val textAlert: Color = classicDark0 } -data class ClassicLight(override val primary: Color = primaryGreen) : ThemeColors { +data class ClassicLight(override val accent: Color = primaryGreen) : ThemeColors { override val isLight = true override val danger = dangerLight override val warning = rust override val disabled = disabledLight override val background = classicLight6 override val backgroundSecondary = classicLight5 - override val onInvertedBackgroundPrimary = primary + override val backgroundTertiary = classicLight4 + override val onInvertedBackgroundAccent = accent override val text = classicLight0 override val textSecondary = classicLight1 override val borders = classicLight3 @@ -137,19 +149,20 @@ data class ClassicLight(override val primary: Color = primaryGreen) : ThemeColor override val textBubbleReceived = classicLight4 override val qrCodeContent = text override val qrCodeBackground = backgroundSecondary - override val primaryButtonFill = text - override val primaryButtonFillText = Color.White + override val accentButtonFillText = Color.Black + override val accentText = text override val textAlert: Color = classicLight0 } -data class OceanDark(override val primary: Color = primaryBlue) : ThemeColors { +data class OceanDark(override val accent: Color = primaryBlue) : ThemeColors { override val isLight = false override val danger = dangerDark override val warning = primaryOrange override val disabled = disabledDark override val background = oceanDark2 override val backgroundSecondary = oceanDark1 - override val onInvertedBackgroundPrimary = background + override val backgroundTertiary = oceanDark0 + override val onInvertedBackgroundAccent = background override val text = oceanDark7 override val textSecondary = oceanDark5 override val borders = oceanDark4 @@ -158,19 +171,20 @@ data class OceanDark(override val primary: Color = primaryBlue) : ThemeColors { override val textBubbleReceived = oceanDark4 override val qrCodeContent = background override val qrCodeBackground = text - override val primaryButtonFill = primary - override val primaryButtonFillText = Color.Black + override val accentButtonFillText = Color.Black + override val accentText = accent override val textAlert: Color = oceanDark0 } -data class OceanLight(override val primary: Color = primaryBlue) : ThemeColors { +data class OceanLight(override val accent: Color = primaryBlue) : ThemeColors { override val isLight = true override val danger = dangerLight override val warning = rust override val disabled = disabledLight override val background = oceanLight7 override val backgroundSecondary = oceanLight6 - override val onInvertedBackgroundPrimary = background + override val backgroundTertiary = oceanLight5 + override val onInvertedBackgroundAccent = background override val text = oceanLight1 override val textSecondary = oceanLight2 override val borders = oceanLight3 @@ -179,8 +193,8 @@ data class OceanLight(override val primary: Color = primaryBlue) : ThemeColors { override val textBubbleReceived = oceanLight1 override val qrCodeContent = text override val qrCodeBackground = backgroundSecondary - override val primaryButtonFill = text - override val primaryButtonFillText = Color.White + override val accentButtonFillText = Color.Black + override val accentText = text override val textAlert: Color = oceanLight0 } @@ -195,8 +209,11 @@ fun PreviewThemeColors( @Composable private fun ThemeColors() { Column { - Box(Modifier.background(LocalColors.current.primary)) { - Text("primary", style = LocalType.current.base) + Box(Modifier.background(LocalColors.current.accent)) { + Text("accent", style = LocalType.current.base) + } + Box(Modifier.background(LocalColors.current.accentText)) { + Text("accentText", style = LocalType.current.base) } Box(Modifier.background(LocalColors.current.background)) { Text("background", style = LocalType.current.base) @@ -204,6 +221,9 @@ private fun ThemeColors() { Box(Modifier.background(LocalColors.current.backgroundSecondary)) { Text("backgroundSecondary", style = LocalType.current.base) } + Box(Modifier.background(LocalColors.current.backgroundTertiary)) { + Text("backgroundTertiary", style = LocalType.current.base) + } Box(Modifier.background(LocalColors.current.text)) { Text("text", style = LocalType.current.base) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeFromPreferences.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeFromPreferences.kt index 403235ef79..326c2d8d49 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeFromPreferences.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeFromPreferences.kt @@ -18,8 +18,8 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.YELLOW_A fun TextSecurePreferences.getColorsProvider(): ThemeColorsProvider { val selectedTheme = getThemeStyle() - // get the chosen primary color from the preferences - val selectedPrimary = primaryColor() + // get the chosen accent color from the preferences + val selectedAccent = accentColor() val isOcean = "ocean" in selectedTheme @@ -28,15 +28,15 @@ fun TextSecurePreferences.getColorsProvider(): ThemeColorsProvider { return when { getFollowSystemSettings() -> FollowSystemThemeColorsProvider( - light = createLight(selectedPrimary), - dark = createDark(selectedPrimary) + light = createLight(selectedAccent), + dark = createDark(selectedAccent) ) - "light" in selectedTheme -> ThemeColorsProvider(createLight(selectedPrimary)) - else -> ThemeColorsProvider(createDark(selectedPrimary)) + "light" in selectedTheme -> ThemeColorsProvider(createLight(selectedAccent)) + else -> ThemeColorsProvider(createDark(selectedAccent)) } } -fun TextSecurePreferences.primaryColor(): Color = when(getSelectedAccentColor()) { +fun TextSecurePreferences.accentColor(): Color = when(getSelectedAccentColor()) { BLUE_ACCENT -> primaryBlue PURPLE_ACCENT -> primaryPurple PINK_ACCENT -> primaryPink diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt index 9ef7c23da7..6c1395d1be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt @@ -35,7 +35,7 @@ fun invalidateComposeThemeColors() { @Composable fun SessionMaterialTheme( preferences: TextSecurePreferences = - (LocalContext.current.applicationContext as ApplicationContext).textSecurePreferences, + (LocalContext.current.applicationContext as ApplicationContext).textSecurePreferences.get(), content: @Composable () -> Unit ) { val cachedColors = cachedColorsProvider ?: preferences.getColorsProvider().also { cachedColorsProvider = it } @@ -47,7 +47,7 @@ fun SessionMaterialTheme( } /** - * Apply a given [ThemeColors], and our typography and shapes as a Material 2 Compose Theme. + * Apply a given [ThemeColors], and our typography and shapes as a Material 3 Compose Theme. **/ @Composable fun SessionMaterialTheme( @@ -57,7 +57,7 @@ fun SessionMaterialTheme( MaterialTheme( colorScheme = colors.toMaterialColors(), typography = sessionTypography.asMaterialTypography(), - shapes = sessionShapes, + shapes = sessionShapes(), ) { CompositionLocalProvider( LocalColors provides colors, @@ -72,9 +72,12 @@ fun SessionMaterialTheme( val pillShape = RoundedCornerShape(percent = 50) val buttonShape = pillShape -val sessionShapes = Shapes( - small = RoundedCornerShape(12.dp), - medium = RoundedCornerShape(16.dp) +@Composable +fun sessionShapes() = Shapes( + extraSmall = RoundedCornerShape(LocalDimensions.current.shapeExtraSmall), + small = RoundedCornerShape(LocalDimensions.current.shapeSmall), + medium = RoundedCornerShape(LocalDimensions.current.shapeMedium) + ) /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AnimatedImageUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/AnimatedImageUtils.kt new file mode 100644 index 0000000000..3a1c0776db --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AnimatedImageUtils.kt @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.util + +import android.content.Context +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build + +/** + * A class offering helper methods relating to animated images + */ +object AnimatedImageUtils { + fun isAnimated(context: Context, uri: Uri): Boolean { + val mime = context.contentResolver.getType(uri) + return when (mime) { + "image/gif" -> isAnimatedImage(context, uri) // not all gifs are animated + "image/webp" -> isAnimatedImage(context, uri) // not all WebPs are animated + else -> false + } + } + + private fun isAnimatedImage(context: Context, uri: Uri): Boolean { + if (Build.VERSION.SDK_INT < 28) return isAnimatedImageLegacy(context, uri) + + var animated = false + val source = ImageDecoder.createSource(context.contentResolver, uri) + + ImageDecoder.decodeDrawable(source) { _, info, _ -> + animated = info.isAnimated // true for GIF & animated WebP + } + + return animated + } + + private fun isAnimatedImageLegacy(context: Context, uri: Uri): Boolean { + context.contentResolver.openInputStream(uri)?.use { input -> + val header = ByteArray(32) + if (input.read(header) != header.size) return false + + // Bytes 12-15 contain “VP8X” + val isVp8x = header.sliceArray(12..15).contentEquals("VP8X".toByteArray()) + + if (isVp8x) { + /* 21st byte (index 20) holds the feature flags; bit #1 = animation */ + val animationFlagSet = header[21].toInt() and 0x02 != 0 + if (animationFlagSet) return true // animated! + } + + // Fallback scan for literal “ANIM” in header area + return header.asList().windowed(4).any { it.toByteArray().contentEquals("ANIM".toByteArray()) } + } + return false + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AppVisibilityManager.kt b/app/src/main/java/org/thoughtcrime/securesms/util/AppVisibilityManager.kt index 78016d3ccc..c31aa0bb2e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AppVisibilityManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AppVisibilityManager.kt @@ -3,25 +3,35 @@ package org.thoughtcrime.securesms.util import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import javax.inject.Inject import javax.inject.Singleton @Singleton -class AppVisibilityManager @Inject constructor() { +class AppVisibilityManager @Inject constructor( + @ManagerScope scope: CoroutineScope +) : OnAppStartupComponent { private val mutableIsAppVisible = MutableStateFlow(false) init { - ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { - override fun onStart(owner: LifecycleOwner) { - mutableIsAppVisible.value = true - } + // `addObserver` must be called on the main thread. + scope.launch(Dispatchers.Main) { + ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + mutableIsAppVisible.value = true + } - override fun onStop(owner: LifecycleOwner) { - mutableIsAppVisible.value = false - } - }) + override fun onStop(owner: LifecycleOwner) { + mutableIsAppVisible.value = false + } + }) + } } val isAppVisible: StateFlow get() = mutableIsAppVisible diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java index bda23c0377..b1ae856cf0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java @@ -12,44 +12,12 @@ import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId; import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment; -import org.session.libsession.utilities.ServiceUtil; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import java.util.Collections; -import java.util.Set; - public class AttachmentUtil { private static final String TAG = AttachmentUtil.class.getSimpleName(); - @WorkerThread - public static boolean isAutoDownloadPermitted(@NonNull Context context, @Nullable DatabaseAttachment attachment) { - if (attachment == null) { - Log.w(TAG, "attachment was null, returning vacuous true"); - return true; - } - - if (isFromUnknownContact(context, attachment)) { - return false; - } - - Set allowedTypes = getAllowedAutoDownloadTypes(context); - String contentType = attachment.getContentType(); - - if (attachment.isVoiceNote() || - (MediaUtil.isAudio(attachment) && TextUtils.isEmpty(attachment.getFileName())) || - MediaUtil.isLongTextType(attachment.getContentType())) - { - return true; - } else if (isNonDocumentType(contentType)) { - return allowedTypes.contains(MediaUtil.getDiscreteMimeType(contentType)); - } else { - return allowedTypes.contains("documents"); - } - } - /** * Deletes the specified attachment. If its the only attachment for its linked message, the entire * message is deleted. @@ -71,39 +39,6 @@ public static void deleteAttachment(@NonNull Context context, } } - private static boolean isNonDocumentType(String contentType) { - return - MediaUtil.isImageType(contentType) || - MediaUtil.isVideoType(contentType) || - MediaUtil.isAudioType(contentType); - } - - private static @NonNull Set getAllowedAutoDownloadTypes(@NonNull Context context) { - if (isConnectedWifi(context)) return TextSecurePreferences.getWifiMediaDownloadAllowed(context); - else if (isConnectedRoaming(context)) return TextSecurePreferences.getRoamingMediaDownloadAllowed(context); - else if (isConnectedMobile(context)) return TextSecurePreferences.getMobileMediaDownloadAllowed(context); - else return Collections.emptySet(); - } - - private static NetworkInfo getNetworkInfo(@NonNull Context context) { - return ServiceUtil.getConnectivityManager(context).getActiveNetworkInfo(); - } - - private static boolean isConnectedWifi(@NonNull Context context) { - final NetworkInfo info = getNetworkInfo(context); - return info != null && info.isConnected() && info.getType() == ConnectivityManager.TYPE_WIFI; - } - - private static boolean isConnectedMobile(@NonNull Context context) { - final NetworkInfo info = getNetworkInfo(context); - return info != null && info.isConnected() && info.getType() == ConnectivityManager.TYPE_MOBILE; - } - - private static boolean isConnectedRoaming(@NonNull Context context) { - final NetworkInfo info = getNetworkInfo(context); - return info != null && info.isConnected() && info.isRoaming() && info.getType() == ConnectivityManager.TYPE_MOBILE; - } - @WorkerThread private static boolean isFromUnknownContact(@NonNull Context context, @NonNull DatabaseAttachment attachment) { // We don't allow attachments to be sent unless we're friends with someone or the attachment is sent diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt deleted file mode 100644 index 398bcbcc04..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.thoughtcrime.securesms.util - -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.Attachment -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress -import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment - -private const val ZERO_SIZE = "0.00" -private const val KILO_SIZE = 1024f -private const val MB_SUFFIX = "MB" -private const val KB_SUFFIX = "KB" - -fun Attachment.displaySize(): String { - - val kbSize = size / KILO_SIZE - val needsMb = kbSize > KILO_SIZE - val sizeText = "%.2f".format(if (needsMb) kbSize / KILO_SIZE else kbSize) - val displaySize = when { - sizeText == ZERO_SIZE -> "0.01" - sizeText.endsWith(".00") -> sizeText.takeWhile { it != '.' } - else -> sizeText - } - return "$displaySize${if (needsMb) MB_SUFFIX else KB_SUFFIX}" -} - -fun JobQueue.createAndStartAttachmentDownload(attachment: DatabaseAttachment) { - val attachmentId = attachment.attachmentId.rowId - if (attachment.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING - && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { - // start download - add(AttachmentDownloadJob(attachmentId, attachment.mmsId)) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarPlaceholderGenerator.kt b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarPlaceholderGenerator.kt deleted file mode 100644 index bdccc33b96..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarPlaceholderGenerator.kt +++ /dev/null @@ -1,102 +0,0 @@ -package org.thoughtcrime.securesms.util - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.Rect -import android.graphics.RectF -import android.graphics.Typeface -import android.graphics.drawable.BitmapDrawable -import android.text.TextPaint -import android.text.TextUtils -import network.loki.messenger.R -import org.session.libsignal.utilities.IdPrefix -import java.math.BigInteger -import java.security.MessageDigest -import java.util.Locale - -object AvatarPlaceholderGenerator { - - private const val EMPTY_LABEL = "0" - - @JvmStatic - fun generate(context: Context, pixelSize: Int, hashString: String, displayName: String?): BitmapDrawable { - val hash: Long - if (hashString.length >= 12 && hashString.matches(Regex("^[0-9A-Fa-f]+\$"))) { - hash = getSha512(hashString).substring(0 until 12).toLong(16) - } else { - hash = 0 - } - - // Do not cache color array, it may be different depends on the current theme. - val colorArray = context.resources.getIntArray(R.array.profile_picture_placeholder_colors) - val colorPrimary = colorArray[(hash % colorArray.size).toInt()] - - val labelText = when { - !TextUtils.isEmpty(displayName) -> extractLabel(displayName!!.capitalize(Locale.ROOT)) - !TextUtils.isEmpty(hashString) -> extractLabel(hashString) - else -> EMPTY_LABEL - } - - val bitmap = Bitmap.createBitmap(pixelSize, pixelSize, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bitmap) - - // Draw background/frame - val paint = Paint(Paint.ANTI_ALIAS_FLAG) - paint.color = colorPrimary - canvas.drawCircle(pixelSize.toFloat() / 2, pixelSize.toFloat() / 2, pixelSize.toFloat() / 2, paint) - - // Draw text - val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG) - textPaint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL) - textPaint.textSize = pixelSize * 0.5f - textPaint.color = Color.WHITE - val areaRect = Rect(0, 0, pixelSize, pixelSize) - val textBounds = RectF(areaRect) - textBounds.right = textPaint.measureText(labelText) - textBounds.bottom = textPaint.descent() - textPaint.ascent() - textBounds.left += (areaRect.width() - textBounds.right) * 0.5f - textBounds.top += (areaRect.height() - textBounds.bottom) * 0.5f - canvas.drawText(labelText, textBounds.left, textBounds.top - textPaint.ascent(), textPaint) - - return BitmapDrawable(context.resources, bitmap) - } - - fun extractLabel(content: String): String { - val trimmedContent = content.trim() - if (trimmedContent.isEmpty()) return EMPTY_LABEL - return if (trimmedContent.length > 2 && IdPrefix.fromValue(trimmedContent) != null) { - trimmedContent[2].toString() - } else { - val splitWords = trimmedContent.split(Regex("\\W")) - if (splitWords.size < 2) { - trimmedContent.take(2) - } else { - splitWords.filter { word -> word.isNotEmpty() }.take(2).map { it.first() }.joinToString("") - } - }.uppercase() - } - - private fun getSha512(input: String): String { - val messageDigest = MessageDigest.getInstance("SHA-512").digest(input.toByteArray()) - - // Convert byte array into signum representation - val no = BigInteger(1, messageDigest) - - // Convert message digest into hex value - var hashText: String = no.toString(16) - - // Add preceding 0s to make it 32 bytes - if (hashText.length < 128) { - val sb = StringBuilder() - for (i in 0 until 128 - hashText.length) { - sb.append('0') - } - hashText = sb.append(hashText).toString() - } - - return hashText - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtils.kt new file mode 100644 index 0000000000..28ab6b29d3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtils.kt @@ -0,0 +1,291 @@ +package org.thoughtcrime.securesms.util + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Typeface +import android.graphics.drawable.BitmapDrawable +import android.text.TextPaint +import android.text.TextUtils +import androidx.annotation.DrawableRes +import androidx.compose.ui.graphics.Color +import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.request.RequestOptions +import dagger.Lazy +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import network.loki.messenger.R +import org.session.libsession.avatars.ContactPhoto +import org.session.libsession.avatars.ProfileContactPhoto +import org.session.libsession.database.StorageProtocol +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.UsernameUtils +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.IdPrefix +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.pro.ProStatusManager +import java.math.BigInteger +import java.security.MessageDigest +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AvatarUtils @Inject constructor( + @ApplicationContext private val context: Context, + private val usernameUtils: UsernameUtils, + private val groupDatabase: GroupDatabase, // for legacy groups + private val storage: Lazy, + private val proStatusManager: ProStatusManager, +) { + // Hardcoded possible bg colors for avatar backgrounds + private val avatarBgColors = arrayOf( + ContextCompat.getColor(context, R.color.accent_green), + ContextCompat.getColor(context, R.color.accent_blue), + ContextCompat.getColor(context, R.color.accent_yellow), + ContextCompat.getColor(context, R.color.accent_pink), + ContextCompat.getColor(context, R.color.accent_purple), + ContextCompat.getColor(context, R.color.accent_orange), + ContextCompat.getColor(context, R.color.accent_red), + ) + + suspend fun getUIDataFromAccountId(accountId: String): AvatarUIData = + withContext(Dispatchers.Default) { + getUIDataFromRecipient(Recipient.from(context, Address.fromSerialized(accountId), false)) + } + + suspend fun getUIDataFromRecipient(recipient: Recipient?): AvatarUIData { + if (recipient == null) { + return AvatarUIData(elements = emptyList()) + } + + return withContext(Dispatchers.Default) { + // set up the data based on the conversation type + val elements = mutableListOf() + + // Groups can have a double avatar setup, if they don't have a custom image + if (recipient.isGroupRecipient) { + // if the group has a custom image, use that + // other wise make up a double avatar from the first two members + // if there is only one member then use that member + an unknown icon coloured based on the group id + if (recipient.profileAvatar != null) { + elements.add(getUIElementForRecipient(recipient)) + } else { + val members = if (recipient.isLegacyGroupRecipient) { + groupDatabase.getGroupMemberAddresses( + recipient.address.toGroupString(), + true + ) + } else { + storage.get().getMembers(recipient.address.toString()) + .map { Address.fromSerialized(it.accountId()) } + }.sorted().take(2) + + when (members.size) { + 0 -> elements.add(AvatarUIElement()) + + 1 -> { + // when we only have one member, use that member as one of the two avatar + // and the second should be the unknown icon with a colour based on the group id + elements.add( + getUIElementForRecipient( + Recipient.from( + context, Address.fromSerialized( + members[0].toString() + ), false + ) + ) + ) + + elements.add( + AvatarUIElement( + color = Color(getColorFromKey(recipient.address.toString())) + ) + ) + } + + else -> { + members.forEach { + elements.add( + getUIElementForRecipient( + Recipient.from(context, it, false) + ) + ) + } + } + } + } + } else { + elements.add(getUIElementForRecipient(recipient)) + } + + AvatarUIData( + elements = elements + ) + } + } + + private fun getUIElementForRecipient(recipient: Recipient): AvatarUIElement { + // name + val name = if(recipient.isLocalNumber) usernameUtils.getCurrentUsernameWithAccountIdFallback() + else recipient.name + + val defaultColor = Color(getColorFromKey(recipient.address.toString())) + + // custom image + val (contactPhoto, customIcon, color) = when { + // use custom image if there is one + hasAvatar(recipient.contactPhoto) -> Triple(recipient.contactPhoto, null, defaultColor) + + // communities without a custom image should use a default image + recipient.isCommunityRecipient -> Triple(null, R.drawable.session_logo, null) + else -> Triple(null, null, defaultColor) + } + + return AvatarUIElement( + name = extractLabel(name), + color = color, + icon = customIcon, + contactPhoto = contactPhoto, + freezeFrame = proStatusManager.freezeFrameForUser(recipient.address) + ) + } + + private fun hasAvatar(contactPhoto: ContactPhoto?): Boolean { + val avatar = (contactPhoto as? ProfileContactPhoto)?.avatarObject + return contactPhoto != null && avatar != "0" && avatar != "" + } + + fun getColorFromKey(hashString: String): Int { + val hash: Long + if (hashString.length >= 12 && hashString.matches(Regex("^[0-9A-Fa-f]+\$"))) { + hash = getSha512(hashString).substring(0 until 12).toLong(16) + } else { + hash = 0 + } + + return avatarBgColors[(hash % avatarBgColors.size).toInt()] + } + + fun generateTextBitmap(pixelSize: Int, hashString: String, displayName: String?): BitmapDrawable { + val colorPrimary = getColorFromKey(hashString) + + val labelText = when { + !TextUtils.isEmpty(displayName) -> extractLabel(displayName!!.capitalize(Locale.ROOT)) + !TextUtils.isEmpty(hashString) -> extractLabel(hashString) + else -> EMPTY_LABEL + } + + val bitmap = createBitmap(pixelSize, pixelSize) + val canvas = Canvas(bitmap) + + // Draw background/frame + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint.color = colorPrimary + canvas.drawCircle(pixelSize.toFloat() / 2, pixelSize.toFloat() / 2, pixelSize.toFloat() / 2, paint) + + // Draw text + val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG) + textPaint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL) + textPaint.textSize = pixelSize * 0.5f + textPaint.color = android.graphics.Color.WHITE + val areaRect = Rect(0, 0, pixelSize, pixelSize) + val textBounds = RectF(areaRect) + textBounds.right = textPaint.measureText(labelText) + textBounds.bottom = textPaint.descent() - textPaint.ascent() + textBounds.left += (areaRect.width() - textBounds.right) * 0.5f + textBounds.top += (areaRect.height() - textBounds.bottom) * 0.5f + canvas.drawText(labelText, textBounds.left, textBounds.top - textPaint.ascent(), textPaint) + + return BitmapDrawable(context.resources, bitmap) + } + + private fun getSha512(input: String): String { + val messageDigest = MessageDigest.getInstance("SHA-512").digest(input.toByteArray()) + + // Convert byte array into signum representation + val no = BigInteger(1, messageDigest) + + // Convert message digest into hex value + var hashText: String = no.toString(16) + + // Add preceding 0s to make it 32 bytes + if (hashText.length < 128) { + val sb = StringBuilder() + for (i in 0 until 128 - hashText.length) { + sb.append('0') + } + hashText = sb.append(hashText).toString() + } + + return hashText + } + + companion object { + private val EMPTY_LABEL = "0" + + fun extractLabel(content: String): String { + val trimmedContent = content.trim() + if (trimmedContent.isEmpty()) return EMPTY_LABEL + return if (trimmedContent.length > 2 && IdPrefix.fromValue(trimmedContent) != null) { + trimmedContent[2].toString() + } else { + val splitWords = trimmedContent.split(Regex("\\W")) + if (splitWords.size < 2) { + trimmedContent.take(2) + } else { + splitWords.filter { word -> word.isNotEmpty() }.take(2).map { it.first() }.joinToString("") + } + }.uppercase() + } + } +} + +data class AvatarUIData( + val elements: List, +){ + /** + * Helper function to determine if an avatar is composed of a single element, which is + * a custom photo. + * This is used for example to know when to display a fullscreen avatar on tap + */ + fun isSingleCustomAvatar() = elements.size == 1 && elements[0].contactPhoto != null +} + +data class AvatarUIElement( + val name: String? = null, + val color: Color? = null, + @DrawableRes val icon: Int? = null, + val contactPhoto: ContactPhoto? = null, + val freezeFrame: Boolean = true +) + +sealed class AvatarBadge(@DrawableRes val icon: Int){ + data object None: AvatarBadge(0) + data object Admin: AvatarBadge(R.drawable.ic_crown_custom_enlarged) + data class Custom(@DrawableRes val iconRes: Int): AvatarBadge(iconRes) +} + +// Helper function for our common avatar Glide options +fun RequestBuilder.avatarOptions( + sizePx: Int, + freezeFrame: Boolean +): RequestBuilder = this.override(sizePx) + .dontTransform() + .diskCacheStrategy(DiskCacheStrategy.NONE) + .optionalTransform(CenterCrop()) + .run { + if(freezeFrame){ + this.dontAnimate() + .apply(RequestOptions.decodeTypeOf(Bitmap::class.java)) + } else this + } + diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java index 93b21512ea..5e1a85df51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java @@ -118,7 +118,9 @@ private static ScaleResult createScaledBytes(@NonNull Context context, do { totalAttempts++; ByteArrayOutputStream baos = new ByteArrayOutputStream(); - scaledBitmap.compress(format, quality, baos); + if (!scaledBitmap.compress(format, quality, baos)) { + Log.d(TAG, "Unable to compress image with quality " + quality); + } bytes = baos.toByteArray(); Log.d(TAG, "iteration with quality " + quality + " size " + bytes.length + " bytes."); @@ -144,7 +146,7 @@ private static ScaleResult createScaledBytes(@NonNull Context context, } } - if (bytes.length <= 0) { + if (bytes.length == 0) { throw new BitmapDecodingException("Decoding failed. Bitmap has a length of " + bytes.length + " bytes."); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt deleted file mode 100644 index 30d28ad6cb..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt +++ /dev/null @@ -1,137 +0,0 @@ -package org.thoughtcrime.securesms.util - -import android.app.Notification -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.Intent.FLAG_ACTIVITY_NEW_TASK -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import com.squareup.phrase.Phrase -import network.loki.messenger.R -import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.calls.WebRtcCallActivity -import org.thoughtcrime.securesms.notifications.NotificationChannels -import org.thoughtcrime.securesms.preferences.SettingsActivity -import org.thoughtcrime.securesms.service.WebRtcCallService -import org.thoughtcrime.securesms.ui.getSubbedCharSequence -import org.thoughtcrime.securesms.ui.getSubbedString - -class CallNotificationBuilder { - - companion object { - const val WEBRTC_NOTIFICATION = 313388 - - const val TYPE_INCOMING_RINGING = 1 - const val TYPE_OUTGOING_RINGING = 2 - const val TYPE_ESTABLISHED = 3 - const val TYPE_INCOMING_CONNECTING = 4 - const val TYPE_INCOMING_PRE_OFFER = 5 - - @JvmStatic - fun areNotificationsEnabled(context: Context): Boolean { - val notificationManager = NotificationManagerCompat.from(context) - return notificationManager.areNotificationsEnabled() - } - - @JvmStatic - fun getCallInProgressNotification(context: Context, type: Int, recipient: Recipient?): Notification { - val contentIntent = Intent(context, WebRtcCallActivity::class.java) - .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) - - val pendingIntent = PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - - val builder = NotificationCompat.Builder(context, NotificationChannels.CALLS) - .setSound(null) - .setSmallIcon(R.drawable.ic_baseline_call_24) - .setContentIntent(pendingIntent) - .setOngoing(true) - - var recipName = "Unknown" - recipient?.name?.let { name -> - builder.setContentTitle(name) - recipName = name - } - - when (type) { - TYPE_INCOMING_CONNECTING -> { - builder.setContentText(context.getString(R.string.callsConnecting)) - .setNotificationSilent() - } - TYPE_INCOMING_PRE_OFFER, - TYPE_INCOMING_RINGING -> { - val txt = Phrase.from(context, R.string.callsIncoming).put(NAME_KEY, recipName).format() - builder.setContentText(txt) - .setCategory(NotificationCompat.CATEGORY_CALL) - builder.addAction(getServiceNotificationAction( - context, - WebRtcCallService.ACTION_DENY_CALL, - R.drawable.ic_close_grey600_32dp, - R.string.decline - )) - // If notifications aren't enabled, we will trigger the intent from WebRtcCallService - builder.setFullScreenIntent(getFullScreenPendingIntent(context), true) - builder.addAction(getActivityNotificationAction( - context, - if (type == TYPE_INCOMING_PRE_OFFER) WebRtcCallActivity.ACTION_PRE_OFFER else WebRtcCallActivity.ACTION_ANSWER, - R.drawable.ic_phone_grey600_32dp, - R.string.accept - )) - builder.priority = NotificationCompat.PRIORITY_MAX - } - TYPE_OUTGOING_RINGING -> { - builder.setContentText(context.getString(R.string.callsConnecting)) - builder.addAction(getServiceNotificationAction( - context, - WebRtcCallService.ACTION_LOCAL_HANGUP, - R.drawable.ic_call_end_grey600_32dp, - R.string.cancel - )) - } - else -> { - builder.setContentText(context.getString(R.string.callsInProgress)) - builder.addAction(getServiceNotificationAction( - context, - WebRtcCallService.ACTION_LOCAL_HANGUP, - R.drawable.ic_call_end_grey600_32dp, - R.string.callsEnd - )).setUsesChronometer(true) - } - } - - return builder.build() - } - - private fun getServiceNotificationAction(context: Context, action: String, iconResId: Int, titleResId: Int): NotificationCompat.Action { - val intent = Intent(context, WebRtcCallService::class.java) - .setAction(action) - - val pendingIntent = PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE) - - return NotificationCompat.Action(iconResId, context.getString(titleResId), pendingIntent) - } - - private fun getFullScreenPendingIntent(context: Context): PendingIntent { - val intent = Intent(context, WebRtcCallActivity::class.java) - // When launching the call activity do NOT keep it in the history when finished, as it does not pass through CALL_DISCONNECTED - // if the call was denied outright, and without this the "dead" activity will sit around in the history when the device is unlocked. - .setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NO_HISTORY or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) - .setAction(WebRtcCallActivity.ACTION_FULL_SCREEN_INTENT) - return PendingIntent.getActivity(context, 1, intent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE) - } - - private fun getActivityNotificationAction(context: Context, action: String, - @DrawableRes iconResId: Int, @StringRes titleResId: Int): NotificationCompat.Action { - val intent = Intent(context, WebRtcCallActivity::class.java) - .setAction(action) - - val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE) - - return NotificationCompat.Action(iconResId, context.getString(titleResId), pendingIntent) - } - - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CharacterCalculator.java b/app/src/main/java/org/thoughtcrime/securesms/util/CharacterCalculator.java deleted file mode 100644 index 08080ffc3f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CharacterCalculator.java +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Copyright (C) 2015 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 . - */ -package org.thoughtcrime.securesms.util; - -import android.os.Parcel; -import androidx.annotation.NonNull; - -public abstract class CharacterCalculator { - - public abstract CharacterState calculateCharacters(String messageBody); - - public static void writeToParcel(@NonNull Parcel dest, @NonNull CharacterCalculator calculator) { - if (calculator instanceof SmsCharacterCalculator) { - dest.writeInt(1); - } else if (calculator instanceof MmsCharacterCalculator) { - dest.writeInt(2); - } else if (calculator instanceof PushCharacterCalculator) { - dest.writeInt(3); - } else { - throw new IllegalArgumentException("Tried to write an unsupported calculator to a parcel."); - } - } - - public static class CharacterState { - public final int charactersRemaining; - public final int messagesSpent; - public final int maxTotalMessageSize; - public final int maxPrimaryMessageSize; - - public CharacterState(int messagesSpent, int charactersRemaining, int maxTotalMessageSize, int maxPrimaryMessageSize) { - this.messagesSpent = messagesSpent; - this.charactersRemaining = charactersRemaining; - this.maxTotalMessageSize = maxTotalMessageSize; - this.maxPrimaryMessageSize = maxPrimaryMessageSize; - } - } -} - diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt index fb582a1089..47a0a166fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.util import android.annotation.SuppressLint import android.app.Application import android.content.Intent -import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -15,43 +14,87 @@ import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.home.HomeActivity import javax.inject.Inject import androidx.core.content.edit -import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.CoroutineDispatcher +import okio.ByteString.Companion.decodeHex +import org.session.libsession.messaging.notifications.TokenFetcher +import org.session.libsignal.utilities.hexEncodedPublicKey +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil +import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.logging.PersistentLogger +import org.thoughtcrime.securesms.migration.DatabaseMigrationManager class ClearDataUtils @Inject constructor( private val application: Application, private val configFactory: ConfigFactory, + private val tokenFetcher: TokenFetcher, + private val storage: Storage, + private val prefs: TextSecurePreferences, + private val persistentLogger: PersistentLogger, ) { // Method to clear the local data - returns true on success otherwise false @SuppressLint("ApplySharedPref") - suspend fun clearAllData() { - return withContext(Dispatchers.Default) { - // Should not proceed if we can't delete db - check(application.deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) { + suspend fun clearAllData(dispatcher: CoroutineDispatcher = Dispatchers.Default) { + return withContext(dispatcher) { + // Should not proceed if there's a db but we can't delete it + check( + !application.getDatabasePath(SQLCipherOpenHelper.DATABASE_NAME).exists() || + application.deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME) + ) { "Failed to delete database" } + // Also delete the other legacy databases but don't care about the result + application.deleteDatabase(DatabaseMigrationManager.CIPHER4_DB_NAME) + application.deleteDatabase(DatabaseMigrationManager.CIPHER3_DB_NAME) + TextSecurePreferences.clearAll(application) application.getSharedPreferences(ApplicationContext.PREFERENCES_NAME, 0).edit(commit = true) { clear() } configFactory.clearAll() + persistentLogger.deleteAllLogs() + // The token deletion is nice but not critical, so don't let it block the rest of the process runCatching { - FirebaseMessaging.getInstance().deleteToken().await() + tokenFetcher.resetToken() }.onFailure { e -> - Log.w("ClearDataUtils", "Failed to delete Firebase token: ${e.message}", e) + Log.w("ClearDataUtils", "Failed to reset push notification token: ${e.message}", e) } } } + suspend fun clearAllDataWithoutLoggingOutAndRestart(dispatcher: CoroutineDispatcher = Dispatchers.Default) { + withContext(dispatcher) { + val keyPair = storage.getUserED25519KeyPair() + if (keyPair != null) { + val x25519KeyPair = storage.getUserX25519KeyPair() + val seed = + IdentityKeyUtil.retrieve(application, IdentityKeyUtil.LOKI_SEED).decodeHex() + .toByteArray() + + clearAllData(Dispatchers.Unconfined) + KeyPairUtilities.store(application, seed, keyPair, x25519KeyPair) + prefs.setLocalNumber(x25519KeyPair.hexEncodedPublicKey) + } else { + clearAllData(Dispatchers.Unconfined) + } + + delay(200) + restartApplication() + } + } + /** * Clear all local profile data and message history then restart the app after a brief delay. * @return true on success, false otherwise. */ @SuppressLint("ApplySharedPref") - suspend fun clearAllDataAndRestart() { - clearAllData() - delay(200) - restartApplication() + suspend fun clearAllDataAndRestart(dispatcher: CoroutineDispatcher = Dispatchers.Default) { + withContext(dispatcher) { + clearAllData(Dispatchers.Unconfined) + delay(200) + restartApplication() + } } fun restartApplication() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java deleted file mode 100644 index 0d900ea391..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.thoughtcrime.securesms.util; - -import android.content.Context; -import android.content.Intent; -import android.os.AsyncTask; -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.dependencies.DatabaseComponent; - -public class CommunicationActions { - - public static void startConversation(@NonNull Context context, - @NonNull Recipient recipient, - @Nullable String text, - @Nullable TaskStackBuilder backStack) - { - new AsyncTask() { - @Override - protected Long doInBackground(Void... voids) { - return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient); - } - - @Override - protected void onPostExecute(Long threadId) { - Intent intent = new Intent(context, ConversationActivityV2.class); - intent.putExtra(ConversationActivityV2.ADDRESS, recipient.getAddress()); - intent.putExtra(ConversationActivityV2.THREAD_ID, threadId); - - if (backStack != null) { - backStack.addNextIntent(intent); - backStack.startActivities(); - } else { - context.startActivity(intent); - } - } - }.execute(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ContactUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ContactUtilities.kt deleted file mode 100644 index a5822b585f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ContactUtilities.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.thoughtcrime.securesms.util - -import android.content.Context -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.dependencies.DatabaseComponent - -object ContactUtilities { - - @JvmStatic - fun getAllContacts(context: Context): Set { - val threadDatabase = DatabaseComponent.get(context).threadDatabase() - val cursor = threadDatabase.conversationList - val result = mutableSetOf() - threadDatabase.readerFor(cursor).use { reader -> - while (reader.next != null) { - val thread = reader.current - val recipient = thread.recipient - result.add(recipient) - } - } - return result - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CurrentActivityObserver.kt b/app/src/main/java/org/thoughtcrime/securesms/util/CurrentActivityObserver.kt new file mode 100644 index 0000000000..3408057bd7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CurrentActivityObserver.kt @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.util + +import android.app.Activity +import android.app.Application +import android.app.Application.ActivityLifecycleCallbacks +import android.os.Bundle +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import javax.inject.Inject +import javax.inject.Singleton + +/** + * An observer to record currently started activity in the app. + */ +@Singleton +class CurrentActivityObserver @Inject constructor( + application: Application +) : OnAppStartupComponent { + private val _currentActivity = MutableStateFlow(null) + + val currentActivity: StateFlow get() = _currentActivity + + init { + application.registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + override fun onActivityStarted(activity: Activity) { + _currentActivity.value = activity + Log.d("CurrentActivityObserver", "Current activity set to: ${activity.javaClass.simpleName}") + } + override fun onActivityResumed(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivityStopped(activity: Activity) { + if (_currentActivity.value === activity) { + _currentActivity.value = null + Log.d("CurrentActivityObserver", "Current activity set to null") + } + } + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} + }) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt index a67bc7718b..709af67dc9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt @@ -1,142 +1,223 @@ -/* - * 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 . - */ package org.thoughtcrime.securesms.util import android.content.Context import android.text.format.DateFormat +import android.text.format.DateUtils as AndroidxDateUtils +import dagger.hilt.android.qualifiers.ApplicationContext import java.text.SimpleDateFormat +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId import java.time.format.DateTimeFormatter -import java.util.Calendar -import java.util.Date -import java.util.Locale +import java.util.* import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import network.loki.messenger.R +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences.Companion.DATE_FORMAT_PREF +import org.session.libsession.utilities.TextSecurePreferences.Companion.TIME_FORMAT_PREF +import org.session.libsignal.utilities.Log -// Enums used to get the locale-aware String for one of the three relative days enum class RelativeDay { TODAY, YESTERDAY, TOMORROW } /** - * Utility methods to help display dates in a nice, easily readable way. + * Utility methods to help display dates in a user-friendly way. */ -object DateUtils : android.text.format.DateUtils() { - - @Suppress("unused") - private val TAG: String = DateUtils::class.java.simpleName - private val DAY_PRECISION_DATE_FORMAT = SimpleDateFormat("yyyyMMdd") - private val HOUR_PRECISION_DATE_FORMAT = SimpleDateFormat("yyyyMMddHH") - - private fun isWithin(millis: Long, span: Long, unit: TimeUnit): Boolean { - return System.currentTimeMillis() - millis <= unit.toMillis(span) +@Singleton +class DateUtils @Inject constructor( + @ApplicationContext private val context: Context, + private val textSecurePreferences: TextSecurePreferences +) { + private val tag = "DateUtils" + + // Default formats + private val defaultDateFormat = "dd/MM/yyyy" + private val defaultTimeFormat = "HH:mm" + private val twelveHourFormat = "h:mm a" + private val defaultDateTimeFormat = "d MMM YYYY hh:mm a" + + private val messageDateTimeFormat = "h:mm a EEE, MM/dd/yyyy" + + // System defaults and patterns + private val systemDefaultPattern by lazy { + DateFormat.getBestDateTimePattern(Locale.getDefault(), "yyyyMMdd") } - private fun isYesterday(`when`: Long): Boolean { - return isToday(`when` + TimeUnit.DAYS.toMillis(1)) + private val validDatePatterns by lazy { + listOf( + systemDefaultPattern, + "M/d/yy", "d/M/yy", "dd/MM/yyyy", "dd.MM.yyyy", + "dd-MM-yyyy", "yyyy/M/d", "yyyy.M.d", "yyyy-M-d" + ) } + private val validTimePatterns = listOf("HH:mm", "h:mm") - // Method to get the String for a relative day in a locale-aware fashion - public fun getLocalisedRelativeDayString(relativeDay: RelativeDay): String { + // User preferences with property accessors + private var userDateFormat: String + get() = textSecurePreferences.getStringPreference(DATE_FORMAT_PREF, defaultDateFormat)!! + private set(value) { + textSecurePreferences.setStringPreference(DATE_FORMAT_PREF, value) + } - val now = Calendar.getInstance() + // The user time format is the one chosen by the user,if they chose one from the ui (not yet available but coming later) + // Or we check for the system preference setting for 12 vs 24h format + private var userTimeFormat: String + get() = textSecurePreferences.getStringPreference( + TIME_FORMAT_PREF, + if (DateFormat.is24HourFormat(context)) defaultTimeFormat else twelveHourFormat + )!! + private set(value) { + textSecurePreferences.setStringPreference(TIME_FORMAT_PREF, value) + } - // To compare a time to 'now' we need to use get a date relative it, so plus or minus a day, or not - val dayAddition = when (relativeDay) { - RelativeDay.TOMORROW -> { 1 } - RelativeDay.YESTERDAY -> { -1 } - else -> 0 // Today + // Public getters + fun getDateFormat(): String = userDateFormat + fun getTimeFormat(): String = userTimeFormat + + // TODO: This is presently unused but it WILL be used when we tie the ability to choose a date format into the UI for SES-360 + fun getUiPrintableDatePatterns(): List = + validDatePatterns.mapIndexed { index, pattern -> + if (index == 0) "$pattern (${context.getString(R.string.theDefault)})" else pattern } - val comparisonTime = Calendar.getInstance().apply { - add(Calendar.DAY_OF_YEAR, dayAddition) - set(Calendar.HOUR_OF_DAY, 0) - set(Calendar.MINUTE, 0) - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) + // TODO: This is presently unused but it WILL be used when we tie the ability to choose a time format into the UI for SES-360 + fun getUiPrintableTimePatterns(): List = + validTimePatterns.mapIndexed { index, pattern -> + if (index == 0) "$pattern (${context.getString(R.string.theDefault)})" else pattern } - val temp = getRelativeTimeSpanString( - comparisonTime.timeInMillis, - now.timeInMillis, - DAY_IN_MILLIS, - FORMAT_SHOW_DATE).toString() - return temp - } + // Method to get the String for a relative day in a locale-aware fashion + fun getLocalisedRelativeDayString(relativeDay: RelativeDay): String { + val now = System.currentTimeMillis() + + // To compare a time to 'now' we need to get a date relative to it, so plus or minus a day, or not + val offset = when (relativeDay) { + RelativeDay.TOMORROW -> 1 + RelativeDay.YESTERDAY -> -1 + else -> 0 // Today + } + + val comparisonTime = now + TimeUnit.DAYS.toMillis(offset.toLong()) - fun getFormattedDateTime(time: Long, template: String, locale: Locale): String { - val localizedPattern = getLocalizedPattern(template, locale) - return SimpleDateFormat(localizedPattern, locale).format(Date(time)) + return AndroidxDateUtils.getRelativeTimeSpanString( + comparisonTime, + now, + AndroidxDateUtils.DAY_IN_MILLIS, + AndroidxDateUtils.FORMAT_SHOW_DATE + ).toString() } - fun getHourFormat(c: Context?): String { - return if ((DateFormat.is24HourFormat(c))) "HH:mm" else "hh:mm a" + // Format a given timestamp with a specific pattern + fun formatTime(timestamp: Long, pattern: String, locale: Locale = Locale.getDefault()): String { + val formatter = DateTimeFormatter.ofPattern(pattern, locale) + + return Instant.ofEpochMilli(timestamp) + .atZone(ZoneId.systemDefault()) + .format(formatter) } - fun getDisplayFormattedTimeSpanString(c: Context, locale: Locale, timestamp: Long): String { - // If the timestamp is within the last 24 hours we just give the time, e.g, "1:23 PM" or - // "13:23" depending on 12/24 hour formatting. - return if (isToday(timestamp)) { - getFormattedDateTime(timestamp, getHourFormat(c), locale) - } else if (isWithin(timestamp, 6, TimeUnit.DAYS)) { - getFormattedDateTime(timestamp, "EEE " + getHourFormat(c), locale) - } else if (isWithin(timestamp, 365, TimeUnit.DAYS)) { - getFormattedDateTime(timestamp, "MMM d " + getHourFormat(c), locale) + fun getLocaleFormattedDateTime(timestamp: Long): String = + formatTime(timestamp, defaultDateTimeFormat) + + // Method to get a date in a locale-aware fashion or with a specific pattern + fun getLocaleFormattedDate(timestamp: Long, specificPattern: String = ""): String = + formatTime(timestamp, specificPattern.takeIf { it.isNotEmpty() } ?: userDateFormat) + + // Method to get a time in a locale-aware fashion (i.e., 13:25 or 1:25 PM) + fun getLocaleFormattedTime(timestamp: Long): String = + formatTime(timestamp, userTimeFormat) + + // Method to get a time in a forced 12-hour format (e.g., 1:25 PM rather than 13:25) + fun getLocaleFormattedTwelveHourTime(timestamp: Long): String = + formatTime(timestamp, twelveHourFormat) + + // TODO: While currently unused, this will be tied into the UI when the user can adjust their preferred date format + fun updatePreferredDateFormat(dateFormatPattern: String) { + userDateFormat = if (dateFormatPattern in validDatePatterns) { + dateFormatPattern } else { - getFormattedDateTime(timestamp, "MMM d " + getHourFormat(c) + ", yyyy", locale) + Log.w(tag, "Asked to set invalid date format pattern: $dateFormatPattern - using default instead.") + defaultDateFormat } } - fun getDetailedDateFormatter(context: Context?, locale: Locale): SimpleDateFormat { - val dateFormatPattern = if (DateFormat.is24HourFormat(context)) { - getLocalizedPattern("MMM d, yyyy HH:mm:ss zzz", locale) + // TODO: While currently unused, this will be tied into the UI when the user can adjust their preferred time format + fun updatePreferredTimeFormat(timeFormatPattern: String) { + userTimeFormat = if (timeFormatPattern in validTimePatterns) { + timeFormatPattern } else { - getLocalizedPattern("MMM d, yyyy hh:mm:ss a zzz", locale) + Log.w(tag, "Asked to set invalid time format pattern: $timeFormatPattern - using default instead.") + defaultTimeFormat } - - return SimpleDateFormat(dateFormatPattern, locale) } - fun getMediumDateTimeFormatter(): DateTimeFormatter { - return DateTimeFormatter.ofPattern("h:mm a, d MMM yyyy") - } + // Note: Date patterns are in TR-35 format. + // See: https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Format_Patterns + fun getDisplayFormattedTimeSpanString(timestamp: Long, locale: Locale = Locale.getDefault()): String = + when { + // If it's within the last 24 hours we just give the time in 24-hour format, such as "13:27" for 1:27pm + isToday(timestamp) -> formatTime(timestamp, userTimeFormat, locale) + + // If it's within the last week we give the day as 3 letters then the time in 24-hour format, such as "Fri 13:27" for Friday 1:27pm + isWithinDays(timestamp, 7) -> formatTime(timestamp, "EEE $userTimeFormat", locale) + + // If it's within the last year we give the month as 3 letters then the time in 24-hour format, such as "Mar 13:27" for March 1:27pm + // CAREFUL: "MMM d + getHourFormat(c)" actually turns out to be "8 July, 17:14" etc. - it is DAY-NUMBER and then MONTH (which can go up to 4 chars) - and THEN the time. Wild. + isWithinDays(timestamp, 365) -> formatTime(timestamp, "MMM d $userTimeFormat", locale) + + // NOTE: The `userDateFormat` is ONLY ever used on dates which exceed one year! + // See the Figma linked in ticket SES-360 for details. + else -> formatTime(timestamp, userDateFormat, locale) + } + + fun getMediumDateTimeFormatter(): DateTimeFormatter = + DateTimeFormatter.ofPattern(defaultDateTimeFormat) + + fun getMessageDateTimeFormattedString(timestamp: Long): String = getLocaleFormattedDate(timestamp, messageDateTimeFormat) // Method to get the String for a relative day in a locale-aware fashion, including using the // auto-localised words for "today" and "yesterday" as appropriate. - fun getRelativeDate( - context: Context, - locale: Locale, - timestamp: Long - ): String { - return if (isToday(timestamp)) { - getLocalisedRelativeDayString(RelativeDay.TODAY) - } else if (isYesterday(timestamp)) { - getLocalisedRelativeDayString(RelativeDay.YESTERDAY) - } else { - getFormattedDateTime(timestamp, "EEE, MMM d, yyyy", locale) + fun getRelativeDate(locale: Locale, timestamp: Long): String = + when { + isToday(timestamp) -> getLocalisedRelativeDayString(RelativeDay.TODAY) + isYesterday(timestamp) -> getLocalisedRelativeDayString(RelativeDay.YESTERDAY) + else -> formatTime(timestamp, userDateFormat, locale) } - } fun isSameDay(t1: Long, t2: Long): Boolean { - return DAY_PRECISION_DATE_FORMAT.format(Date(t1)) == DAY_PRECISION_DATE_FORMAT.format(Date(t2)) + val date1 = toLocalDate(t1) + val date2 = toLocalDate(t2) + return date1 == date2 } fun isSameHour(t1: Long, t2: Long): Boolean { - return HOUR_PRECISION_DATE_FORMAT.format(Date(t1)) == HOUR_PRECISION_DATE_FORMAT.format(Date(t2)) + val date1 = toLocalDateTime(t1) + val date2 = toLocalDateTime(t2) + return date1.year == date2.year && + date1.month == date2.month && + date1.dayOfMonth == date2.dayOfMonth && + date1.hour == date2.hour } - private fun getLocalizedPattern(template: String, locale: Locale): String { - return DateFormat.getBestDateTimePattern(locale, template) - } + // Helper methods + private fun toLocalDate(timestamp: Long): LocalDate = + Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toLocalDate() + + private fun toLocalDateTime(timestamp: Long): LocalDateTime = + Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toLocalDateTime() + + private fun isToday(timestamp: Long): Boolean = + toLocalDate(timestamp) == LocalDate.now() + + private fun isYesterday(timestamp: Long): Boolean = + toLocalDate(timestamp) == LocalDate.now().minusDays(1) + + private fun isWithinDays(timestamp: Long, days: Long): Boolean = + System.currentTimeMillis() - timestamp <= TimeUnit.DAYS.toMillis(days) + + private fun getLocalizedPattern(template: String, locale: Locale): String = + DateFormat.getBestDateTimePattern(locale, template) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FileProviderUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/FileProviderUtil.java index c05832352a..34a4eb2093 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FileProviderUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FileProviderUtil.java @@ -1,18 +1,19 @@ package org.thoughtcrime.securesms.util; - import android.content.Context; import android.net.Uri; -import android.os.Build; import androidx.annotation.NonNull; import androidx.core.content.FileProvider; import java.io.File; +import network.loki.messenger.BuildConfig; + public class FileProviderUtil { - private static final String AUTHORITY = "network.loki.securesms.fileprovider"; + public static final String AUTHORITY = "network.loki.securesms.fileprovider" + BuildConfig.AUTHORITY_POSTFIX; + @NonNull public static Uri getUriFor(@NonNull Context context, @NonNull File file) { return FileProvider.getUriForFile(context, AUTHORITY, file); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FilenameUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/FilenameUtils.kt new file mode 100644 index 0000000000..9476844413 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FilenameUtils.kt @@ -0,0 +1,161 @@ +package org.thoughtcrime.securesms.util + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import java.text.SimpleDateFormat +import java.util.Locale +import network.loki.messenger.R +import org.session.libsession.messaging.sending_receiving.attachments.Attachment +import org.session.libsignal.utilities.Log + +object FilenameUtils { + private const val TAG = "FilenameUtils" + + private fun getFormattedDate(timestamp: Long? = null): String { + val dateFormatter = SimpleDateFormat("yyyy-MM-dd-HHmmss", Locale.getDefault()) + return dateFormatter.format( timestamp ?: System.currentTimeMillis() ) + } + + // Filename for when we take a photo from within Session + @JvmStatic + fun constructPhotoFilename(context: Context): String = "${context.getString(R.string.app_name)}-Photo-${getFormattedDate()}.jpg" + + // Filename for when we create a new voice message + @JvmStatic + fun constructNewVoiceMessageFilename(context: Context): String = context.getString(R.string.app_name) + "-" + context.getString(R.string.messageVoice).replace(" ", "") + "_${getFormattedDate()}" + ".mp4" + + // Method to synthesize a suitable filename for a voice message that we have been sent. + // Note: If we have a file as an attachment then it has a `isVoiceNote` property which + @JvmStatic + fun constructAudioMessageFilenameFromAttachment(context: Context, attachment: Attachment): String { + // Try and get the file extension, e.g., from "audio/aac" extract the "aac" part etc. + val fileExtensionSegments = attachment.contentType.split("/") + val fileExtension = if (fileExtensionSegments.size == 2) fileExtensionSegments[1] else "" + + // We SHOULD always have a uri path - but it's not guaranteed + val uriPath = attachment.dataUri?.path + + val timestamp = if (uriPath.isNullOrEmpty()) System.currentTimeMillis() else getTimestampFromUri(uriPath) + + // Return the filename using either the "VoiceMessage" or "Audio" string depending on the attachment type + val appNameString = context.getString(R.string.app_name) + val audioTypeString = if (attachment.isVoiceNote) context.getString(R.string.messageVoice).replace(" ", "") else context.getString(R.string.audio) + return "$appNameString-${audioTypeString}_${getFormattedDate(timestamp)}.$fileExtension" + } + + // As all picked media now has a mandatory filename this method should never get called - but it's here as a last line of defence + @JvmStatic + fun constructFallbackMediaFilenameFromMimeType( + context: Context, + mimeType: String?, + timestamp: Long? + ): String { + // If we couldn't extract a timestamp from a Uri then the best we can do is use now. + // Note: Once a file is created with this timestamp it is maintained with that timestamp so + // we do not have issues such as saving the file multiple times resulting in multiple filenames + // where each file uses the "now" timestamp it was saved at (although multiple files will + // have -1, -2, -3 etc. suffixes to prevent overwriting any file). + val guaranteedTimestamp = timestamp ?: System.currentTimeMillis() + val formattedDate = "_${getFormattedDate(guaranteedTimestamp)}" + val fileExtension = mimeType?.split("/")?.get(1) ?: "" + + return if (MediaUtil.isVideoType(mimeType)) { + "${context.getString(R.string.app_name)}-${context.getString(R.string.video)}$formattedDate.$fileExtension" // Session-Video_ + } else if (MediaUtil.isGif(mimeType)) { + "${context.getString(R.string.app_name)}-${context.getString(R.string.gif)}$formattedDate.$fileExtension" // Session-GIF_ + } else if (MediaUtil.isImageType(mimeType)) { + "${context.getString(R.string.app_name)}-${context.getString(R.string.image)}$formattedDate.$fileExtension" // Session-Image_ + } else if (MediaUtil.isAudioType(mimeType)) { + "${context.getString(R.string.app_name)}-${context.getString(R.string.audio)}$formattedDate.$fileExtension" // Session-Audio_ + } + else { + Log.i(TAG, "Asked to construct a filename for an unsupported media type: $mimeType.") + "${context.getString(R.string.app_name)}$formattedDate.$fileExtension" // Session_ - potentially no file extension, but it's the best we can do with limited data + } + } + + // Method to attempt to get a filename from a Uri. + // Note: We typically (now) populate filenames from the file picker Uri - which will work - if + // we are forced to attempt to obtain the filename from a Uri which does NOT come directly from + // the file picker then it may or MAY NOT work - or it may work but we get a GUID or an int as + // the filename rather than the actual filename like "cat.jpg" etc. In such a case returning + // null from this method means that the calling code must construct a suitable placeholder filename. + @JvmStatic + @JvmOverloads // Force creation of two versions of this method - one with and one without the mimeType param + fun getFilenameFromUri(context: Context, uri: Uri?, mimeType: String? = null, attachment: Attachment? = null): String { + var extractedFilename: String? = null + + if (uri != null) { + val scheme = uri.scheme + if ("content".equals(scheme, ignoreCase = true)) { + val projection = arrayOf(OpenableColumns.DISPLAY_NAME) + val contentRes = context.contentResolver + if (contentRes != null) { + val cursor = contentRes.query(uri, projection, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val nameIndex = it.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME) + extractedFilename = it.getString(nameIndex) + } + } + } + } + + // If the uri did not contain sufficient details to get the filename directly from the content resolver + // then we'll attempt to extract it from the uri path. For example, it's possible we could end up with + // a uri path such as: + // + // uri path: /blob/multi-session-disk/image/jpeg/cat.jpeg/3050/3a507d6a-f2f9-41d1-97a0-319de47e3a8d + // + // from which we'd want to extract the filename "cat.jpeg". + if (extractedFilename.isNullOrEmpty() && uri.path != null && uri.path!!.contains("/blob/")) { + // Split the path by "/" then traverse the segments in reverse order looking for the first one containing a dot + val segments = uri.path?.split("/") + + // If the uri path was not in the blob format extractedFilename will still be null and we'll continue on to our next + // filename synthesis technique. + extractedFilename = segments?.asReversed()?.firstOrNull { it.contains('.') } + } + } + + // Uri filename extraction failed - synthesize a filename from the media's MIME type + if (extractedFilename.isNullOrEmpty()) { + + if (attachment == null) { + val timestamp = if (uri?.path.isNullOrEmpty()) null else getTimestampFromUri(uri!!.path!!) + extractedFilename = constructFallbackMediaFilenameFromMimeType(context, mimeType, timestamp) + } else { + // If the mimetype is audio then we generate a filename which contain "VoiceMessage" or "Audio" + // based on the attachment's `isVoiceNote` flag.. + extractedFilename = if (mimeType?.contains("audio") == true) { + constructAudioMessageFilenameFromAttachment(context, attachment) + } else { + // ..otherwise we just do the best we can from the mime type (if any). + constructFallbackMediaFilenameFromMimeType(context, mimeType, null) + } + } + } + + return extractedFilename!! + } + + // Uri paths comes in a variety of formats - if we have the right format, such as "/part/1736914338425/4", then we can + // extract the incoming file timestamp from it. + private fun getTimestampFromUri(uriPath: String): Long? { + val segments = uriPath.split("/") + + // We cannot extract a timestamp from a uri path like "/file/6921609917390343" because that large number is not a timestamp + val uriPathStartsWithFile = uriPath.startsWith("/file/") == true + if (uriPathStartsWithFile) return null + + // But if we have a uri path in a format like "/part/1736914338425/4" then we CAN extract that timestamp (the middle value) + val uriPathStartsWithPart = uriPath.startsWith("/part/") == true + if (!uriPathStartsWithPart) return null + return try { + segments.getOrNull(2)?.toLong() + } catch (e: Exception) { + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt index cc40e0cc92..a19f89dae3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.util import android.content.res.Resources +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import kotlin.math.roundToInt @@ -22,12 +23,16 @@ fun toDp(px: Float, resources: Resources): Float { return (px / scale) } -val RecyclerView.isScrolledToBottom: Boolean +/** + * Returns true if the recyclerview is scrolled within 50dp of the bottom + */ +val RecyclerView.isNearBottom: 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 +val RecyclerView.isFullyScrolled: Boolean + get() { + return (layoutManager as LinearLayoutManager).findLastCompletelyVisibleItemPosition() == + adapter!!.itemCount - 1 + } 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 46ad821233..0285857409 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt @@ -11,8 +11,8 @@ import android.view.animation.AccelerateDecelerateInterpolator import android.widget.LinearLayout import android.widget.RelativeLayout import androidx.annotation.ColorInt -import network.loki.messenger.R import kotlin.math.roundToInt +import network.loki.messenger.R interface GlowView { var mainColor: Int @@ -198,7 +198,8 @@ class InputBarButtonImageViewContainer : RelativeLayout, GlowView { val h = height.toFloat() c.drawCircle(w / 2, h / 2, w / 2, fillPaint) if (strokeColor != 0) { - c.drawCircle(w / 2, h / 2, w / 2, strokePaint) + // Adjust radius to account for stroke width + c.drawCircle(w / 2, h / 2, w / 2 - strokePaint.strokeWidth / 2, strokePaint) } super.onDraw(c) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java index fa2fd6b503..84513ccfa5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java @@ -5,24 +5,28 @@ import android.graphics.Bitmap; import android.net.Uri; import android.provider.MediaStore; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; import android.text.TextUtils; import android.util.Pair; import android.webkit.MimeTypeMap; - +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import com.bumptech.glide.Glide; import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.resource.gif.GifDrawable; - +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.Locale; +import java.util.concurrent.ExecutionException; import org.session.libsession.messaging.sending_receiving.attachments.Attachment; import org.session.libsession.utilities.MediaTypes; import org.session.libsignal.utilities.Log; +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2; import org.thoughtcrime.securesms.mms.AudioSlide; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; import org.thoughtcrime.securesms.mms.DocumentSlide; import org.thoughtcrime.securesms.mms.GifSlide; -import com.bumptech.glide.Glide; import org.thoughtcrime.securesms.mms.ImageSlide; import org.thoughtcrime.securesms.mms.MmsSlide; import org.thoughtcrime.securesms.mms.PartAuthority; @@ -30,258 +34,259 @@ import org.thoughtcrime.securesms.mms.TextSlide; import org.thoughtcrime.securesms.mms.VideoSlide; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.util.concurrent.ExecutionException; - public class MediaUtil { - private static final String TAG = MediaUtil.class.getSimpleName(); - - public static Slide getSlideForAttachment(Context context, Attachment attachment) { - Slide slide = null; - if (isGif(attachment.getContentType())) { - slide = new GifSlide(context, attachment); - } else if (isImageType(attachment.getContentType())) { - slide = new ImageSlide(context, attachment); - } else if (isVideoType(attachment.getContentType())) { - slide = new VideoSlide(context, attachment); - } else if (isAudioType(attachment.getContentType())) { - slide = new AudioSlide(context, attachment); - } else if (isMms(attachment.getContentType())) { - slide = new MmsSlide(context, attachment); - } else if (isLongTextType(attachment.getContentType())) { - slide = new TextSlide(context, attachment); - } else if (attachment.getContentType() != null) { - slide = new DocumentSlide(context, attachment); + private static final String TAG = MediaUtil.class.getSimpleName(); + + public static Slide getSlideForAttachment(Context context, Attachment attachment) { + Slide slide = null; + if (isGif(attachment.getContentType())) { + slide = new GifSlide(context, attachment); + } else if (isImageType(attachment.getContentType())) { + slide = new ImageSlide(context, attachment); + } else if (isVideoType(attachment.getContentType())) { + slide = new VideoSlide(context, attachment); + } else if (isAudioType(attachment.getContentType())) { + slide = new AudioSlide(context, attachment); + } else if (isMms(attachment.getContentType())) { + slide = new MmsSlide(context, attachment); + } else if (isLongTextType(attachment.getContentType())) { + slide = new TextSlide(context, attachment); + } else if (attachment.getContentType() != null) { + slide = new DocumentSlide(context, attachment); + } + + return slide; } - return slide; - } + public static @Nullable String getMimeType(Context context, Uri uri) { + if (uri == null) return null; - public static @Nullable String getMimeType(Context context, Uri uri) { - if (uri == null) return null; + if (PartAuthority.isLocalUri(uri)) { + return PartAuthority.getAttachmentContentType(context, uri); + } - if (PartAuthority.isLocalUri(uri)) { - return PartAuthority.getAttachmentContentType(context, uri); - } + String type = context.getContentResolver().getType(uri); + if (type == null) { + final String extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()); + type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.toLowerCase()); + } - String type = context.getContentResolver().getType(uri); - if (type == null) { - final String extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()); - type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.toLowerCase()); + return getJpegCorrectedMimeTypeIfRequired(type); } - return getCorrectedMimeType(type); - } - - public static @Nullable String getCorrectedMimeType(@Nullable String mimeType) { - if (mimeType == null) return null; + // Method to convert the mime-type "image/jpg" to the recognised mime-type "image/jpeg" if appropriate + public static @Nullable String getJpegCorrectedMimeTypeIfRequired(@Nullable String mimeType) { + if (mimeType == null) return null; - switch(mimeType) { - case "image/jpg": - return MimeTypeMap.getSingleton().hasMimeType(MediaTypes.IMAGE_JPEG) - ? MediaTypes.IMAGE_JPEG - : mimeType; - default: - return mimeType; + if (mimeType.equals("image/jpg")) { + return MimeTypeMap.getSingleton().hasMimeType(MediaTypes.IMAGE_JPEG) + ? MediaTypes.IMAGE_JPEG + : mimeType; + } + return mimeType; } - } - - public static long getMediaSize(Context context, Uri uri) throws IOException { - InputStream in = PartAuthority.getAttachmentStream(context, uri); - if (in == null) throw new IOException("Couldn't obtain input stream."); - long size = 0; - byte[] buffer = new byte[4096]; - int read; + public static long getMediaSize(Context context, Uri uri) throws IOException { + InputStream in = PartAuthority.getAttachmentStream(context, uri); + if (in == null) throw new IOException("Couldn't obtain input stream."); - while ((read = in.read(buffer)) != -1) { - size += read; - } - in.close(); + long size = 0; + byte[] buffer = new byte[4096]; + int read; - return size; - } + while ((read = in.read(buffer)) != -1) { + size += read; + } + in.close(); - @WorkerThread - public static Pair getDimensions(@NonNull Context context, @Nullable String contentType, @Nullable Uri uri) { - if (uri == null || !MediaUtil.isImageType(contentType)) { - return new Pair<>(0, 0); + return size; } - Pair dimens = null; - - if (MediaUtil.isGif(contentType)) { - try { - GifDrawable drawable = Glide.with(context) - .asGif() - .skipMemoryCache(true) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .load(new DecryptableUri(uri)) - .submit() - .get(); - dimens = new Pair<>(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); - } catch (InterruptedException e) { - Log.w(TAG, "Was unable to complete work for GIF dimensions.", e); - } catch (ExecutionException e) { - Log.w(TAG, "Glide experienced an exception while trying to get GIF dimensions.", e); - } - } else { - InputStream attachmentStream = null; - try { - if (MediaUtil.isJpegType(contentType)) { - attachmentStream = PartAuthority.getAttachmentStream(context, uri); - dimens = BitmapUtil.getExifDimensions(attachmentStream); - attachmentStream.close(); - attachmentStream = null; + @WorkerThread + public static Pair getDimensions(@NonNull Context context, @Nullable String contentType, @Nullable Uri uri) { + if (uri == null || !MediaUtil.isImageType(contentType)) { + return new Pair<>(0, 0); } - if (dimens == null) { - attachmentStream = PartAuthority.getAttachmentStream(context, uri); - dimens = BitmapUtil.getDimensions(attachmentStream); + + Pair dimens = null; + + if (MediaUtil.isGif(contentType)) { + try { + GifDrawable drawable = Glide.with(context) + .asGif() + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .load(new DecryptableUri(uri)) + .submit() + .get(); + dimens = new Pair<>(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); + } catch (InterruptedException e) { + Log.w(TAG, "Was unable to complete work for GIF dimensions.", e); + } catch (ExecutionException e) { + Log.w(TAG, "Glide experienced an exception while trying to get GIF dimensions.", e); + } + } else { + InputStream attachmentStream = null; + try { + if (MediaUtil.isJpegType(contentType)) { + attachmentStream = PartAuthority.getAttachmentStream(context, uri); + dimens = BitmapUtil.getExifDimensions(attachmentStream); + attachmentStream.close(); + attachmentStream = null; + } + if (dimens == null) { + attachmentStream = PartAuthority.getAttachmentStream(context, uri); + dimens = BitmapUtil.getDimensions(attachmentStream); + } + } catch (FileNotFoundException e) { + Log.w(TAG, "Failed to find file when retrieving media dimensions.", e); + } catch (IOException e) { + Log.w(TAG, "Experienced a read error when retrieving media dimensions.", e); + } catch (BitmapDecodingException e) { + Log.w(TAG, "Bitmap decoding error when retrieving dimensions.", e); + } finally { + if (attachmentStream != null) { + try { + attachmentStream.close(); + } catch (IOException e) { + Log.w(TAG, "Failed to close stream after retrieving dimensions.", e); + } + } + } } - } catch (FileNotFoundException e) { - Log.w(TAG, "Failed to find file when retrieving media dimensions.", e); - } catch (IOException e) { - Log.w(TAG, "Experienced a read error when retrieving media dimensions.", e); - } catch (BitmapDecodingException e) { - Log.w(TAG, "Bitmap decoding error when retrieving dimensions.", e); - } finally { - if (attachmentStream != null) { - try { - attachmentStream.close(); - } catch (IOException e) { - Log.w(TAG, "Failed to close stream after retrieving dimensions.", e); - } + if (dimens == null) { + dimens = new Pair<>(0, 0); } - } + Log.d(TAG, "Dimensions for [" + uri + "] are " + dimens.first + " x " + dimens.second); + return dimens; } - if (dimens == null) { - dimens = new Pair<>(0, 0); - } - Log.d(TAG, "Dimensions for [" + uri + "] are " + dimens.first + " x " + dimens.second); - return dimens; - } - - public static boolean isMms(String contentType) { - return !TextUtils.isEmpty(contentType) && contentType.trim().equals("application/mms"); - } - - public static boolean isGif(Attachment attachment) { - return isGif(attachment.getContentType()); - } - public static boolean isJpeg(Attachment attachment) { - return isJpegType(attachment.getContentType()); - } - - public static boolean isImage(Attachment attachment) { - return isImageType(attachment.getContentType()); - } + public static boolean isMms(String contentType) { + return !TextUtils.isEmpty(contentType) && contentType.trim().equals("application/mms"); + } - public static boolean isAudio(Attachment attachment) { - return isAudioType(attachment.getContentType()); - } + public static boolean isGif(Attachment attachment) { + return isGif(attachment.getContentType()); + } - public static boolean isVideo(Attachment attachment) { - return isVideoType(attachment.getContentType()); - } + public static boolean isJpeg(Attachment attachment) { + return isJpegType(attachment.getContentType()); + } - public static boolean isVcard(String contentType) { - return !TextUtils.isEmpty(contentType) && contentType.trim().equals(MediaTypes.VCARD); - } + public static boolean isImage(Attachment attachment) { + return isImageType(attachment.getContentType()); + } - public static boolean isGif(String contentType) { - return !TextUtils.isEmpty(contentType) && contentType.trim().equals("image/gif"); - } + public static boolean isAudio(Attachment attachment) { + return isAudioType(attachment.getContentType()); + } - public static boolean isJpegType(String contentType) { - return !TextUtils.isEmpty(contentType) && contentType.trim().equals(MediaTypes.IMAGE_JPEG); - } + public static boolean isVideo(Attachment attachment) { + return isVideoType(attachment.getContentType()); + } - public static boolean isFile(Attachment attachment) { - return !isGif(attachment) && !isImage(attachment) && !isAudio(attachment) && !isVideo(attachment); - } + public static boolean isVcard(String contentType) { + return !TextUtils.isEmpty(contentType) && contentType.trim().equals(MediaTypes.VCARD); + } - public static boolean isImageType(String contentType) { - return (null != contentType) - && contentType.startsWith("image/") - && !contentType.contains("svg"); // Do not treat SVGs as regular images. - } + public static boolean isGif(String contentType) { + return !TextUtils.isEmpty(contentType) && contentType.trim().equals("image/gif"); + } - public static boolean isAudioType(String contentType) { - return (null != contentType) && contentType.startsWith("audio/"); - } + public static boolean isJpegType(String contentType) { + return !TextUtils.isEmpty(contentType) && contentType.trim().equals(MediaTypes.IMAGE_JPEG); + } - public static boolean isVideoType(String contentType) { - return (null != contentType) && contentType.startsWith("video/"); - } + public static boolean isFile(Attachment attachment) { + return !isGif(attachment) && !isImage(attachment) && !isAudio(attachment) && !isVideo(attachment); + } - public static boolean isLongTextType(String contentType) { - return (null != contentType) && contentType.equals(MediaTypes.LONG_TEXT); - } + public static boolean isImageType(String contentType) { + return (null != contentType) + && contentType.startsWith("image/") + && !contentType.contains("svg"); // Do not treat SVGs as regular images. + } - public static boolean hasVideoThumbnail(Uri uri) { - Log.i(TAG, "Checking: " + uri); + public static boolean isAudioType(String contentType) { + return (null != contentType) && contentType.startsWith("audio/"); + } - if (uri == null || !ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) { - return false; + public static boolean isVideoType(String contentType) { + return (null != contentType) && contentType.startsWith("video/"); } - if ("com.android.providers.media.documents".equals(uri.getAuthority())) { - return uri.getLastPathSegment().contains("video"); - } else if (uri.toString().startsWith(MediaStore.Video.Media.EXTERNAL_CONTENT_URI.toString())) { - return true; + public static boolean isLongTextType(String contentType) { + return (null != contentType) && contentType.equals(MediaTypes.LONG_TEXT); } - return false; - } + public static boolean hasVideoThumbnail(Uri uri) { + Log.i(TAG, "Checking: " + uri); - public static @Nullable Bitmap getVideoThumbnail(Context context, Uri uri) { - if ("com.android.providers.media.documents".equals(uri.getAuthority())) { - long videoId = Long.parseLong(uri.getLastPathSegment().split(":")[1]); + if (uri == null || !ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) { + return false; + } - return MediaStore.Video.Thumbnails.getThumbnail(context.getContentResolver(), - videoId, - MediaStore.Images.Thumbnails.MINI_KIND, - null); - } else if (uri.toString().startsWith(MediaStore.Video.Media.EXTERNAL_CONTENT_URI.toString())) { - long videoId = Long.parseLong(uri.getLastPathSegment()); + if ("com.android.providers.media.documents".equals(uri.getAuthority())) { + return uri.getLastPathSegment().contains("video"); + } else if (uri.toString().startsWith(MediaStore.Video.Media.EXTERNAL_CONTENT_URI.toString())) { + return true; + } - return MediaStore.Video.Thumbnails.getThumbnail(context.getContentResolver(), - videoId, - MediaStore.Images.Thumbnails.MINI_KIND, - null); + return false; } - return null; - } - - public static @Nullable String getDiscreteMimeType(@NonNull String mimeType) { - final String[] sections = mimeType.split("/", 2); - return sections.length > 1 ? sections[0] : null; - } + public static @Nullable Bitmap getVideoThumbnail(Context context, Uri uri) { + if ("com.android.providers.media.documents".equals(uri.getAuthority())) { + long videoId = Long.parseLong(uri.getLastPathSegment().split(":")[1]); + + return MediaStore.Video.Thumbnails.getThumbnail(context.getContentResolver(), + videoId, + MediaStore.Images.Thumbnails.MINI_KIND, + null); + } else if (uri.toString().startsWith(MediaStore.Video.Media.EXTERNAL_CONTENT_URI.toString())) { + long videoId = Long.parseLong(uri.getLastPathSegment()); + + return MediaStore.Video.Thumbnails.getThumbnail(context.getContentResolver(), + videoId, + MediaStore.Images.Thumbnails.MINI_KIND, + null); + } - public static class ThumbnailData { - Bitmap bitmap; - float aspectRatio; + return null; + } - public ThumbnailData(Bitmap bitmap) { - this.bitmap = bitmap; - this.aspectRatio = (float) bitmap.getWidth() / (float) bitmap.getHeight(); + public static @Nullable String getDiscreteMimeType(@NonNull String mimeType) { + final String[] sections = mimeType.split("/", 2); + return sections.length > 1 ? sections[0] : null; } - public Bitmap getBitmap() { - return bitmap; + // Method to return a formatted voice message duration, e.g., 12345ms -> 00:12 + public static String getFormattedVoiceMessageDuration(long durationMS) { + long durationInSeconds = durationMS / 1000L; + return String.format( + Locale.getDefault(), + "%d:%02d", + durationInSeconds / 60, // Minutes + durationInSeconds % 60); // Seconds } - public float getAspectRatio() { - return aspectRatio; + // Voice messages must have a duration of at least 1 second or we don't send them + public static boolean voiceMessageMeetsMinimumDuration(long durationMS) { + return durationMS >= 1000L; } - public InputStream toDataStream() { - return BitmapUtil.toCompressedJpeg(bitmap); + public static class ThumbnailData { + Bitmap bitmap; + float aspectRatio; + + public ThumbnailData(Bitmap bitmap) { + this.bitmap = bitmap; + this.aspectRatio = (float) bitmap.getWidth() / (float) bitmap.getHeight(); + } + + public Bitmap getBitmap() { return bitmap; } + public float getAspectRatio() { return aspectRatio; } + public InputStream toDataStream() { return BitmapUtil.toCompressedJpeg(bitmap); } } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MmsCharacterCalculator.java b/app/src/main/java/org/thoughtcrime/securesms/util/MmsCharacterCalculator.java deleted file mode 100644 index c25d47a845..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MmsCharacterCalculator.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.thoughtcrime.securesms.util; - -public class MmsCharacterCalculator extends CharacterCalculator { - - private static final int MAX_SIZE = 5000; - - @Override - public CharacterState calculateCharacters(String messageBody) { - return new CharacterState(1, MAX_SIZE - messageBody.length(), MAX_SIZE, MAX_SIZE); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt index a7eb864893..54fe4361f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.util import android.content.Context +import network.loki.messenger.libsession_util.Curve25519 import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.messages.signal.IncomingTextMessage @@ -11,6 +12,9 @@ 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.crypto.ecc.DjbECPrivateKey +import org.session.libsignal.crypto.ecc.DjbECPublicKey +import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional @@ -232,7 +236,12 @@ object MockDataGenerator { 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() + val encryptionKeyPair = Curve25519.generateKeyPair().let { + ECKeyPair( + DjbECPublicKey(it.pubKey.data), + DjbECPrivateKey(it.secretKey.data) + ) + } storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, randomGroupPublicKey, System.currentTimeMillis()) storage.createInitialConfigGroup(randomGroupPublicKey, groupName, GroupUtil.createConfigMemberMap(members, setOf(adminUserId)), System.currentTimeMillis(), encryptionKeyPair, 0) @@ -368,7 +377,13 @@ object MockDataGenerator { ) ) storage.setUserCount(roomName, serverName, numGroupMembers) - lokiThreadDB.setOpenGroupChat(OpenGroup(server = serverName, room = roomName, publicKey = randomGroupPublicKey, name = roomName, imageId = null, canWrite = true, infoUpdates = 0), threadId) + lokiThreadDB.setOpenGroupChat( + OpenGroup( + server = serverName, room = roomName, publicKey = randomGroupPublicKey, + name = roomName, imageId = null, canWrite = true, infoUpdates = 0, + description = null + ), threadId + ) // Generate the message history (Note: Unapproved message requests will only include incoming messages) logProgress("Open Group Thread $threadIndex", "Generate $numMessages Messages") diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/NumberUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/NumberUtil.kt index c35ddbefaa..b739ad5251 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/NumberUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/NumberUtil.kt @@ -1,17 +1,34 @@ package org.thoughtcrime.securesms.util +import java.math.BigDecimal +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.text.NumberFormat import java.util.Locale +import kotlin.math.abs object NumberUtil { + // Method to format a number so that 1000 becomes 1k, 1200 becomes 1.2k etc. - typically used to display + // the count of emoji reactions. + // Note: This method is only designed to handle values in the scale of 0..999_999 - it will return "1000k" + // etc. for values greater than a million. @JvmStatic - fun getFormattedNumber(count: Long): String { - val isNegative = count < 0 - val absoluteCount = Math.abs(count) - if (absoluteCount < 1000) return count.toString() + fun getFormattedNumber(value: Long): String { + + // Values less than 1000 get returned as just that + // Note: While we abs the value for range, we actually return the original value with the sign + val absoluteCount = abs(value) + if (absoluteCount < 1000) return value.toString() + + // Otherwise we work out the thousands and hundreds values to use in "1.2 etc. val thousands = absoluteCount / 1000 val hundreds = (absoluteCount - thousands * 1000) / 100 - val negativePrefix = if (isNegative) "-" else "" + + // Set a negative prefix to be either a minus sign or nothing + val negativePrefix = if (value < 0) "-" else "" + + // Finally, return the formatted string return if (hundreds == 0L) { String.format(Locale.ROOT, "$negativePrefix%dk", thousands) } else { @@ -19,4 +36,69 @@ object NumberUtil { } } + /** + * Extension function on Number to format it with specified decimal places using locale settings. + * + * @param decimalPlaces Number of decimal places to display + * @param locale Locale for formatting (defaults to system locale) + * @return Formatted string representation of the number + */ + fun Number.formatWithDecimalPlaces(decimalPlaces: Int, locale: Locale = Locale.getDefault()): String { + val pattern = if (decimalPlaces > 0) { + "#,##0.${"0".repeat(decimalPlaces)}" + } else { + "#,##0" + } + + // Create locale-specific decimal format symbols + val symbols = DecimalFormatSymbols(locale) + + return DecimalFormat(pattern, symbols).apply { + this.isDecimalSeparatorAlwaysShown = decimalPlaces > 0 + this.maximumFractionDigits = decimalPlaces + this.minimumFractionDigits = decimalPlaces + }.format(this) + } + + /** + * Extension function on Number to format it with abbreviated suffixes (K, M, B, T) + * in a locale-aware way. + * + * @param locale Locale for formatting (defaults to system locale) + * @param minFractionDigits Minimum fraction digits to display + * @param maxFractionDigits Maximum fraction digits to display + * @return Formatted string with appropriate suffix, or full number if below 1,000. + */ + fun Number.formatAbbreviated( + locale: Locale = Locale.getDefault(), + minFractionDigits: Int = 1, + maxFractionDigits: Int = 1 + ): String { + // Convert to BigDecimal for precise arithmetic + val bd = when (this) { + is BigDecimal -> this + is Long, is Int, is Short, is Byte -> BigDecimal(this.toLong()) + is Double, is Float -> BigDecimal.valueOf(this.toDouble()) + else -> throw IllegalArgumentException("Unsupported number type: ${this::class.java.name}") + } + // Create a locale-aware formatter + val formatter = NumberFormat.getNumberInstance(locale).apply { + this.minimumFractionDigits = minFractionDigits + this.maximumFractionDigits = maxFractionDigits + this.isGroupingUsed = false + } + val absValue = bd.abs() + return when { + absValue >= BigDecimal(1_000_000_000_000) -> + "${formatter.format(bd.divide(BigDecimal(1_000_000_000_000)))}T" + absValue >= BigDecimal(1_000_000_000) -> + "${formatter.format(bd.divide(BigDecimal(1_000_000_000)))}B" + absValue >= BigDecimal(1_000_000) -> + "${formatter.format(bd.divide(BigDecimal(1_000_000)))}M" + absValue >= BigDecimal(1_000) -> + "${formatter.format(bd.divide(BigDecimal(1_000)))}K" + else -> formatter.format(bd) + } + } + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ProtobufEnumSerializer.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ProtobufEnumSerializer.kt new file mode 100644 index 0000000000..57e11842fe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ProtobufEnumSerializer.kt @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.util + +import com.google.protobuf.ProtocolMessageEnum +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * A base serializer for Protobuf enums that implements the [KSerializer] interface. + * It provides a way to serialize and deserialize Protobuf enum values using their numeric representation. + * + * @param T The type of the Protobuf enum that extends [ProtocolMessageEnum]. + */ +abstract class ProtobufEnumSerializer : KSerializer { + abstract fun fromNumber(number: Int): T + + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( + serialName = javaClass.simpleName, + kind = PrimitiveKind.INT + ) + + override fun serialize(encoder: Encoder, value: T) { + encoder.encodeInt(value.number) + } + + override fun deserialize(decoder: Decoder): T { + return fromNumber(decoder.decodeInt()) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/PushCharacterCalculator.java b/app/src/main/java/org/thoughtcrime/securesms/util/PushCharacterCalculator.java deleted file mode 100644 index 93561487c6..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/PushCharacterCalculator.java +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Copyright (C) 2015 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 . - */ -package org.thoughtcrime.securesms.util; - -public class PushCharacterCalculator extends CharacterCalculator { - private static final int MAX_TOTAL_SIZE = 64 * 1024; - private static final int MAX_PRIMARY_SIZE = 2000; - @Override - public CharacterState calculateCharacters(String messageBody) { - return new CharacterState(1, MAX_TOTAL_SIZE - messageBody.length(), MAX_TOTAL_SIZE, MAX_PRIMARY_SIZE); - } -} - diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RecipientChangeSource.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RecipientChangeSource.kt new file mode 100644 index 0000000000..1b2e614d0d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RecipientChangeSource.kt @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.util + +import android.content.ContentResolver +import app.cash.copper.Query +import app.cash.copper.flow.observeQuery +import kotlinx.coroutines.flow.Flow +import org.thoughtcrime.securesms.database.DatabaseContentProviders + +/** Emits every time the Recipients table changes. */ +interface RecipientChangeSource { + fun changes(): Flow +} + +/** Real implementation used in production. */ +class ContentObserverRecipientChangeSource( + private val contentResolver: ContentResolver +) : RecipientChangeSource { + override fun changes(): Flow = + contentResolver.observeQuery(DatabaseContentProviders.Recipient.CONTENT_URI) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SafeClickListener.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SafeClickListener.kt new file mode 100644 index 0000000000..a53cc35625 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SafeClickListener.kt @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.util + +import android.os.SystemClock +import android.view.View + +// Listener class that only accepts clicks at a given interval to prevent button spam. +// Note: While this cannot be used on conversation views without interfering with motion events it may still be useful. +class SafeClickListener( + private var minimumClickIntervalMS: Long = 500L, + private val onSafeClick: (View) -> Unit +) : View.OnClickListener { + private var lastClickTimestampMS: Long = 0L + + override fun onClick(v: View) { + // Ignore any follow-up clicks if the minimum interval has not passed + if (SystemClock.elapsedRealtime() - lastClickTimestampMS < minimumClickIntervalMS) return + + lastClickTimestampMS = SystemClock.elapsedRealtime() + onSafeClick(v) + } +} \ No newline at end of file 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 390f461a6c..5d65638002 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt @@ -14,7 +14,6 @@ import java.io.File import java.io.FileOutputStream import java.io.IOException import java.lang.ref.WeakReference -import java.text.SimpleDateFormat import java.util.concurrent.TimeUnit import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences @@ -23,6 +22,7 @@ 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.util.Locale /** * Saves attachment files to an external storage using [MediaStore] API. @@ -55,7 +55,6 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int // potential risks of other apps accessing their saved attachments. context.showSessionDialog { title(R.string.warning) - iconAttribute(R.attr.dialog_alert_icon) text(context.getString(R.string.attachmentsWarning)) dangerButton(R.string.save) { // Set our 'haveWarned' SharedPref and perform the save on accept @@ -68,17 +67,13 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int } fun saveAttachment(context: Context, attachment: Attachment): String? { - val contentType = checkNotNull(MediaUtil.getCorrectedMimeType(attachment.contentType)) - var fileName = attachment.fileName + val contentType = checkNotNull(MediaUtil.getJpegCorrectedMimeTypeIfRequired(attachment.contentType)) + var filename = attachment.filename + Log.i(TAG, "Saving attachment as: $filename") - // Added for SES-2624 to prevent Android API 28 devices and lower from crashing because - // for unknown reasons it provides us with an empty filename when saving files. - // TODO: Further investigation into root cause and fix! - if (fileName.isNullOrEmpty()) fileName = generateOutputFileName(contentType, attachment.date) - - fileName = sanitizeOutputFileName(fileName) val outputUri: Uri = getMediaStoreContentUriForType(contentType) - val mediaUri = createOutputUri(context, outputUri, contentType, fileName) + val mediaUri = createOutputUri(context, outputUri, contentType, filename) + val updateValues = ContentValues() PartAuthority.getAttachmentStream(context, attachment.uri).use { inputStream -> if (inputStream == null) { @@ -98,26 +93,20 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int } } } - if (Build.VERSION.SDK_INT > 28) { + + if (Build.VERSION.SDK_INT >= 29) { updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0) } + if (updateValues.size() > 0) { - context.contentResolver.update(mediaUri!!, updateValues, null, null) + try { + context.contentResolver.update(mediaUri!!, updateValues, null, null) + } catch (e: Exception) { + Log.e(TAG, "Failed to update MediaStore entry", e) + } } - return outputUri.lastPathSegment - } - - private fun generateOutputFileName(contentType: String, timestamp: Long): String { - val mimeTypeMap = MimeTypeMap.getSingleton() - val extension = mimeTypeMap.getExtensionFromMimeType(contentType) ?: "attach" - val dateFormatter = SimpleDateFormat("yyyy-MM-dd-HHmmss") - val base = "session-${dateFormatter.format(timestamp)}" - - return "${base}.${extension}"; - } - private fun sanitizeOutputFileName(fileName: String): String { - return File(fileName).name + return outputUri.lastPathSegment } private fun getMediaStoreContentUriForType(contentType: String): Uri { @@ -133,18 +122,19 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int } } - private fun createOutputUri(context: Context, outputUri: Uri, contentType: String, fileName: String): Uri? { + private fun createOutputUri(context: Context, outputUri: Uri, contentType: String, filename: String): Uri? { + // Break the filename up into its base and extension in case we have to number the base should a file + // with the given filename exist. e.g., "cat.jpg" --> base = "cat", extension = "jpg" + val fileParts: Array = getFileNameParts(filename) + val base = fileParts[0] + val extension = fileParts[1].lowercase(Locale.getDefault()) - // TODO: This method may pass an empty string as the filename in Android API 28 and below. This requires - // TODO: follow-up investigation, but has temporarily been worked around, see: - // TODO: https://github.com/session-foundation/session-android/commit/afbb71351a74220c312a09c25cc1c79738453c12 + // Some files (Giphy GIFs, for example) turn up as just a number with no file extension - so we'll use the contentType as + // the mimetype for those & all others are picked up via `getMimeTypeFromExtension` + val mimeType = if (extension.isEmpty()) contentType else MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) - val fileParts: Array = getFileNameParts(fileName) - val base = fileParts[0] - val extension = fileParts[1] - val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) val contentValues = ContentValues() - contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, filename) contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType) contentValues.put(MediaStore.MediaColumns.DATE_ADDED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())) contentValues.put(MediaStore.MediaColumns.DATE_MODIFIED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())) @@ -152,27 +142,43 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1) } else if (outputUri.scheme == ContentResolver.SCHEME_FILE) { val outputDirectory = File(outputUri.path) - var outputFile = File(outputDirectory, "$base.$extension") + var outputFile = File(outputDirectory, filename) + + // Find a unique filename by appending numbers rather than overwriting any existing file var i = 0 while (outputFile.exists()) { outputFile = File(outputDirectory, base + "-" + ++i + "." + extension) } + if (outputFile.isHidden) { throw IOException("Specified name would not be visible") } return Uri.fromFile(outputFile) } else { - var outputFileName = fileName + var outputFileName = filename var dataPath = String.format("%s/%s", getExternalPathToFileForType(context, contentType), outputFileName) + + // Find a unique filename by appending numbers rather than overwriting any existing file var i = 0 while (pathTaken(context, outputUri, dataPath)) { Log.d(TAG, "The content exists. Rename and check again.") outputFileName = base + "-" + ++i + "." + extension dataPath = String.format("%s/%s", getExternalPathToFileForType(context, contentType), outputFileName) } + contentValues.put(MediaStore.MediaColumns.DATA, dataPath) } - return context.contentResolver.insert(outputUri, contentValues) + + // making sure the inferred mimy type matches the destination collection + var targetUri = outputUri + if ((targetUri == ExternalStorageUtil.getImageUri() && (mimeType == null || !mimeType.startsWith("image/"))) || + (targetUri == ExternalStorageUtil.getVideoUri() && (mimeType == null || !mimeType.startsWith("video/"))) || + (targetUri == ExternalStorageUtil.getAudioUri() && (mimeType == null || !mimeType.startsWith("audio/")))) { + Log.w(TAG, "MIME type $mimeType does not match target collection $targetUri, using Downloads collection instead.") + targetUri = ExternalStorageUtil.getDownloadUri() + } + + return context.contentResolver.insert(targetUri, contentValues) } private fun getExternalPathToFileForType(context: Context, contentType: String): String { @@ -260,6 +266,5 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int } } - data class Attachment(val uri: Uri, val contentType: String, val date: Long, val fileName: String?) - + data class Attachment(val uri: Uri, val contentType: String, val date: Long, val filename: String) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt deleted file mode 100644 index b17356618b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.thoughtcrime.securesms.util - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import kotlinx.coroutines.flow.emptyFlow -import org.thoughtcrime.securesms.ui.components.QRScannerScreen -import org.thoughtcrime.securesms.ui.createThemedComposeView - -class ScanQRCodeWrapperFragment : Fragment() { - - companion object { - const val FRAGMENT_TAG = "ScanQRCodeWrapperFragment_FRAGMENT_TAG" - } - - var delegate: ScanQRCodeWrapperFragmentDelegate? = null - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = - createThemedComposeView { - QRScannerScreen(emptyFlow(), onScan = { - delegate?.handleQRCodeScanned(it) - }) - } -} - -fun interface ScanQRCodeWrapperFragmentDelegate { - - fun handleQRCodeScanned(string: String) -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ScreenDensity.java b/app/src/main/java/org/thoughtcrime/securesms/util/ScreenDensity.java index 7e45d51996..8e1a6d2bfd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ScreenDensity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ScreenDensity.java @@ -5,9 +5,6 @@ import androidx.annotation.NonNull; -import org.session.libsession.messaging.MessagingModuleConfiguration; -import org.thoughtcrime.securesms.ApplicationContext; - import java.util.LinkedHashMap; import java.util.Map; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt index d044d9f8c0..28f625a144 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt @@ -3,21 +3,26 @@ package org.thoughtcrime.securesms.util import network.loki.messenger.libsession_util.ReadableConversationVolatileConfig import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.utilities.GroupUtil +import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.database.model.ThreadRecord fun ReadableConversationVolatileConfig.getConversationUnread(thread: ThreadRecord): Boolean { val recipient = thread.recipient - if (recipient.isContactRecipient - && recipient.isCommunityInboxRecipient - && recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) { - return getOneToOne(recipient.address.serialize())?.unread == true + return getConversationUnread(recipient, thread.threadId) +} +fun ReadableConversationVolatileConfig.getConversationUnread(recipient : Recipient, threadId : Long): Boolean { + if ((recipient.isContactRecipient || recipient.isCommunityInboxRecipient) + && recipient.address.toString().startsWith(IdPrefix.STANDARD.value) + ) { + return getOneToOne(recipient.address.toString())?.unread == true } else if (recipient.isGroupV2Recipient) { - return getClosedGroup(recipient.address.serialize())?.unread == true + return getClosedGroup(recipient.address.toString())?.unread == true } else if (recipient.isLegacyGroupRecipient) { return getLegacyClosedGroup(GroupUtil.doubleDecodeGroupId(recipient.address.toGroupString()))?.unread == true } else if (recipient.isCommunityRecipient) { - val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(thread.threadId) ?: return false + val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(threadId) + ?: return false return getCommunity(openGroup.server, openGroup.room)?.unread == true } return false diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SmsCharacterCalculator.java b/app/src/main/java/org/thoughtcrime/securesms/util/SmsCharacterCalculator.java deleted file mode 100644 index 5bac6ec95c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SmsCharacterCalculator.java +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Copyright (C) 2011 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 . - */ -package org.thoughtcrime.securesms.util; - -import android.telephony.SmsMessage; -import org.session.libsignal.utilities.Log; - -public class SmsCharacterCalculator extends CharacterCalculator { - - private static final String TAG = SmsCharacterCalculator.class.getSimpleName(); - - @Override - public CharacterState calculateCharacters(String messageBody) { - int[] length; - int messagesSpent; - int charactersSpent; - int charactersRemaining; - - try { - length = SmsMessage.calculateLength(messageBody, false); - messagesSpent = length[0]; - charactersSpent = length[1]; - charactersRemaining = length[2]; - } catch (NullPointerException e) { - Log.w(TAG, e); - messagesSpent = 1; - charactersSpent = messageBody.length(); - charactersRemaining = 1000; - } - - int maxMessageSize; - - if (messagesSpent > 0) { - maxMessageSize = (charactersSpent + charactersRemaining) / messagesSpent; - } else { - maxMessageSize = (charactersSpent + charactersRemaining); - } - - return new CharacterState(messagesSpent, charactersRemaining, maxMessageSize, maxMessageSize); - } -} - diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Trimmer.java b/app/src/main/java/org/thoughtcrime/securesms/util/Trimmer.java deleted file mode 100644 index 6707a078c8..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Trimmer.java +++ /dev/null @@ -1,63 +0,0 @@ -package org.thoughtcrime.securesms.util; - -import android.app.ProgressDialog; -import android.content.Context; -import android.os.AsyncTask; -import android.widget.Toast; - -import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; - -import network.loki.messenger.R; - -public class Trimmer { - - public static void trimAllThreads(Context context, int threadLengthLimit) { - new TrimmingProgressTask(context).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, threadLengthLimit); - } - - private static class TrimmingProgressTask extends AsyncTask implements ThreadDatabase.ProgressListener { - private ProgressDialog progressDialog; - private Context context; - - public TrimmingProgressTask(Context context) { - this.context = context; - } - - @Override - protected void onPreExecute() { - progressDialog = new ProgressDialog(context); - progressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); - progressDialog.setCancelable(false); - progressDialog.setIndeterminate(false); - progressDialog.setTitle(R.string.deleting); - progressDialog.setMessage(context.getString(R.string.deleting)); - progressDialog.setMax(100); - progressDialog.show(); - } - - @Override - protected Void doInBackground(Integer... params) { - DatabaseComponent.get(context).threadDatabase().trimAllThreads(params[0], this); - return null; - } - - @Override - protected void onProgressUpdate(Integer... progress) { - double count = progress[1]; - double index = progress[0]; - - progressDialog.setProgress((int)Math.round((index / count) * 100.0)); - } - - @Override - protected void onPostExecute(Void result) { - progressDialog.dismiss(); - } - - @Override - public void onProgress(int complete, int total) { - this.publishProgress(complete, total); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UnreadStylingHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/util/UnreadStylingHelper.kt new file mode 100644 index 0000000000..4449f970a8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UnreadStylingHelper.kt @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.util + +import android.content.Context +import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.widget.TextView +import androidx.core.content.ContextCompat +import network.loki.messenger.R + +object UnreadStylingHelper { + + fun getUnreadBackground(context: Context, isUnread: Boolean): Drawable? { + val drawableRes = if (isUnread) { + ContextCompat.getDrawable(context, R.drawable.conversation_unread_background) + } else { + ContextCompat.getDrawable(context, R.drawable.conversation_view_background) + } + + return drawableRes + } + + fun formatUnreadCount(unreadCount: Int): String? { + return when { + unreadCount == 0 -> null + unreadCount < 1000 -> unreadCount.toString() + else -> "999+" + } + } + + fun getUnreadTypeface(isUnread: Boolean): Typeface { + return if (isUnread) Typeface.DEFAULT_BOLD else Typeface.DEFAULT + } + + fun getAccentBackground(context: Context) : ColorDrawable { + val accentColor = context.getAccentColor() + val background = ColorDrawable(accentColor) + + return background + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UserProfileUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/UserProfileUtils.kt new file mode 100644 index 0000000000..d04aeca5af --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UserProfileUtils.kt @@ -0,0 +1,197 @@ +package org.thoughtcrime.securesms.util + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Context.CLIPBOARD_SERVICE +import android.widget.Toast +import com.squareup.phrase.Phrase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.database.StorageProtocol +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.pro.ProStatusManager + +/** + * Helper class to get the information required for the user profile modal + */ +class UserProfileUtils @AssistedInject constructor( + @ApplicationContext private val context: Context, + @Assisted private val recipient: Recipient, + @Assisted private val threadId: Long, + @Assisted private val scope: CoroutineScope, + private val avatarUtils: AvatarUtils, + private val configFactory: ConfigFactoryProtocol, + private val proStatusManager: ProStatusManager, + private val storage: StorageProtocol +) { + private val _userProfileModalData: MutableStateFlow = MutableStateFlow(null) + val userProfileModalData: StateFlow + get() = _userProfileModalData + + init { + Log.d("UserProfileUtils", "init") + + scope.launch(Dispatchers.Default) { + _userProfileModalData.update { getDefaultProfileData() } + } + } + + private suspend fun getDefaultProfileData(): UserProfileModalData { + var address = recipient.address.toString() + val configContact = configFactory.withUserConfigs { configs -> + configs.contacts.get(address) + } + + var isResolvedBlinded = false + + var isBlinded = IdPrefix.fromValue(address)?.isBlinded() == true + + // if we have a blinded address, check if it can be resolved + if(isBlinded){ + val openGroup = storage.getOpenGroup(threadId) + openGroup?.let { + val resolvedAddress = storage.getOrCreateBlindedIdMapping( + address, + it.server, + it.publicKey + ).accountId + + if (resolvedAddress != null) { + address = resolvedAddress + isResolvedBlinded = true + isBlinded = false // no longer blinded + } + } + } + + // we apply the display rules from figma (the numbers being the number of characters): + // - if the address is blinded (with a tooltip), display as 10...10 + // - if the address is a resolved blinded id (with a tooltip) 23 / 23 / 20 + // - for the rest: non blinded address which aren't from a community, break in 33 / 33 + val (displayAddress, tooltipText) = when { + isBlinded -> { + "${address.take(10)}...${address.takeLast(10)}" to + context.getString(R.string.tooltipBlindedIdCommunities) + } + + isResolvedBlinded -> { + "${address.substring(0, 23)}\n${address.substring(23, 46)}\n${address.substring(46)}" to + Phrase.from(context, R.string.tooltipAccountIdVisible) + .put(NAME_KEY, truncateName(recipient.name)) + .format() + } + + else -> { + "${address.take(33)}\n${address.takeLast(33)}" to null + } + } + + return UserProfileModalData( + name = if(recipient.isLocalNumber) context.getString(R.string.you) else recipient.name, + subtitle = if(configContact?.nickname?.isNotEmpty() == true) "(${configContact.name})" else null, + avatarUIData = avatarUtils.getUIDataFromAccountId(accountId = address), + showProBadge = proStatusManager.shouldShowProBadge(recipient.address), + currentUserPro = proStatusManager.isCurrentUserPro(), + rawAddress = address, + displayAddress = displayAddress, + threadId = threadId, + isBlinded = isBlinded, + tooltipText = tooltipText, + enableMessage = !isBlinded || !recipient.blocksCommunityMessageRequests, + expandedAvatar = false, + showQR = false, + showProCTA = false + ) + + } + + private fun truncateName(name: String): String { + return if (name.length > 10) { + name.take(10) + "…" + } else { + name + } + } + + fun onCommand(command: UserProfileModalCommands){ + when(command){ + UserProfileModalCommands.ShowProCTA -> { + _userProfileModalData.update { _userProfileModalData.value?.copy(showProCTA = true) } + } + + UserProfileModalCommands.HideSessionProCTA -> { + _userProfileModalData.update { _userProfileModalData.value?.copy(showProCTA = false) } + } + + UserProfileModalCommands.ToggleQR -> { + _userProfileModalData.update { + _userProfileModalData.value?.let{ + it.copy(showQR = !it.showQR) + } + } + } + + UserProfileModalCommands.ToggleAvatarExpand -> { + _userProfileModalData.update { + _userProfileModalData.value?.let{ + it.copy(expandedAvatar = !it.expandedAvatar) + } + } + } + + UserProfileModalCommands.CopyAccountId -> { + //todo we do this in a few places, should reuse the logic + val accountID = recipient.address.toString() + val clip = ClipData.newPlainText("Account ID", accountID) + val manager = context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + manager.setPrimaryClip(clip) + Toast.makeText(context, R.string.copied, Toast.LENGTH_SHORT).show() + } + } + } + + @AssistedFactory + interface UserProfileUtilsFactory { + fun create(recipient: Recipient, threadId: Long, scope: CoroutineScope): UserProfileUtils + } + +} + +data class UserProfileModalData( + val name: String, + val subtitle: String?, + val showProBadge: Boolean, + val currentUserPro: Boolean, + val rawAddress: String, + val displayAddress: String, + val threadId: Long, + val isBlinded: Boolean, + val tooltipText: CharSequence?, + val enableMessage: Boolean, + val expandedAvatar: Boolean, + val showQR: Boolean, + val avatarUIData: AvatarUIData, + val showProCTA: Boolean +) + +sealed interface UserProfileModalCommands { + object ShowProCTA: UserProfileModalCommands + object HideSessionProCTA: UserProfileModalCommands + object CopyAccountId: UserProfileModalCommands + object ToggleAvatarExpand: UserProfileModalCommands + object ToggleQR: UserProfileModalCommands +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtilsImpl.kt b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtilsImpl.kt new file mode 100644 index 0000000000..c5a81da8a8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtilsImpl.kt @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.util + +import network.loki.messenger.libsession_util.getOrNull +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.UsernameUtils +import org.session.libsession.utilities.truncateIdForDisplay +import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.database.SessionContactDatabase +import org.thoughtcrime.securesms.dependencies.ConfigFactory + +class UsernameUtilsImpl( + private val prefs: TextSecurePreferences, + private val configFactory: ConfigFactory, + private val sessionContactDatabase: SessionContactDatabase, +): UsernameUtils { + override fun getCurrentUsernameWithAccountIdFallback(): String = prefs.getProfileName() + ?: truncateIdForDisplay( prefs.getLocalNumber() ?: "") + + override fun getCurrentUsername(): String? = prefs.getProfileName() + + override fun saveCurrentUserName(name: String) { + configFactory.withMutableUserConfigs { + it.userProfile.setName(name) + } + } + + override fun getContactNameWithAccountID( + accountID: String, + groupId: AccountId?, + contactContext: Contact.ContactContext + ): String { + val contact = sessionContactDatabase.getContactWithAccountID(accountID) + return getContactNameWithAccountID(contact, accountID, groupId, contactContext) + } + + override fun getContactNameWithAccountID( + contact: Contact?, + accountID: String, + groupId: AccountId?, + contactContext: Contact.ContactContext) + : String { + // first attempt to get the name from the contact + val userName: String? = contact?.displayName(contactContext) + ?: if(groupId != null){ + configFactory.withGroupConfigs(groupId) { it.groupMembers.getOrNull(accountID)?.name } + } else null + + // if the username is actually set to the user's accountId, truncate it + val validatedUsername = if(userName == accountID) truncateIdForDisplay(accountID) else userName + + return if(validatedUsername.isNullOrEmpty()) truncateIdForDisplay(accountID) else validatedUsername + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/VersionDataFetcher.kt b/app/src/main/java/org/thoughtcrime/securesms/util/VersionDataFetcher.kt index aba814524c..388803a15b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/VersionDataFetcher.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/VersionDataFetcher.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.launch import org.session.libsession.messaging.file_server.FileServerApi import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.hours @@ -18,7 +19,7 @@ private val REFRESH_TIME_MS = 4.hours.inWholeMilliseconds @Singleton class VersionDataFetcher @Inject constructor( private val prefs: TextSecurePreferences -) { +) : OnAppStartupComponent { private val handler = Handler(Looper.getMainLooper()) private val fetchVersionData = Runnable { scope.launch { @@ -57,4 +58,8 @@ class VersionDataFetcher @Inject constructor( fun stopTimedVersionCheck() { handler.removeCallbacks(fetchVersionData) } + + override fun onPostAppStarted() { + startTimedVersionCheck() + } } \ No newline at end of file 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 a7ba6027d5..4c275ca886 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt @@ -11,15 +11,24 @@ 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.ViewGroup.MarginLayoutParams import android.view.inputmethod.InputMethodManager import android.widget.EditText +import android.widget.ScrollView import androidx.annotation.AttrRes +import androidx.annotation.ColorInt import androidx.annotation.ColorRes +import androidx.annotation.DimenRes +import androidx.core.graphics.Insets import androidx.core.graphics.applyCanvas +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsCompat.Type.InsetsType +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.recyclerview.widget.RecyclerView +import network.loki.messenger.R +import org.session.libsession.utilities.getColorFromAttr import org.session.libsignal.utilities.Log import kotlin.math.roundToInt @@ -75,8 +84,9 @@ fun View.animateSizeChange(startSize: Float, endSize: Float, animationDuration: } fun View.fadeIn(duration: Long = 150) { + alpha = 0.0f visibility = View.VISIBLE - animate().setDuration(duration).alpha(1.0f).start() + animate().setDuration(duration).alpha(1.0f).setListener(null).start() } fun View.fadeOut(duration: Long = 150) { @@ -120,3 +130,128 @@ fun EditText.addTextChangedListener(listener: (String) -> Unit) { } }) } + +/** + * Applies the system insets to the view's paddings. + */ +@JvmOverloads +fun View.applySafeInsetsPaddings( + @InsetsType + typeMask: Int = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime(), + consumeInsets: Boolean = true, + applyTop: Boolean = true, + applyBottom: Boolean = true, + alsoApply: (Insets) -> Unit = {} +) { + ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> + val insets = windowInsets.getInsets(typeMask) + + view.updatePadding( + left = insets.left, + top = if(applyTop) insets.top else 0, + right = insets.right, + bottom = if(applyBottom) insets.bottom else 0 + ) + + alsoApply(insets) + + if (consumeInsets) { + windowInsets.inset(insets) + } else { + // Return the insets unconsumed + windowInsets + } + } +} + +/** + * Applies the system insets to the view's margins. + */ +@JvmOverloads +fun View.applySafeInsetsMargins( + consumeInsets: Boolean = true, + @InsetsType + typeMask: Int = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime(), +) { + ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> + // Get system bars insets + val systemBarsInsets = windowInsets.getInsets(typeMask) + + // Update view margins to account for system bars + val lp = view.layoutParams as? MarginLayoutParams + if (lp != null) { + lp.setMargins(systemBarsInsets.left, systemBarsInsets.top, systemBarsInsets.right, systemBarsInsets.bottom) + view.layoutParams = lp + + if (consumeInsets) { + WindowInsetsCompat.CONSUMED + } else { + // Return the insets unconsumed + windowInsets + } + } else { + Log.w("ViewUtils", "Cannot apply insets to view with no margins") + windowInsets + } + } +} + +/** + * Applies the system insets to a RecyclerView or ScrollView. The inset will apply as margin + * at the top and padding at the bottom. For ScrollView, the bottom insets will be applied to the first child. + */ +@JvmOverloads +fun applyCommonWindowInsetsOnViews( + mainRecyclerView: RecyclerView? = null, + mainScrollView: ScrollView? = null +) { + if (mainRecyclerView != null && mainScrollView == null) { + mainRecyclerView.clipToPadding = false + + ViewCompat.setOnApplyWindowInsetsListener(mainRecyclerView) { _, windowInsets -> + mainRecyclerView.updateLayoutParams { + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + topMargin = insets.top + leftMargin = insets.left + rightMargin = insets.right + } + + mainRecyclerView.updatePadding( + bottom = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()).bottom + ) + + WindowInsetsCompat.CONSUMED + } + } else if (mainScrollView != null && mainRecyclerView == null) { + val firstChild = requireNotNull(mainScrollView.getChildAt(0)) { + "Given scrollView has no child to apply insets to" + } + + ViewCompat.setOnApplyWindowInsetsListener(mainScrollView) { _, windowInsets -> + mainScrollView.updateLayoutParams { + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + topMargin = insets.top + leftMargin = insets.left + rightMargin = insets.right + } + + firstChild.updatePadding( + bottom = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()).bottom + ) + + WindowInsetsCompat.CONSUMED + } + } else { + error("Either mainRecyclerView or mainScrollView must be non-null, but not both.") + } +} + +// Listener class that only accepts clicks at given interval to prevent button spam - can be used instead +// of a standard `onClickListener` in many places. A separate mechanism exists for VisibleMessageViews to +// prevent interfering with gestures. +fun View.setSafeOnClickListener(clickIntervalMS: Long = 1000L, onSafeClick: (View) -> Unit) { + val safeClickListener = SafeClickListener(minimumClickIntervalMS = clickIntervalMS) { + onSafeClick(it) + } + setOnClickListener(safeClickListener) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java index 67a7e0335d..bb8bdf3b1e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java @@ -17,50 +17,47 @@ package org.thoughtcrime.securesms.video; import android.content.Context; -import android.os.Build; import android.util.AttributeSet; +import android.util.TypedValue; import android.view.View; import android.view.Window; import android.view.WindowManager; import android.widget.FrameLayout; -import android.widget.Toast; -import android.widget.VideoView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; - +import androidx.annotation.OptIn; +import androidx.core.graphics.ColorUtils; +import androidx.media3.common.AudioAttributes; import androidx.media3.common.MediaItem; import androidx.media3.common.Player; -import androidx.media3.common.AudioAttributes; import androidx.media3.common.util.UnstableApi; import androidx.media3.exoplayer.ExoPlayer; -import androidx.media3.ui.LegacyPlayerControlView; import androidx.media3.ui.PlayerView; - import org.session.libsession.utilities.ViewUtil; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.attachments.AttachmentServer; -import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.mms.VideoSlide; import java.io.IOException; import network.loki.messenger.R; -@UnstableApi public class VideoPlayer extends FrameLayout { private static final String TAG = VideoPlayer.class.getSimpleName(); - @Nullable private final VideoView videoView; @Nullable private final PlayerView exoView; @Nullable private ExoPlayer exoPlayer; - @Nullable private LegacyPlayerControlView exoControls; - @Nullable private AttachmentServer attachmentServer; @Nullable private Window window; + public interface VideoPlayerInteractions { + void onControllerVisibilityChanged(boolean visible); + } + + @Nullable + private VideoPlayerInteractions interactor = null; + public VideoPlayer(Context context) { this(context, null); } @@ -69,15 +66,33 @@ public VideoPlayer(Context context, AttributeSet attrs) { this(context, attrs, 0); } - public VideoPlayer(Context context, AttributeSet attrs, int defStyleAttr) { + @OptIn(markerClass = UnstableApi.class) + public VideoPlayer(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); inflate(context, R.layout.video_player, this); this.exoView = ViewUtil.findById(this, R.id.video_view); - this.videoView = null; - this.exoControls = new LegacyPlayerControlView(getContext()); - this.exoControls.setShowTimeoutMs(-1); + exoView.setControllerShowTimeoutMs(3000); + + TypedValue tv = new TypedValue(); + getContext().getTheme().resolveAttribute(android.R.attr.colorPrimary, tv, true); + int bgColor = tv.data; + findViewById(R.id.custom_controls).setBackgroundColor( + ColorUtils.setAlphaComponent(bgColor, (int) (0.7f * 255)) + ); + + // listen to changes in the controller visibility + exoView.setControllerVisibilityListener(new PlayerView.ControllerVisibilityListener() { + @Override + public void onVisibilityChanged(int visibility) { + if (interactor != null) interactor.onControllerVisibilityChanged(visibility == View.VISIBLE); + } + }); + } + + public void setInteractor(@Nullable VideoPlayerInteractions interactor) { + this.interactor = interactor; } public void setVideoSource(@NonNull VideoSlide videoSource, boolean autoplay) @@ -86,32 +101,25 @@ public void setVideoSource(@NonNull VideoSlide videoSource, boolean autoplay) setExoViewSource(videoSource, autoplay); } - public void pause() { - if (this.attachmentServer != null && this.videoView != null) { - this.videoView.stopPlayback(); - } else if (this.exoPlayer != null) { - this.exoPlayer.setPlayWhenReady(false); - } + public void setControlsYPosition(int yPosition){ + org.thoughtcrime.securesms.conversation.v2.ViewUtil.setBottomMargin(findViewById(R.id.custom_controls), yPosition); } - public void hideControls() { - if (this.exoView != null) { - this.exoView.hideController(); + public Long pause() { + if (this.exoPlayer != null) { + this.exoPlayer.setPlayWhenReady(false); + // return last playback position + return exoPlayer.getCurrentPosition(); } + + return 0L; } - public @Nullable View getControlView() { - if (this.exoControls != null) { - return this.exoControls; - } - return null; + public void seek(Long position){ + if (exoPlayer != null) exoPlayer.seekTo(position); } public void cleanup() { - if (this.attachmentServer != null) { - this.attachmentServer.stop(); - } - if (this.exoPlayer != null) { this.exoPlayer.release(); } @@ -121,16 +129,16 @@ public void setWindow(@Nullable Window window) { this.window = window; } - private void setExoViewSource(@NonNull VideoSlide videoSource, boolean autoplay) + @OptIn(markerClass = UnstableApi.class) + private void setExoViewSource(@NonNull VideoSlide videoSource, boolean autoplay) throws IOException { exoPlayer = new ExoPlayer.Builder(getContext()).build(); exoPlayer.addListener(new ExoPlayerListener(window)); exoPlayer.setAudioAttributes(AudioAttributes.DEFAULT, true); //noinspection ConstantConditions - exoView.setPlayer(exoPlayer); + exoView.setPlayer(exoPlayer); //todo this should be optimised as it creates a small lag in the viewpager //noinspection ConstantConditions - exoControls.setPlayer(exoPlayer); if(videoSource.getUri() != null){ MediaItem mediaItem = MediaItem.fromUri(videoSource.getUri()); @@ -141,32 +149,7 @@ private void setExoViewSource(@NonNull VideoSlide videoSource, boolean autoplay) exoPlayer.setPlayWhenReady(autoplay); } - private void setVideoViewSource(@NonNull VideoSlide videoSource, boolean autoplay) - throws IOException - { - if (this.attachmentServer != null) { - this.attachmentServer.stop(); - } - - if (videoSource.getUri() != null && PartAuthority.isLocalUri(videoSource.getUri())) { - Log.i(TAG, "Starting video attachment server for part provider Uri..."); - this.attachmentServer = new AttachmentServer(getContext(), videoSource.asAttachment()); - this.attachmentServer.start(); - - //noinspection ConstantConditions - this.videoView.setVideoURI(this.attachmentServer.getUri()); - } else if (videoSource.getUri() != null) { - Log.i(TAG, "Playing video directly from non-local Uri..."); - //noinspection ConstantConditions - this.videoView.setVideoURI(videoSource.getUri()); - } else { - Toast.makeText(getContext(), getContext().getString(R.string.videoErrorPlay), Toast.LENGTH_LONG).show(); - return; - } - - if (autoplay) this.videoView.start(); - } - + @UnstableApi private static class ExoPlayerListener implements Player.Listener { private final Window window; 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 90e1ce6085..094bd615d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.boolean import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonPrimitive @@ -33,11 +32,12 @@ 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 -import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.VideoEnabled import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat import org.thoughtcrime.securesms.webrtc.audio.OutgoingRinger import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager.AudioDevice +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager.AudioDevice.EARPIECE +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager.AudioDevice.SPEAKER_PHONE import org.thoughtcrime.securesms.webrtc.data.Event import org.thoughtcrime.securesms.webrtc.data.StateProcessor import org.thoughtcrime.securesms.webrtc.locks.LockManager @@ -122,7 +122,7 @@ class CallManager( private val stateProcessor = StateProcessor(CallState.Idle) - private val _callStateEvents = MutableStateFlow(CallViewModel.State.CALL_PENDING) + private val _callStateEvents = MutableStateFlow(CallViewModel.State.CALL_INITIALIZING) val callStateEvents = _callStateEvents.asSharedFlow() private val _recipientEvents = MutableStateFlow(RecipientUpdate.UNKNOWN) val recipientEvents = _recipientEvents.asSharedFlow() @@ -131,6 +131,9 @@ class CallManager( private val _audioDeviceEvents = MutableStateFlow(AudioDeviceUpdate(AudioDevice.NONE, setOf())) val audioDeviceEvents = _audioDeviceEvents.asSharedFlow() + val currentConnectionStateFlow + get() = stateProcessor.currentStateFlow + val currentConnectionState get() = stateProcessor.currentState @@ -165,6 +168,19 @@ class CallManager( var fullscreenRenderer: SurfaceViewRenderer? = null private var peerConnectionFactory: PeerConnectionFactory? = null + private val lockManager by lazy { LockManager(context) } + private var uncaughtExceptionHandlerManager: UncaughtExceptionHandlerManager? = null + + init { + registerUncaughtExceptionHandler() + } + + private fun registerUncaughtExceptionHandler() { + uncaughtExceptionHandlerManager = UncaughtExceptionHandlerManager().apply { + registerHandler(ProximityLockRelease(lockManager)) + } + } + fun clearPendingIceUpdates() { pendingOutgoingIceUpdates.clear() pendingIncomingIceUpdates.clear() @@ -219,8 +235,6 @@ class CallManager( fun isIdle() = currentConnectionState == CallState.Idle - fun isCurrentUser(recipient: Recipient) = recipient.address.serialize() == storage.getUserPublicKey() - fun initializeVideo(context: Context) { Util.runOnMainSync { val base = EglBase.create() @@ -261,7 +275,7 @@ class CallManager( fun setAudioEnabled(isEnabled: Boolean) { currentConnectionState.withState(*CallState.CAN_HANGUP_STATES) { peerConnection?.setAudioEnabled(isEnabled) - _audioEvents.value = AudioEnabled(true) + _audioEvents.value = AudioEnabled(isEnabled) } } @@ -270,7 +284,6 @@ class CallManager( } override fun onIceConnectionChange(newState: IceConnectionState) { - Log.d("Loki", "New ice connection state = $newState") iceState = newState peerConnectionObservers.forEach { listener -> listener.onIceConnectionChange(newState) } if (newState == IceConnectionState.CONNECTED) { @@ -298,6 +311,7 @@ class CallManager( } private fun queueOutgoingIce(expectedCallId: UUID, expectedRecipient: Recipient) { + postViewModelState(CallViewModel.State.CALL_SENDING_ICE) outgoingIceDebouncer.publish { val currentCallId = this.callId ?: return@publish val currentRecipient = this.recipient ?: return@publish @@ -317,6 +331,7 @@ class CallManager( ) .applyExpiryMode(thread) .also { MessageSender.sendNonDurably(it, currentRecipient.address, isSyncMessage = currentRecipient.isLocalNumber) } + } } } @@ -387,6 +402,8 @@ class CallManager( fun stop() { val isOutgoing = currentConnectionState in CallState.OUTGOING_STATES stateProcessor.processEvent(Event.Cleanup) { + lockManager.updatePhoneState(LockManager.PhoneState.IDLE) + signalAudioManager.handleCommand(AudioManagerCommand.Stop(isOutgoing)) peerConnection?.dispose() peerConnection = null @@ -476,12 +493,15 @@ class CallManager( } fun onIncomingCall(context: Context, isAlwaysTurn: Boolean = false): Promise { + lockManager.updatePhoneState(LockManager.PhoneState.PROCESSING) + val callId = callId ?: return Promise.ofFail(NullPointerException("callId is null")) val recipient = recipient ?: return Promise.ofFail(NullPointerException("recipient is null")) val offer = pendingOffer ?: return Promise.ofFail(NullPointerException("pendingOffer is null")) val factory = peerConnectionFactory ?: return Promise.ofFail(NullPointerException("peerConnectionFactory is null")) val local = floatingRenderer ?: return Promise.ofFail(NullPointerException("localRenderer is null")) val base = eglBase ?: return Promise.ofFail(NullPointerException("eglBase is null")) + val connection = PeerConnectionWrapper( context, factory, @@ -508,7 +528,7 @@ class CallManager( callId ).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber) - insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_INCOMING, false) + insertCallMessage(recipient.address.toString(), CallMessageType.CALL_INCOMING, false) while (pendingIncomingIceUpdates.isNotEmpty()) { val candidate = pendingIncomingIceUpdates.pop() ?: break @@ -521,6 +541,8 @@ class CallManager( } fun onOutgoingCall(context: Context, isAlwaysTurn: Boolean = false): Promise { + lockManager.updatePhoneState(LockManager.PhoneState.IN_CALL) + val callId = callId ?: return Promise.ofFail(NullPointerException("callId is null")) val recipient = recipient ?: return Promise.ofFail(NullPointerException("recipient is null")) @@ -560,6 +582,7 @@ class CallManager( ).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber).bind { Log.d("Loki", "Sent pre-offer") Log.d("Loki", "Sending offer") + postViewModelState(CallViewModel.State.CALL_OFFER_OUTGOING) MessageSender.sendNonDurably(CallMessage.offer( offer.description, callId @@ -580,16 +603,20 @@ class CallManager( 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) + insertCallMessage(recipient.address.toString(), CallMessageType.CALL_INCOMING) + } } + fun handleIgnoreCall(){ + stateProcessor.processEvent(Event.IgnoreCall) + } + fun handleLocalHangup(intentRecipient: Recipient?) { val recipient = recipient ?: return val callId = callId ?: return - val currentUserPublicKey = storage.getUserPublicKey() - val sendHangup = intentRecipient == null || (intentRecipient == recipient && recipient.address.serialize() != currentUserPublicKey) + val sendHangup = intentRecipient == null || (intentRecipient == recipient && !recipient.isLocalNumber) postViewModelState(CallViewModel.State.CALL_DISCONNECTED) stateProcessor.processEvent(Event.Hangup) @@ -634,11 +661,33 @@ class CallManager( } } - fun handleSetMuteAudio(muted: Boolean) { - _audioEvents.value = AudioEnabled(!muted) - peerConnection?.setAudioEnabled(!muted) + fun toggleVideo(){ + handleSetMuteVideo(_videoState.value.userVideoEnabled) + } + + fun toggleMuteAudio() { + val muted = !_audioEvents.value.isEnabled + setAudioEnabled(muted) + } + + fun toggleSpeakerphone(){ + if (currentConnectionState !in arrayOf( + CallState.Connected, + *CallState.PENDING_CONNECTION_STATES + ) + ) { + Log.w(TAG, "handling audio command not in call") + return + } + + // we default to EARPIECE if not SPEAKER but the audio manager will know to actually use a headset if any is connected + val command = + AudioManagerCommand.SetUserDevice(if (isOnSpeakerphone()) EARPIECE else SPEAKER_PHONE) + handleAudioCommand(command) } + private fun isOnSpeakerphone() = _audioDeviceEvents.value.selectedDevice == SPEAKER_PHONE + /** * Returns the renderer currently showing the user's video, not the contact's */ @@ -666,7 +715,7 @@ class CallManager( } } - fun handleSetMuteVideo(muted: Boolean, lockManager: LockManager) { + private fun handleSetMuteVideo(muted: Boolean) { _videoState.update { it.copy(userVideoEnabled = !muted) } handleMirroring() @@ -692,7 +741,7 @@ class CallManager( } } - fun handleSetCameraFlip() { + fun flipCamera() { if (!localCameraState.enabled) return peerConnection?.let { connection -> connection.flipCamera() @@ -760,6 +809,10 @@ class CallManager( return } + if(_callStateEvents.value != CallViewModel.State.CALL_CONNECTED){ + postViewModelState(CallViewModel.State.CALL_HANDLING_ICE) + } + val connection = peerConnection if (connection != null && connection.readyForIce && currentConnectionState != CallState.Reconnecting) { Log.i("Loki", "Handling connection ice candidate") @@ -776,13 +829,12 @@ class CallManager( signalAudioManager.handleCommand(AudioManagerCommand.StartIncomingRinger(true)) } - fun startCommunication(lockManager: LockManager) { + fun startCommunication() { signalAudioManager.handleCommand(AudioManagerCommand.Start) val connection = peerConnection ?: return if (connection.isVideoEnabled()) lockManager.updatePhoneState(LockManager.PhoneState.IN_VIDEO) else lockManager.updatePhoneState(LockManager.PhoneState.IN_CALL) connection.setCommunicationMode() - setAudioEnabled(true) dataChannel?.let { channel -> val toSend = if (_videoState.value.userVideoEnabled) VIDEO_ENABLED_JSON else VIDEO_DISABLED_JSON val buffer = DataChannel.Buffer(ByteBuffer.wrap(toSend.toString().encodeToByteArray()), false) 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 60899a9c17..494c0093fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt @@ -1,13 +1,9 @@ package org.thoughtcrime.securesms.webrtc import android.Manifest -import android.app.KeyguardManager import android.content.Context -import android.content.Intent -import android.os.PowerManager -import androidx.core.content.ContextCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.coroutineScope +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -17,7 +13,6 @@ 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.NonTranslatableStringConstants import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.ANSWER @@ -27,41 +22,29 @@ import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.OFFER import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.PRE_OFFER import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.PROVISIONAL_ANSWER import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.ThreadUtils -import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.permissions.Permissions -import org.thoughtcrime.securesms.service.WebRtcCallService import org.webrtc.IceCandidate - -class CallMessageProcessor(private val context: Context, private val textSecurePreferences: TextSecurePreferences, lifecycle: Lifecycle, private val storage: StorageProtocol) { +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CallMessageProcessor @Inject constructor( + @param:ApplicationContext private val context: Context, + private val textSecurePreferences: TextSecurePreferences, + private val storage: StorageProtocol, + private val webRtcBridge: WebRtcCallBridge, + @ManagerScope scope: CoroutineScope +) : OnAppStartupComponent { companion object { private const val TAG = "CallMessageProcessor" private const val VERY_EXPIRED_TIME = 15 * 60 * 1000L - - fun safeStartForegroundService(context: Context, intent: Intent) { - // Wake up the device (if required) before attempting to start any services - otherwise on Android 12 and above we get - // a BackgroundServiceStartNotAllowedException such as: - // Unable to start CallMessage intent: startForegroundService() not allowed due to mAllowStartForeground false: - // service network.loki.messenger/org.thoughtcrime.securesms.service.WebRtcCallService - (context as ApplicationContext).wakeUpDeviceAndDismissKeyguardIfRequired() - - // Attempt to start the call service.. - try { - context.startService(intent) - } catch (e: Exception) { - Log.e("Loki", "Unable to start service: ${e.message}", e) - try { - ContextCompat.startForegroundService(context, intent) - } catch (e2: Exception) { - Log.e(TAG, "Unable to start CallMessage intent: ${e2.message}", e2) - } - } - } } init { - lifecycle.coroutineScope.launch(IO) { + scope.launch(IO) { while (isActive) { val nextMessage = WebRtcUtils.SIGNAL_QUEUE.receive() Log.d("Loki", nextMessage.type?.name ?: "CALL MESSAGE RECEIVED") @@ -107,65 +90,64 @@ class CallMessageProcessor(private val context: Context, private val textSecureP } private fun incomingHangup(callMessage: CallMessage) { + Log.d("", "CallMessageProcessor: incomingHangup") val callId = callMessage.callId ?: return - val hangupIntent = WebRtcCallService.remoteHangupIntent(context, callId) - safeStartForegroundService(context, hangupIntent) + webRtcBridge.handleRemoteHangup(callId) } private fun incomingAnswer(callMessage: CallMessage) { + Log.d("", "CallMessageProcessor: incomingAnswer") val recipientAddress = callMessage.sender ?: return Log.w(TAG, "Cannot answer incoming call without sender") val callId = callMessage.callId ?: return Log.w(TAG, "Cannot answer incoming call without callId" ) val sdp = callMessage.sdps.firstOrNull() ?: return Log.w(TAG, "Cannot answer incoming call without sdp") - val answerIntent = WebRtcCallService.incomingAnswer( - context = context, - address = Address.fromSerialized(recipientAddress), - sdp = sdp, - callId = callId + + webRtcBridge.handleAnswerIncoming( + address = Address.fromSerialized(recipientAddress), + sdp = sdp, + callId = callId ) - safeStartForegroundService(context, answerIntent) } private fun handleIceCandidates(callMessage: CallMessage) { + Log.d("", "CallMessageProcessor: handleIceCandidates") val callId = callMessage.callId ?: return val sender = callMessage.sender ?: return val iceCandidates = callMessage.iceCandidates() if (iceCandidates.isEmpty()) return - val iceIntent = WebRtcCallService.iceCandidates( - context = context, + webRtcBridge.handleRemoteIceCandidate( iceCandidates = iceCandidates, - callId = callId, - address = Address.fromSerialized(sender) + callId = callId ) - safeStartForegroundService(context, iceIntent) } private fun incomingPreOffer(callMessage: CallMessage) { // handle notification state + Log.d("", "CallMessageProcessor: incomingPreOffer") val recipientAddress = callMessage.sender ?: return val callId = callMessage.callId ?: return - val incomingIntent = WebRtcCallService.preOffer( - context = context, - address = Address.fromSerialized(recipientAddress), - callId = callId, - callTime = callMessage.sentTimestamp!! + + webRtcBridge.handlePreOffer( + address = Address.fromSerialized(recipientAddress), + callId = callId, + callTime = callMessage.sentTimestamp!! ) - safeStartForegroundService(context, incomingIntent) } private fun incomingCall(callMessage: CallMessage) { + Log.d("", "CallMessageProcessor: incomingCall") + val recipientAddress = callMessage.sender ?: return val callId = callMessage.callId ?: return val sdp = callMessage.sdps.firstOrNull() ?: return - val incomingIntent = WebRtcCallService.incomingCall( - context = context, - address = Address.fromSerialized(recipientAddress), - sdp = sdp, - callId = callId, - callTime = callMessage.sentTimestamp!! + + webRtcBridge.onIncomingCall( + address = Address.fromSerialized(recipientAddress), + sdp = sdp, + callId = callId, + callTime = callMessage.sentTimestamp!! ) - safeStartForegroundService(context, incomingIntent) } private fun CallMessage.iceCandidates(): List { diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallNotificationBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallNotificationBuilder.kt new file mode 100644 index 0000000000..6738814846 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallNotificationBuilder.kt @@ -0,0 +1,154 @@ +package org.thoughtcrime.securesms.webrtc + +import android.app.Notification +import android.app.PendingIntent +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.notifications.NotificationChannels +import org.thoughtcrime.securesms.webrtc.WebRtcCallBridge.Companion.ACTION_DENY_CALL +import org.thoughtcrime.securesms.webrtc.WebRtcCallBridge.Companion.ACTION_IGNORE_CALL +import org.thoughtcrime.securesms.webrtc.WebRtcCallBridge.Companion.ACTION_LOCAL_HANGUP + +class CallNotificationBuilder { + + companion object { + const val WEBRTC_NOTIFICATION = 313388 + + const val TYPE_OUTGOING_RINGING = 2 + const val TYPE_ESTABLISHED = 3 + const val TYPE_INCOMING_CONNECTING = 4 + const val TYPE_INCOMING_PRE_OFFER = 5 + + @JvmStatic + fun areNotificationsEnabled(context: Context): Boolean { + val notificationManager = NotificationManagerCompat.from(context) + return notificationManager.areNotificationsEnabled() + } + + @JvmStatic + fun getCallInProgressNotification(context: Context, type: Int, recipient: Recipient?): Notification { + val contentIntent = WebRtcCallActivity.getCallActivityIntent(context) + + val pendingIntent = PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + val builder = NotificationCompat.Builder(context, NotificationChannels.CALLS) + .setSound(null) + .setSmallIcon(R.drawable.ic_phone) + .setContentIntent(pendingIntent) + .setOngoing(true) + + var recipName = "Unknown" + recipient?.name?.let { name -> + recipName = name + } + + builder.setContentTitle(recipName) + + when (type) { + TYPE_INCOMING_PRE_OFFER -> { + val txt = Phrase.from(context, R.string.callsIncoming).put(NAME_KEY, recipName).format() + builder.setContentText(txt) + .setCategory(NotificationCompat.CATEGORY_CALL) + builder.addAction( + getEndCallNotification( + context, + ACTION_DENY_CALL, + R.drawable.ic_x, + R.string.decline) + ) + // If notifications aren't enabled, we will trigger the intent from WebRtcCallBridge + builder.setFullScreenIntent(getFullScreenPendingIntent(context), true) + builder.addAction( + getActivityNotificationAction( + context, + WebRtcCallActivity.ACTION_ANSWER, + R.drawable.ic_phone, + R.string.accept + ) + ) + builder.priority = NotificationCompat.PRIORITY_MAX + // catch the case where this notification is swiped off, to ignore the call + builder.setDeleteIntent(getEndCallPendingIntent(context, ACTION_IGNORE_CALL)) + // remove notification if tapped on + builder.setAutoCancel(true) + } + + TYPE_INCOMING_CONNECTING -> { + builder.setContentText(context.getString(R.string.callsConnecting)) + .setSilent(true) + } + + TYPE_OUTGOING_RINGING -> { + builder.setContentText(context.getString(R.string.callsConnecting)) + .setSilent(true) + builder.addAction( + getEndCallNotification( + context, + ACTION_LOCAL_HANGUP, + R.drawable.ic_phone_fill_custom, + R.string.cancel + ) + ) + } + else -> { + builder.setContentText(context.getString(R.string.callsInProgress)) + .setSilent(true) + builder.addAction( + getEndCallNotification( + context, + ACTION_LOCAL_HANGUP, + R.drawable.ic_phone_fill_custom, + R.string.callsEnd + ) + ).setUsesChronometer(true) + } + } + + return builder.build() + } + + private fun getFullScreenPendingIntent(context: Context): PendingIntent { + val intent = WebRtcCallActivity.getCallActivityIntent(context) + .setAction(WebRtcCallActivity.ACTION_FULL_SCREEN_INTENT) + return PendingIntent.getActivity(context, 1, intent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + + private fun getEndCallNotification(context: Context, action: String, + @DrawableRes iconResId: Int, @StringRes titleResId: Int): NotificationCompat.Action { + return NotificationCompat.Action( + iconResId, context.getString(titleResId), + getEndCallPendingIntent(context, action) + ) + } + + private fun getEndCallPendingIntent(context: Context, action: String): PendingIntent{ + val actionIntent = Intent(context, EndCallReceiver::class.java).apply { + this.action = action + component = ComponentName(context, EndCallReceiver::class.java) + } + + return PendingIntent.getBroadcast(context, 0, actionIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + + private fun getActivityNotificationAction(context: Context, action: String, + @DrawableRes iconResId: Int, @StringRes titleResId: Int): NotificationCompat.Action { + val intent = WebRtcCallActivity.getCallActivityIntent(context) + .setAction(action) + + val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + return NotificationCompat.Action(iconResId, context.getString(titleResId), pendingIntent) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt index 6337bff5d7..88659c4dfc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt @@ -1,37 +1,66 @@ package org.thoughtcrime.securesms.webrtc +import android.content.Context +import androidx.annotation.StringRes import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import org.session.libsession.database.StorageProtocol -import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.stateIn +import network.loki.messenger.R +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.UsernameUtils +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.conversation.v2.ViewUtil +import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_ANSWER_INCOMING +import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_ANSWER_OUTGOING +import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_CONNECTED +import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_DISCONNECTED +import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_HANDLING_ICE +import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_OFFER_INCOMING +import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_OFFER_OUTGOING +import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_PRE_OFFER_INCOMING +import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_PRE_OFFER_OUTGOING +import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_RECONNECTING +import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_SENDING_ICE +import org.thoughtcrime.securesms.webrtc.CallViewModel.State.NETWORK_FAILURE +import org.thoughtcrime.securesms.webrtc.CallViewModel.State.RECIPIENT_UNAVAILABLE import org.webrtc.SurfaceViewRenderer import javax.inject.Inject @HiltViewModel class CallViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val callManager: CallManager, - private val storage: StorageProtocol, + private val rtcCallBridge: WebRtcCallBridge, + private val usernameUtils: UsernameUtils + ): ViewModel() { + //todo PHONE Can we eventually remove this state and instead use the StateMachine.kt State? enum class State { - CALL_PENDING, + CALL_INITIALIZING, // default starting state before any rtc state kicks in + + CALL_PRE_OFFER_INCOMING, + CALL_PRE_OFFER_OUTGOING, + CALL_OFFER_INCOMING, + CALL_OFFER_OUTGOING, + CALL_ANSWER_INCOMING, + CALL_ANSWER_OUTGOING, + CALL_HANDLING_ICE, + CALL_SENDING_ICE, - CALL_PRE_INIT, - CALL_INCOMING, - CALL_OUTGOING, CALL_CONNECTED, - CALL_RINGING, - CALL_BUSY, CALL_DISCONNECTED, CALL_RECONNECTING, NETWORK_FAILURE, RECIPIENT_UNAVAILABLE, - NO_SUCH_USER, - UNTRUSTED_IDENTITY, } val floatingRenderer: SurfaceViewRenderer? @@ -40,20 +69,11 @@ class CallViewModel @Inject constructor( val fullscreenRenderer: SurfaceViewRenderer? get() = callManager.fullscreenRenderer - var microphoneEnabled: Boolean = true - private set - - var isSpeaker: Boolean = false - private set - val audioDeviceState - get() = callManager.audioDeviceEvents.onEach { - isSpeaker = it.selectedDevice == SignalAudioManager.AudioDevice.SPEAKER_PHONE - } + get() = callManager.audioDeviceEvents val localAudioEnabledState get() = callManager.audioEvents.map { it.isEnabled } - .onEach { microphoneEnabled = it } val videoState: StateFlow get() = callManager.videoState @@ -65,13 +85,130 @@ class CallViewModel @Inject constructor( } val currentCallState get() = callManager.currentCallState - val callState get() = callManager.callStateEvents + + val initialCallState = CallState("", "", false, false, false) + val initialAccumulator = CallAccumulator(emptySet(), initialCallState) + + val callState: StateFlow = callManager.callStateEvents + .combine(rtcCallBridge.hasAcceptedCall) { state, accepted -> + Pair(state, accepted) + } + .scan(initialAccumulator) { acc, (state, accepted) -> + // reset the set on preoffers + val newSteps = if (state in listOf( + CALL_PRE_OFFER_OUTGOING, + CALL_PRE_OFFER_INCOMING + ) + ) { + setOf(state) + } else { + acc.callSteps + state + } + + val callTitle = when (state) { + CALL_PRE_OFFER_OUTGOING, CALL_PRE_OFFER_INCOMING, + CALL_OFFER_OUTGOING, CALL_OFFER_INCOMING -> + context.getString(R.string.callsRinging) + CALL_ANSWER_INCOMING, CALL_ANSWER_OUTGOING -> + context.getString(R.string.callsConnecting) + CALL_CONNECTED -> "" + CALL_RECONNECTING -> context.getString(R.string.callsReconnecting) + RECIPIENT_UNAVAILABLE, CALL_DISCONNECTED -> + context.getString(R.string.callsEnded) + NETWORK_FAILURE -> context.getString(R.string.callsErrorStart) + else -> acc.callState.callLabelTitle // keep previous title + } + + val callSubtitle = when (state) { + CALL_PRE_OFFER_OUTGOING -> constructCallLabel(R.string.creatingCall, newSteps.size) + CALL_PRE_OFFER_INCOMING -> constructCallLabel(R.string.receivingPreOffer, newSteps.size) + CALL_OFFER_OUTGOING -> constructCallLabel(R.string.sendingCallOffer, newSteps.size) + CALL_OFFER_INCOMING -> constructCallLabel(R.string.receivingCallOffer, newSteps.size) + CALL_ANSWER_OUTGOING, CALL_ANSWER_INCOMING -> constructCallLabel(R.string.receivedAnswer, newSteps.size) + CALL_SENDING_ICE -> constructCallLabel(R.string.sendingConnectionCandidates, newSteps.size) + CALL_HANDLING_ICE -> constructCallLabel(R.string.handlingConnectionCandidates, newSteps.size) + else -> "" + } + + val showCallControls = state in listOf( + CALL_CONNECTED, + CALL_PRE_OFFER_OUTGOING, + CALL_OFFER_OUTGOING, + CALL_ANSWER_OUTGOING, + CALL_ANSWER_INCOMING + ) || (state in listOf( + CALL_PRE_OFFER_INCOMING, + CALL_OFFER_INCOMING, + CALL_HANDLING_ICE, + CALL_SENDING_ICE + ) && accepted) + + val showEndCallButton = showCallControls || state == CALL_RECONNECTING + + val showPreCallButtons = state in listOf( + CALL_PRE_OFFER_INCOMING, + CALL_OFFER_INCOMING, + CALL_HANDLING_ICE, + CALL_SENDING_ICE + ) && !accepted + + val newCallState = CallState( + callLabelTitle = callTitle, + callLabelSubtitle = callSubtitle, + showCallButtons = showCallControls, + showPreCallButtons = showPreCallButtons, + showEndCallButton = showEndCallButton + ) + + CallAccumulator(newSteps, newCallState) + } + .map { it.callState } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), initialCallState) + + val recipient get() = callManager.recipientEvents val callStartTime: Long get() = callManager.callStartTime - fun getUserName(accountID: String) = storage.getContactNameWithAccountID(accountID) + data class CallAccumulator( + val callSteps: Set, + val callState: CallState + ) + + private val MAX_CALL_STEPS: Int = 5 - fun swapVideos() { - callManager.swapVideos() + private fun constructCallLabel(@StringRes label: Int, stepsCount: Int): String { + return ViewUtil.safeRTLString(context, "${context.getString(label)} $stepsCount/$MAX_CALL_STEPS") } + + + fun swapVideos() = callManager.swapVideos() + + fun toggleMute() = callManager.toggleMuteAudio() + + fun toggleSpeakerphone() = callManager.toggleSpeakerphone() + + fun toggleVideo() = callManager.toggleVideo() + + fun flipCamera() = callManager.flipCamera() + + fun answerCall() = rtcCallBridge.handleAnswerCall() + + fun denyCall() = rtcCallBridge.handleDenyCall() + + fun createCall(recipientAddress: Address) = + rtcCallBridge.handleOutgoingCall(Recipient.from(context, recipientAddress, true)) + + fun hangUp() = rtcCallBridge.handleLocalHangup(null) + + fun getContactName(accountID: String) = usernameUtils.getContactNameWithAccountID(accountID) + + fun getCurrentUsername() = usernameUtils.getCurrentUsernameWithAccountIdFallback() + + data class CallState( + val callLabelTitle: String?, + val callLabelSubtitle: String, + val showCallButtons: Boolean, + val showPreCallButtons: Boolean, + val showEndCallButton: Boolean + ) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/IncomingPstnCallReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/IncomingPstnCallReceiver.java deleted file mode 100644 index 01b161e088..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/IncomingPstnCallReceiver.java +++ /dev/null @@ -1,74 +0,0 @@ -package org.thoughtcrime.securesms.webrtc; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.os.Handler; -import android.os.ResultReceiver; -import android.telephony.TelephonyManager; - -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.service.WebRtcCallService; - -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; - -/** - * Listens for incoming PSTN calls and rejects them if a RedPhone call is already in progress. - * - * Unstable use of reflection employed to gain access to ITelephony. - * - */ -public class IncomingPstnCallReceiver extends BroadcastReceiver { - - private static final String TAG = IncomingPstnCallReceiver.class.getSimpleName(); - - @Override - public void onReceive(Context context, Intent intent) { - Log.i(TAG, "Checking incoming call..."); - - if (intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER) == null) { - Log.w(TAG, "Telephony event does not contain number..."); - return; - } - - if (!intent.getStringExtra(TelephonyManager.EXTRA_STATE).equals(TelephonyManager.EXTRA_STATE_RINGING)) { - Log.w(TAG, "Telephony event is not state ringing..."); - return; - } - - InCallListener listener = new InCallListener(context, new Handler()); - - WebRtcCallService.isCallActive(context, listener); - } - - private static class InCallListener extends ResultReceiver { - - private final Context context; - - InCallListener(Context context, Handler handler) { - super(handler); - this.context = context.getApplicationContext(); - } - - protected void onReceiveResult(int resultCode, Bundle resultData) { - if (resultCode == 1) { - Log.i(TAG, "Attempting to deny incoming PSTN call."); - - TelephonyManager tm = (TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE); - - try { - Method getTelephony = tm.getClass().getDeclaredMethod("getITelephony"); - getTelephony.setAccessible(true); - Object telephonyService = getTelephony.invoke(tm); - Method endCall = telephonyService.getClass().getDeclaredMethod("endCall"); - endCall.invoke(telephonyService); - Log.i(TAG, "Denied Incoming Call."); - } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { - Log.w(TAG, "Unable to access ITelephony API", e); - } - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/OrientationManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/OrientationManager.kt similarity index 92% rename from app/src/main/java/org/thoughtcrime/securesms/calls/OrientationManager.kt rename to app/src/main/java/org/thoughtcrime/securesms/webrtc/OrientationManager.kt index baae40bcb2..c16c9f93f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/OrientationManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/OrientationManager.kt @@ -1,16 +1,14 @@ -package org.thoughtcrime.securesms.calls +package org.thoughtcrime.securesms.webrtc import android.content.Context +import android.content.Context.SENSOR_SERVICE import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager import android.provider.Settings -import androidx.core.content.ContextCompat.getSystemService import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity.SENSOR_SERVICE -import org.thoughtcrime.securesms.webrtc.Orientation import kotlin.math.asin class OrientationManager(private val context: Context): SensorEventListener { 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 eda4c1a39b..07982946fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt @@ -133,13 +133,22 @@ class PeerConnectionWrapper(private val context: Context, } fun dispose() { - camera.dispose() + try { + camera.dispose() + } catch (e: Exception){ Log.w("", "Camera already disposed")} - videoSource?.dispose() + try { + videoSource?.dispose() + } catch (e: Exception){ Log.w("", "Video source already disposed")} - audioSource.dispose() - peerConnection?.close() - peerConnection?.dispose() + try { + audioSource.dispose() + } catch (e: Exception){ Log.w("", "Audio source already disposed")} + + try { + peerConnection?.close() + peerConnection?.dispose() + } catch (e: Exception){ Log.w("", "Peer connection source already disposed")} } fun setNewRemoteDescription(description: SessionDescription) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallActivity.kt new file mode 100644 index 0000000000..ae14591234 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallActivity.kt @@ -0,0 +1,428 @@ +package org.thoughtcrime.securesms.webrtc + +import android.Manifest +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.content.IntentFilter +import android.content.res.ColorStateList +import android.graphics.Outline +import android.media.AudioManager +import android.os.Build +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewOutlineProvider +import android.view.WindowManager +import androidx.activity.viewModels +import androidx.core.content.IntentCompat +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import dagger.hilt.android.AndroidEntryPoint +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.ActivityWebrtcBinding +import org.apache.commons.lang3.time.DurationFormatUtils +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.getColorFromAttr +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.ScreenLockActionBarActivity +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_CONNECTED +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager.AudioDevice.SPEAKER_PHONE +import java.time.Duration + +@AndroidEntryPoint +class WebRtcCallActivity : ScreenLockActionBarActivity() { + + companion object { + const val ACTION_FULL_SCREEN_INTENT = "fullscreen-intent" + const val ACTION_ANSWER = "answer" + const val ACTION_END = "end-call" + const val ACTION_START_CALL = "start-call" + + const val EXTRA_RECIPIENT_ADDRESS = "RECIPIENT_ID" + + fun getCallActivityIntent(context: Context): Intent{ + return Intent(context, WebRtcCallActivity::class.java) + .setFlags(FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_HISTORY or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + } + } + + private val viewModel by viewModels() + private lateinit var binding: ActivityWebrtcBinding + private var uiJob: Job? = null + private var hangupReceiver: BroadcastReceiver? = null + + private val CALL_DURATION_FORMAT_HOURS = "HH:mm:ss" + private val CALL_DURATION_FORMAT_MINS = "mm:ss" + private val ONE_HOUR: Long = Duration.ofHours(1).toMillis() + + private val buttonColorEnabled by lazy { getColor(R.color.white) } + private val buttonColorDisabled by lazy { getColorFromAttr(R.attr.disabled) } + + /** + * We need to track the device's orientation so we can calculate whether or not to rotate the video streams + * This works a lot better than using `OrientationEventListener > onOrientationChanged' + * which gives us a rotation angle that doesn't take into account pitch vs roll, so tipping the device from front to back would + * trigger the video rotation logic, while we really only want it when the device is in portrait or landscape. + */ + private var orientationManager = OrientationManager(this) + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + finish() + return true + } + return super.onOptionsItemSelected(item) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleIntent(intent) + } + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + super.onCreate(savedInstanceState, ready) + + binding = ActivityWebrtcBinding.inflate(layoutInflater) + setContentView(binding.root) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } + + window.addFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + or WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + or WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON + ) + volumeControlStream = AudioManager.STREAM_VOICE_CALL + + binding.floatingRendererContainer.setOnClickListener { + viewModel.swapVideos() + } + + binding.microphoneButton.setOnClickListener { + viewModel.toggleMute() + } + + binding.speakerPhoneButton.setOnClickListener { + viewModel.toggleSpeakerphone() + } + + binding.acceptCallButton.setOnClickListener { + answerCall() + } + + binding.declineCallButton.setOnClickListener { + denyCall() + } + + hangupReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + Log.d("", "Received hangup broadcast in webrtc activity - finishing.") + finish() + } + } + + LocalBroadcastManager.getInstance(this) + .registerReceiver(hangupReceiver!!, IntentFilter(ACTION_END)) + + binding.enableCameraButton.setOnClickListener { + Permissions.with(this) + .request(Manifest.permission.CAMERA) + .onAllGranted { + viewModel.toggleVideo() + } + .execute() + } + + binding.switchCameraButton.setOnClickListener { + viewModel.flipCamera() + } + + binding.endCallButton.setOnClickListener { + hangUp() + } + binding.backArrow.setOnClickListener { + onBackPressed() + } + + lifecycleScope.launch { + orientationManager.orientation.collect { orientation -> + viewModel.deviceOrientation = orientation + updateControlsRotation() + } + } + + clipFloatingInsets() + + // set up the user avatar + TextSecurePreferences.getLocalNumber(this)?.let{ + binding.userAvatar.apply { + publicKey = it + displayName = viewModel.getCurrentUsername() + update() + } + } + + handleIntent(intent) + } + + private fun handleIntent(intent: Intent) { + Log.d("", "Web RTC activity handle intent ${intent.action}") + if (intent.action == ACTION_START_CALL && intent.hasExtra(EXTRA_RECIPIENT_ADDRESS)) { + viewModel.createCall(IntentCompat.getParcelableExtra(intent, EXTRA_RECIPIENT_ADDRESS, Address::class.java)!!) + } + if (intent.action == ACTION_ANSWER) { + answerCall() + } + + if (intent.action == ACTION_FULL_SCREEN_INTENT) { + supportActionBar?.setDisplayHomeAsUpEnabled(false) + } + } + + /** + * Makes sure the floating video inset has clipped rounded corners, included with the video stream itself + */ + private fun clipFloatingInsets() { + // clip the video inset with rounded corners + val videoInsetProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + // all corners + outline.setRoundRect( + 0, 0, view.width, view.height, + resources.getDimensionPixelSize(R.dimen.video_inset_radius).toFloat() + ) + } + } + + binding.floatingRendererContainer.outlineProvider = videoInsetProvider + binding.floatingRendererContainer.clipToOutline = true + } + + override fun onResume() { + super.onResume() + orientationManager.startOrientationListener() + + } + + override fun onPause() { + super.onPause() + orientationManager.stopOrientationListener() + } + + override fun onDestroy() { + super.onDestroy() + hangupReceiver?.let { receiver -> + LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver) + } + + orientationManager.destroy() + } + + private fun answerCall() { + viewModel.answerCall() + } + + private fun denyCall(){ + viewModel.denyCall() + } + + private fun hangUp(){ + viewModel.hangUp() + } + + private fun updateControlsRotation() { + with (binding) { + val rotation = when(viewModel.deviceOrientation){ + Orientation.LANDSCAPE -> -90f + Orientation.REVERSED_LANDSCAPE -> 90f + else -> 0f + } + + userAvatar.animate().cancel() + userAvatar.animate().rotation(rotation).start() + contactAvatar.animate().cancel() + contactAvatar.animate().rotation(rotation).start() + + speakerPhoneButton.animate().cancel() + speakerPhoneButton.animate().rotation(rotation).start() + + microphoneButton.animate().cancel() + microphoneButton.animate().rotation(rotation).start() + + enableCameraButton.animate().cancel() + enableCameraButton.animate().rotation(rotation).start() + + switchCameraButton.animate().cancel() + switchCameraButton.animate().rotation(rotation).start() + + endCallButton.animate().cancel() + endCallButton.animate().rotation(rotation).start() + } + } + + private fun updateControls(callState: CallViewModel.CallState) { + with(binding) { + // set up title and subtitle + callTitle.text = callState.callLabelTitle ?: callTitle.text // keep existing text if null + + callSubtitle.text = callState.callLabelSubtitle + callSubtitle.isVisible = callSubtitle.text.isNotEmpty() + + // buttons visibility + controlGroup.isVisible = callState.showCallButtons + endCallButton.isVisible = callState.showEndCallButton + incomingControlGroup.isVisible = callState.showPreCallButtons + } + } + + override fun onStart() { + super.onStart() + + uiJob = lifecycleScope.launch { + + launch { + viewModel.audioDeviceState.collect { state -> + val speakerEnabled = state.selectedDevice == SPEAKER_PHONE + // change drawable background to enabled or not + binding.speakerPhoneButton.isSelected = speakerEnabled + } + } + + launch { + viewModel.callState.collect { data -> + updateControls(data) + } + } + + launch { + viewModel.recipient.collect { latestRecipient -> + binding.contactAvatar.recycle() + + if (latestRecipient.recipient != null) { + val contactPublicKey = latestRecipient.recipient.address.toString() + val contactDisplayName = viewModel.getContactName(contactPublicKey) + supportActionBar?.title = contactDisplayName + binding.remoteRecipientName.text = contactDisplayName + + // sort out the contact's avatar + binding.contactAvatar.apply { + publicKey = contactPublicKey + displayName = contactDisplayName + update() + } + } + } + } + + launch { + while (isActive) { + val startTime = viewModel.callStartTime + if (startTime != -1L) { + if(viewModel.currentCallState == CALL_CONNECTED) { + val duration = System.currentTimeMillis() - startTime + // apply format based on whether the call is more than 1h long + val durationFormat = if (duration > ONE_HOUR) CALL_DURATION_FORMAT_HOURS else CALL_DURATION_FORMAT_MINS + binding.callTitle.text = DurationFormatUtils.formatDuration( + duration, + durationFormat + ) + } + } + + delay(1_000) + } + } + + launch { + viewModel.localAudioEnabledState.collect { isEnabled -> + // change drawable background to enabled or not + binding.microphoneButton.isSelected = !isEnabled + } + } + + // handle video state + launch { + viewModel.videoState.collect { state -> + binding.floatingRenderer.removeAllViews() + binding.fullscreenRenderer.removeAllViews() + + // handle fullscreen video window + if(state.showFullscreenVideo()){ + viewModel.fullscreenRenderer?.let { surfaceView -> + binding.fullscreenRenderer.addView(surfaceView) + binding.fullscreenRenderer.isVisible = true + hideAvatar() + } + } else { + binding.fullscreenRenderer.isVisible = false + showAvatar(state.swapped) + } + + // handle floating video window + if(state.showFloatingVideo()){ + viewModel.floatingRenderer?.let { surfaceView -> + binding.floatingRenderer.addView(surfaceView) + binding.floatingRenderer.isVisible = true + binding.swapViewIcon.bringToFront() + } + } else { + binding.floatingRenderer.isVisible = false + } + + // the floating video inset (empty or not) should be shown + // the moment we have either of the video streams + val showFloatingContainer = state.userVideoEnabled || state.remoteVideoEnabled + binding.floatingRendererContainer.isVisible = showFloatingContainer + binding.swapViewIcon.isVisible = showFloatingContainer + + // make sure to default to the contact's avatar if the floating container is not visible + if (!showFloatingContainer) showAvatar(false) + + // handle buttons + binding.enableCameraButton.isSelected = state.userVideoEnabled + binding.switchCameraButton.isEnabled = state.userVideoEnabled + binding.switchCameraButton.imageTintList = + ColorStateList.valueOf( + if(state.userVideoEnabled) buttonColorEnabled + else buttonColorDisabled + ) + } + } + } + } + + /** + * Shows the avatar image. + * If @showUserAvatar is true, the user's avatar is shown, otherwise the contact's avatar is shown. + */ + private fun showAvatar(showUserAvatar: Boolean) { + binding.userAvatar.isVisible = showUserAvatar + binding.contactAvatar.isVisible = !showUserAvatar + } + + private fun hideAvatar() { + binding.userAvatar.isVisible = false + binding.contactAvatar.isVisible = false + } + + override fun onStop() { + super.onStop() + uiJob?.cancel() + binding.fullscreenRenderer.removeAllViews() + binding.floatingRenderer.removeAllViews() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallBridge.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallBridge.kt new file mode 100644 index 0000000000..8070fbf407 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallBridge.kt @@ -0,0 +1,743 @@ +package org.thoughtcrime.securesms.webrtc + +import android.Manifest +import android.annotation.SuppressLint +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 androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.session.libsession.messaging.calls.CallMessageType +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.FutureTaskListener +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import org.thoughtcrime.securesms.notifications.BackgroundPollWorker +import org.thoughtcrime.securesms.service.CallForegroundService +import org.thoughtcrime.securesms.util.NetworkConnectivity +import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.Companion.TYPE_ESTABLISHED +import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.Companion.TYPE_INCOMING_CONNECTING +import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.Companion.TYPE_INCOMING_PRE_OFFER +import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.Companion.TYPE_OUTGOING_RINGING +import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.Companion.WEBRTC_NOTIFICATION +import org.thoughtcrime.securesms.webrtc.audio.OutgoingRinger +import org.thoughtcrime.securesms.webrtc.data.Event +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 java.util.concurrent.ExecutionException +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import org.thoughtcrime.securesms.webrtc.data.State as CallState + +//todo PHONE We want to eventually remove this bridging class and move the logic here to a better place, probably in the callManager +/** + * A class that used to be an Android system in the old codebase and was replaced by a temporary bridging class ro simplify the transition away from + * system services that handle the call logic. We had to avoid system services in order to circumvent the restrictions around starting a service when + * the app is in the background or killed. + * The idea is to eventually remove this class entirely and move its code in a better place (likely directly in the CallManager) + */ +@Singleton +class WebRtcCallBridge @Inject constructor( + @param:ApplicationContext private val context: Context, + private val callManager: CallManager, + private val networkConnectivity: NetworkConnectivity, + @ManagerScope scope: CoroutineScope, +): CallManager.WebRtcListener, OnAppStartupComponent { + + companion object { + + private val TAG = Log.tag(WebRtcCallBridge::class.java) + + const val ACTION_IGNORE_CALL = "IGNORE_CALL" // like when swiping off a notification. Ends the call without notifying the caller + const val ACTION_DENY_CALL = "DENY_CALL" + const val ACTION_LOCAL_HANGUP = "LOCAL_HANGUP" + + const val EXTRA_RECIPIENT_ADDRESS = "RECIPIENT_ID" + const val EXTRA_CALL_ID = "call_id" + + private const val TIMEOUT_SECONDS = 60L + private const val RECONNECT_SECONDS = 5L + private const val MAX_RECONNECTS = 5 + + } + + private var _hasAcceptedCall: MutableStateFlow = MutableStateFlow(false) // always true for outgoing call and true once the user accepts the call for incoming calls + val hasAcceptedCall: StateFlow = _hasAcceptedCall + + private var currentTimeouts = 0 + private var isNetworkAvailable = true + private var scheduledTimeout: ScheduledFuture<*>? = null + private var scheduledReconnect: ScheduledFuture<*>? = null + + private val serviceExecutor = Executors.newSingleThreadExecutor() + private val timeoutExecutor = Executors.newScheduledThreadPool(1) + + private var wiredHeadsetStateReceiver: WiredHeadsetStateReceiver? = null + private var powerButtonReceiver: PowerButtonReceiver? = null + + init { + callManager.registerListener(this) + _hasAcceptedCall.value = false + isNetworkAvailable = true + registerWiredHeadsetStateReceiver() + + scope.launch { + networkConnectivity.networkAvailable.collectLatest(::networkChange) + } + } + + + @Synchronized + private fun terminate() { + Log.d(TAG, "Terminating rtc service") + context.stopService(Intent(context, CallForegroundService::class.java)) + NotificationManagerCompat.from(context).cancel(WEBRTC_NOTIFICATION) + LocalBroadcastManager.getInstance(context).sendBroadcast(Intent(WebRtcCallActivity.ACTION_END)) + callManager.stop() + _hasAcceptedCall.value = false + currentTimeouts = 0 + isNetworkAvailable = true + scheduledTimeout?.cancel(false) + scheduledReconnect?.cancel(false) + scheduledTimeout = null + scheduledReconnect = null + callManager.postViewModelState(CallViewModel.State.CALL_INITIALIZING) // reset to default state + + //todo PHONE should we refactor ice candidates to be sent prior to answering the call? + + } + + override fun onHangup() { + serviceExecutor.execute { + callManager.handleRemoteHangup() + + if (!hasAcceptedCall.value) { + callManager.recipient?.let { recipient -> + insertMissedCall(recipient, true) + } + } + + terminate() + } + } + + private fun registerWiredHeadsetStateReceiver() { + wiredHeadsetStateReceiver = WiredHeadsetStateReceiver(::handleWiredHeadsetChanged) + context.registerReceiver(wiredHeadsetStateReceiver, IntentFilter(AudioManager.ACTION_HEADSET_PLUG)) + } + + private fun handleBusyCall(address: Address) { + val recipient = getRecipientFromAddress(address) + insertMissedCall(recipient, false) + } + + private fun handleNewOffer(address: Address, sdp: String, callId: UUID) { + Log.d(TAG, "Handle new offer") + val recipient = getRecipientFromAddress(address) + callManager.onNewOffer(sdp, callId, recipient).fail { + Log.e("Loki", "Error handling new offer", it) + callManager.postConnectionError() + terminate() + } + } + + fun onIncomingCall(address: Address, sdp: String, callId: UUID, callTime: Long){ + serviceExecutor.execute { + when { + // same call / new offer + callManager.callId == callId && + callManager.currentConnectionState == CallState.Reconnecting -> { + handleNewOffer(address, sdp, callId) + } + // busy call + callManager.isBusy(context, callId) -> handleBusyCall(address) + // in pre offer + callManager.isPreOffer() -> handleIncomingPreOffer(address, sdp, callId, callTime) + } + } + } + + fun handlePreOffer(address: Address, callId: UUID, callTime: Long) { + serviceExecutor.execute { + Log.d(TAG, "Handle pre offer") + if (!callManager.isIdle()) { + Log.w(TAG, "Handling pre-offer from non-idle state") + return@execute + } + + val recipient = getRecipientFromAddress(address) + + if (isIncomingMessageExpired(callTime)) { + Log.d(TAG, "Pre offer expired - message timestamp was deemed expired: ${System.currentTimeMillis() - callTime}s") + insertMissedCall(recipient, true) + terminate() + return@execute + } + + callManager.onPreOffer(callId, recipient) { + setCallNotification(TYPE_INCOMING_PRE_OFFER, recipient) + callManager.postViewModelState(CallViewModel.State.CALL_PRE_OFFER_INCOMING) + callManager.initializeAudioForCall() + callManager.startIncomingRinger() + callManager.setAudioEnabled(true) + + BackgroundPollWorker.scheduleOnce( + context, + listOf(BackgroundPollWorker.Target.ONE_TO_ONE) + ) + } + } + } + + private fun handleIncomingPreOffer(address: Address, sdp: String, callId: UUID, callTime: Long) { + serviceExecutor.execute { + val recipient = getRecipientFromAddress(address) + val preOffer = callManager.preOfferCallData + if (callManager.isPreOffer() && (preOffer == null || preOffer.callId != callId || preOffer.recipient.address != recipient.address)) { + Log.d(TAG, "Incoming ring from non-matching pre-offer") + return@execute + } + + callManager.onIncomingRing(sdp, callId, recipient, callTime) { + if (_hasAcceptedCall.value) { + setCallNotification(TYPE_INCOMING_CONNECTING, recipient) + } else { + //No need to do anything here as this case is already taken care of from the pre offer that came before + } + callManager.clearPendingIceUpdates() + callManager.postViewModelState(CallViewModel.State.CALL_OFFER_INCOMING) + registerPowerButtonReceiver() + + // if the user has already accepted the incoming call, try to answer again + // (they would have tried to answer when they first accepted + // but it would have silently failed due to the pre offer having not been set yet + if (_hasAcceptedCall.value) handleAnswerCall() + } + } + } + + fun handleOutgoingCall(recipient: Recipient) { + serviceExecutor.execute { + if (!callManager.isIdle()) return@execute + + _hasAcceptedCall.value = true // outgoing calls are automatically set to 'accepted' + callManager.postConnectionEvent(Event.SendPreOffer) { + callManager.recipient = recipient + val callId = UUID.randomUUID() + callManager.callId = callId + + callManager.initializeVideo(context) + + callManager.postViewModelState(CallViewModel.State.CALL_PRE_OFFER_OUTGOING) + callManager.initializeAudioForCall() + callManager.startOutgoingRinger(OutgoingRinger.Type.RINGING) + setCallNotification(TYPE_OUTGOING_RINGING, callManager.recipient) + callManager.insertCallMessage( + recipient.address.toString(), + CallMessageType.CALL_OUTGOING + ) + scheduledTimeout = timeoutExecutor.schedule( + TimeoutRunnable(callId, ::handleCheckTimeout), + TIMEOUT_SECONDS, + TimeUnit.SECONDS + ) + callManager.setAudioEnabled(true) + + val expectedState = callManager.currentConnectionState + val expectedCallId = callManager.callId + + try { + val offerFuture = callManager.onOutgoingCall(context) + offerFuture.fail { 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) + callManager.postConnectionError() + terminate() + } + } + } + } + + fun handleAnswerCall() { + serviceExecutor.execute { + Log.d(TAG, "Handle answer call") + _hasAcceptedCall.value = true + + val recipient = callManager.recipient ?: return@execute Log.e( + TAG, + "No recipient to answer in handleAnswerCall" + ) + setCallNotification(TYPE_INCOMING_CONNECTING, recipient) + + if (callManager.pendingOffer == null) { + return@execute Log.e(TAG, "No pending offer in handleAnswerCall") + } + + val callId = callManager.callId ?: return@execute Log.e(TAG, "No callId in handleAnswerCall") + + val timestamp = callManager.pendingOfferTime + + if (callManager.currentConnectionState != CallState.RemoteRing) { + Log.e(TAG, "Can only answer from ringing!") + return@execute + } + + if (isIncomingMessageExpired(timestamp)) { + val didHangup = callManager.postConnectionEvent(Event.TimeOut) { + Log.d(TAG, "Answer expired - message timestamp was deemed expired: ${System.currentTimeMillis() - timestamp}s") + insertMissedCall( + recipient, + true + ) //todo PHONE do we want a missed call in this case? Or just [xxx] called you ? + terminate() + } + if (didHangup) { + return@execute + } + } + + callManager.postConnectionEvent(Event.SendAnswer) { + callManager.silenceIncomingRinger() + + callManager.postViewModelState(CallViewModel.State.CALL_ANSWER_INCOMING) + + scheduledTimeout = timeoutExecutor.schedule( + TimeoutRunnable(callId, ::handleCheckTimeout), + TIMEOUT_SECONDS, + TimeUnit.SECONDS + ) + + callManager.initializeAudioForCall() + callManager.initializeVideo(context) + + val expectedState = callManager.currentConnectionState + val expectedCallId = callManager.callId + + try { + val answerFuture = callManager.onIncomingCall(context) + answerFuture.fail { e -> + if (isConsistentState( + expectedState, + expectedCallId, + callManager.currentConnectionState, + callManager.callId + ) + ) { + Log.e(TAG, "incoming call error: $e") + insertMissedCall( + recipient, + true + ) //todo PHONE do we want a missed call in this case? Or just [xxx] called you ? + callManager.postConnectionError() + terminate() + } + } + } catch (e: Exception) { + Log.e(TAG, e) + callManager.postConnectionError() + terminate() + } + } + } + } + + fun handleDenyCall() { + serviceExecutor.execute { + callManager.handleDenyCall() + terminate() + } + } + + fun handleIgnoreCall(){ + serviceExecutor.execute { + callManager.handleIgnoreCall() + terminate() + } + } + + fun handleLocalHangup(recipient: Recipient?) { + serviceExecutor.execute { + callManager.handleLocalHangup(recipient) + terminate() + } + } + + fun handleRemoteHangup(callId: UUID) { + serviceExecutor.execute { + if (callManager.callId != callId) { + Log.e(TAG, "Hangup for non-active call...") + return@execute + } + + onHangup() + } + } + + private fun handleWiredHeadsetChanged(enabled: Boolean) { + callManager.handleWiredHeadsetChanged(enabled) + } + + private fun handleScreenOffChange() { + callManager.handleScreenOffChange() + } + + fun handleAnswerIncoming(address: Address, sdp: String, callId: UUID) { + serviceExecutor.execute { + try { + val recipient = getRecipientFromAddress(address) + if (recipient.isLocalNumber && callManager.currentConnectionState in CallState.CAN_DECLINE_STATES) { + handleLocalHangup(recipient) + return@execute + } + + callManager.postViewModelState(CallViewModel.State.CALL_ANSWER_OUTGOING) + + callManager.handleResponseMessage( + recipient, + callId, + SessionDescription(SessionDescription.Type.ANSWER, sdp) + ) + } catch (e: PeerConnectionException) { + terminate() + } + } + } + + fun handleRemoteIceCandidate(iceCandidates: List, callId: UUID) { + serviceExecutor.execute { + Log.d(TAG, "Handle remote ice") + callManager.handleRemoteIceCandidate(iceCandidates, callId) + } + } + + private fun handleIceConnected() { + serviceExecutor.execute { + val recipient = callManager.recipient ?: return@execute + if (callManager.currentCallState == CallViewModel.State.CALL_CONNECTED) return@execute + Log.d(TAG, "Handle ice connected") + + val connected = callManager.postConnectionEvent(Event.Connect) { + callManager.postViewModelState(CallViewModel.State.CALL_CONNECTED) + setCallNotification(TYPE_ESTABLISHED, recipient) + callManager.startCommunication() + } + if (!connected) { + Log.e("Loki", "Error handling ice connected state transition") + callManager.postConnectionError() + terminate() + } + } + } + + private fun registerPowerButtonReceiver() { + if (powerButtonReceiver == null) { + powerButtonReceiver = PowerButtonReceiver(::handleScreenOffChange) + context.registerReceiver(powerButtonReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF)) + } + } + + private fun handleCheckReconnect(callId: UUID) { + serviceExecutor.execute { + val currentCallId = callManager.callId ?: return@execute + val numTimeouts = ++currentTimeouts + + if (currentCallId == callId && isNetworkAvailable && numTimeouts <= MAX_RECONNECTS) { + Log.i("Loki", "Trying to re-connect") + callManager.networkReestablished() + scheduledTimeout = timeoutExecutor.schedule( + TimeoutRunnable(currentCallId, ::handleCheckTimeout), + 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(currentCallId, ::handleCheckReconnect), + RECONNECT_SECONDS, + TimeUnit.SECONDS + ) + } else { + Log.i("Loki", "Network isn't available, timing out") + handleLocalHangup(null) + } + } + } + + private fun handleCheckTimeout(callId: UUID) { + serviceExecutor.execute { + val currentCallId = callManager.callId ?: return@execute + val callState = callManager.currentConnectionState + + if (currentCallId == callId && (callState !in arrayOf( + CallState.Connected, + CallState.Connecting + )) + ) { + Log.w(TAG, "Timing out call: $callId") + handleLocalHangup(null) + } + } + } + + /** + * This method handles displaying notifications relating to the various call states. + * Those notifications can be shown in two ways: + * - Directly sent by the notification manager + * - Displayed as part of a foreground Service + */ + private fun setCallNotification(type: Int, recipient: Recipient?) { + // send appropriate notification if we have permission + if ( + ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + ) { + when (type) { + // show a notification directly for this case + TYPE_INCOMING_PRE_OFFER -> { + sendNotification(type, recipient) + } + // attempt to show the notification via a service + else -> { + startServiceOrShowNotification(type, recipient) + } + } + + } // otherwise if we do not have permission and we have a pre offer, try to open the activity directly (this won't work if the app is backgrounded/killed) + else if(type == TYPE_INCOMING_PRE_OFFER) { + // Start an intent for the fullscreen call activity + val foregroundIntent = WebRtcCallActivity.getCallActivityIntent(context) + .setAction(WebRtcCallActivity.ACTION_FULL_SCREEN_INTENT) + context.startActivity(foregroundIntent) + } + + } + + @SuppressLint("MissingPermission") + private fun sendNotification(type: Int, recipient: Recipient?){ + NotificationManagerCompat.from(context).notify( + WEBRTC_NOTIFICATION, + CallNotificationBuilder.getCallInProgressNotification(context, type, recipient) + ) + } + + /** + * This will attempt to start a service with an attached notification, + * if the service fails to start a manual notification will be sent + */ + private fun startServiceOrShowNotification(type: Int, recipient: Recipient?){ + try { + ContextCompat.startForegroundService(context, CallForegroundService.startIntent(context, type, recipient)) + } catch (e: Exception) { + Log.e(TAG, "Unable to start Call Service intent: $e") + sendNotification(type, recipient) + } + } + + private fun getRecipientFromAddress(address: Address): Recipient = Recipient.from(context, address, true) + + private fun insertMissedCall(recipient: Recipient, signal: Boolean) { + callManager.insertCallMessage( + threadPublicKey = recipient.address.toString(), + callMessageType = CallMessageType.CALL_MISSED, + signal = signal + ) + } + + private fun isIncomingMessageExpired(timestamp: Long) = + (System.currentTimeMillis() - timestamp) > TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS) + + private fun onDestroy() { + Log.d(TAG, "onDestroy()") + callManager.unregisterListener(this) + wiredHeadsetStateReceiver?.let(context::unregisterReceiver) + powerButtonReceiver?.let(context::unregisterReceiver) + callManager.shutDownAudioManager() + powerButtonReceiver = null + wiredHeadsetStateReceiver = null + _hasAcceptedCall.value = false + currentTimeouts = 0 + isNetworkAvailable = false + } + + private fun networkChange(networkAvailable: Boolean) { + Log.d("Loki", "flipping network available to $networkAvailable") + isNetworkAvailable = networkAvailable + if (networkAvailable && callManager.currentConnectionState == CallState.Connected) { + Log.d("Loki", "Should reconnected") + } + } + + private class CheckReconnectedRunnable( + private val callId: UUID, val checkReconnect: (UUID)->Unit + ) : Runnable { + override fun run() { + checkReconnect(callId) + } + } + + private class TimeoutRunnable( + private val callId: UUID, val onCheckTimeout: (UUID)->Unit + ) : Runnable { + override fun run() { + onCheckTimeout(callId) + } + } + + private abstract class StateAwareListener( + private val expectedState: CallState, + private val expectedCallId: UUID?, + private val getState: () -> Pair + ) : FutureTaskListener { + + companion object { + private val TAG = Log.tag(StateAwareListener::class.java) + } + + override fun onSuccess(result: V) { + if (!isConsistentState()) { + Log.w(TAG, "State has changed since request, aborting success callback...") + } else { + onSuccessContinue(result) + } + } + + override fun onFailure(exception: ExecutionException?) { + if (!isConsistentState()) { + Log.w(TAG, exception) + Log.w(TAG, "State has changed since request, aborting failure callback...") + } else { + exception?.let { + onFailureContinue(it.cause) + } + } + } + + private fun isConsistentState(): Boolean { + val (currentState, currentCallId) = getState() + return expectedState == currentState && expectedCallId == currentCallId + } + + abstract fun onSuccessContinue(result: V) + abstract fun onFailureContinue(throwable: Throwable?) + + } + + private fun isConsistentState( + expectedState: CallState, + expectedCallId: UUID?, + currentState: CallState, + currentCallId: UUID? + ): Boolean { + return expectedState == currentState && expectedCallId == currentCallId + } + + override fun onSignalingChange(p0: PeerConnection.SignalingState?) {} + + override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) { + newState?.let { state -> processIceConnectionChange(state) } + } + + private fun processIceConnectionChange(newState: PeerConnection.IceConnectionState) { + serviceExecutor.execute { + if (newState == CONNECTED) { + scheduledTimeout?.cancel(false) + scheduledReconnect?.cancel(false) + scheduledTimeout = null + scheduledReconnect = null + + handleIceConnected() + } 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, ::handleCheckReconnect), + RECONNECT_SECONDS, + TimeUnit.SECONDS + ) + } else { + Log.i("Loki", "Starting timeout, awaiting new reconnect") + callManager.postConnectionEvent(Event.PrepareForNewOffer) { + scheduledTimeout = timeoutExecutor.schedule( + TimeoutRunnable(callId, ::handleCheckTimeout), + TIMEOUT_SECONDS, + TimeUnit.SECONDS + ) + } + } + } + } ?: run { + handleLocalHangup(null) + } + } + Log.i("Loki", "onIceConnectionChange: $newState") + } + } + + override fun onIceConnectionReceivingChange(p0: Boolean) {} + + override fun onIceGatheringChange(p0: PeerConnection.IceGatheringState?) {} + + override fun onIceCandidate(p0: IceCandidate?) {} + + override fun onIceCandidatesRemoved(p0: Array?) {} + + override fun onAddStream(p0: MediaStream?) {} + + override fun onRemoveStream(p0: MediaStream?) {} + + override fun onDataChannel(p0: DataChannel?) {} + + override fun onRenegotiationNeeded() { + Log.w(TAG, "onRenegotiationNeeded was called!") + } + + override fun onAddTrack(p0: RtpReceiver?, p1: Array?) {} +} \ No newline at end of file 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 dbbbffc3e8..982e83112b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt @@ -3,53 +3,16 @@ 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 dagger.hilt.android.AndroidEntryPoint import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.service.WebRtcCallService import org.thoughtcrime.securesms.webrtc.locks.LockManager +import javax.inject.Inject -class HangUpRtcOnPstnCallAnsweredListener(private val hangupListener: ()->Unit): PhoneStateListener() { - - companion object { - 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) { - hangupListener() - Log.i(TAG, "Device phone call ended Session call.") - } - } -} - -@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() { +class PowerButtonReceiver(val onScreenOffChange: ()->Unit) : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (Intent.ACTION_SCREEN_OFF == intent.action) { - val serviceIntent = Intent(context,WebRtcCallService::class.java) - .setAction(WebRtcCallService.ACTION_SCREEN_OFF) - context.startService(serviceIntent) + onScreenOffChange() } } } @@ -64,13 +27,30 @@ class ProximityLockRelease(private val lockManager: LockManager): Thread.Uncaugh } } -class WiredHeadsetStateReceiver: BroadcastReceiver() { +class WiredHeadsetStateReceiver(val onWiredHeadsetChanged: (Boolean)->Unit): BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val state = intent.getIntExtra("state", -1) - val serviceIntent = Intent(context, WebRtcCallService::class.java) - .setAction(WebRtcCallService.ACTION_WIRED_HEADSET_CHANGE) - .putExtra(WebRtcCallService.EXTRA_AVAILABLE, state != 0) + onWiredHeadsetChanged(state != 0) + } +} - context.startService(serviceIntent) + +@AndroidEntryPoint +class EndCallReceiver(): BroadcastReceiver() { + @Inject + lateinit var webRtcCallBridge: WebRtcCallBridge + + override fun onReceive(context: Context, intent: Intent) { + when(intent.action) { + WebRtcCallBridge.ACTION_LOCAL_HANGUP -> { + webRtcCallBridge.handleLocalHangup(null) + } + + WebRtcCallBridge.ACTION_IGNORE_CALL -> { + webRtcCallBridge.handleIgnoreCall() + } + + else -> webRtcCallBridge.handleDenyCall() + } } } \ No newline at end of file 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 229cbd13dd..de3636f502 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 @@ -11,6 +11,7 @@ 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.Companion import org.thoughtcrime.securesms.webrtc.audio.SignalBluetoothManager.State as BState private val TAG = Log.tag(SignalAudioManager::class.java) @@ -53,9 +54,9 @@ class SignalAudioManager(private val context: Context, private var audioDevices: MutableSet = mutableSetOf() - private val soundPool: SoundPool = androidAudioManager.createSoundPool() - private val connectedSoundId = soundPool.load(context, R.raw.webrtc_completed, 1) - private val disconnectedSoundId = soundPool.load(context, R.raw.webrtc_disconnected, 1) + private val soundPool: SoundPool by lazy { androidAudioManager.createSoundPool() } + private val connectedSoundId by lazy { soundPool.load(context, R.raw.webrtc_completed, 1) } + private val disconnectedSoundId by lazy { soundPool.load(context, R.raw.webrtc_disconnected, 1) } private val incomingRinger = IncomingRinger(context) private val outgoingRinger = OutgoingRinger(context) @@ -289,7 +290,13 @@ class SignalAudioManager(private val context: Context, } private fun selectAudioDevice(device: AudioDevice) { - val actualDevice = if (device == AudioDevice.EARPIECE && audioDevices.contains(AudioDevice.WIRED_HEADSET)) AudioDevice.WIRED_HEADSET else device + // if we are toggling the speaker button back and forth, the code sets the device to earpiece by default + // but really we want to go back to whatever is currently connected, so a wired or blt set if such audio is ready + val actualDevice = when { + device == AudioDevice.EARPIECE && audioDevices.contains(AudioDevice.WIRED_HEADSET) -> AudioDevice.WIRED_HEADSET + device == AudioDevice.EARPIECE && audioDevices.contains(AudioDevice.BLUETOOTH) -> AudioDevice.BLUETOOTH + else -> device + } Log.d(TAG, "selectAudioDevice(): device: $device actualDevice: $actualDevice") if (!audioDevices.contains(actualDevice)) { 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 0a80cacef8..f15325dd05 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 @@ -1,15 +1,21 @@ package org.thoughtcrime.securesms.webrtc.audio import android.Manifest +import android.annotation.SuppressLint import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothHeadset +import android.bluetooth.BluetoothManager 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.AudioDeviceCallback +import android.media.AudioDeviceInfo import android.media.AudioManager +import android.media.AudioManager.GET_DEVICES_OUTPUTS +import android.os.Build import androidx.core.app.ActivityCompat import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.webrtc.AudioManagerCommand @@ -42,6 +48,20 @@ class SignalBluetoothManager( private val bluetoothListener = BluetoothServiceListener() private var bluetoothReceiver: BluetoothHeadsetBroadcastReceiver? = null + private var systemAudioManager: AudioManager? = null + + private val audioDeviceCallback = object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array) { + Log.d(TAG, "onAudioDevicesAdded: ${addedDevices.map { it.productName }}") + handleAudioDevices() + } + + override fun onAudioDevicesRemoved(removedDevices: Array) { + Log.d(TAG, "onAudioDevicesRemoved: ${removedDevices.map { it.productName }}") + handleAudioDevices() + } + } + private val bluetoothTimeout = { onBluetoothTimeout() } fun start() { @@ -57,7 +77,10 @@ class SignalBluetoothManager( bluetoothHeadset = null scoConnectionAttempts = 0 - bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() + systemAudioManager = context.getSystemService(AudioManager::class.java) + systemAudioManager?.registerAudioDeviceCallback(audioDeviceCallback, handler) + + bluetoothAdapter = context.getSystemService(BluetoothManager::class.java)?.adapter if (bluetoothAdapter == null) { Log.i(TAG, "Device does not support Bluetooth") return @@ -74,7 +97,6 @@ class SignalBluetoothManager( } val bluetoothHeadsetFilter = IntentFilter().apply { - addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) addAction(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) } @@ -116,6 +138,9 @@ class SignalBluetoothManager( bluetoothAdapter = null state = State.UNINITIALIZED + + systemAudioManager?.unregisterAudioDeviceCallback(audioDeviceCallback) + systemAudioManager = null } fun startScoAudio(): Boolean { @@ -161,20 +186,26 @@ class SignalBluetoothManager( Log.d(TAG, "updateDevice(): state: $state") - if (state == State.UNINITIALIZED || bluetoothHeadset == null - || ActivityCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { + if (state == State.UNINITIALIZED || bluetoothHeadset == null) { return } - if (bluetoothAdapter!!.getProfileConnectionState(BluetoothProfile.HEADSET) !in arrayOf(BluetoothProfile.STATE_CONNECTED)) { - state = State.UNAVAILABLE - Log.i(TAG, "No connected bluetooth headset") - } else { + if (isAudioRoutedToBluetooth(systemAudioManager?.getDevices(GET_DEVICES_OUTPUTS))) { state = State.AVAILABLE Log.i(TAG, "Connected bluetooth headset.") + } else { + state = State.UNAVAILABLE + Log.i(TAG, "No connected bluetooth headset") } } + private fun isAudioRoutedToBluetooth(devices: Array?): Boolean { + return devices?.any { device -> + device.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || + device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO + } == true + } + private fun updateAudioDeviceState() { audioManager.handleCommand(AudioManagerCommand.UpdateAudioDeviceState) } @@ -228,15 +259,15 @@ class SignalBluetoothManager( updateAudioDeviceState() } - private fun onHeadsetConnectionStateChanged(connectionState: Int) { - Log.i(TAG, "onHeadsetConnectionStateChanged: state: $state connectionState: ${connectionState.toStateString()}") + private fun handleAudioDevices() { + Log.i(TAG, "On Audio device changed") - when (connectionState) { - BluetoothHeadset.STATE_CONNECTED -> { + when (isAudioRoutedToBluetooth(systemAudioManager?.getDevices(GET_DEVICES_OUTPUTS))) { + true -> { scoConnectionAttempts = 0 updateAudioDeviceState() } - BluetoothHeadset.STATE_DISCONNECTED -> { + false -> { stopScoAudio() updateAudioDeviceState() } @@ -292,25 +323,11 @@ class SignalBluetoothManager( private inner class BluetoothHeadsetBroadcastReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - if (intent.action == BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) { - val connectionState: Int = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED) - handler.post { - if (state != State.UNINITIALIZED) { - onHeadsetConnectionStateChanged(connectionState) - } - } - } else if (intent.action == BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) { -// val connectionState: Int = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_AUDIO_DISCONNECTED) -// handler.post { -// if (state != State.UNINITIALIZED) { -// onAudioStateChanged(connectionState, isInitialStickyBroadcast) -// } -// } - } else if (intent.action == AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) { + if (intent.action == AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) { val scoState: Int = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, AudioManager.ERROR) handler.post { if (state != State.UNINITIALIZED) { - onAudioStateChanged(scoState, isInitialStickyBroadcast) + onAudioStateChanged(scoState, this.isInitialStickyBroadcast) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/data/StateMachine.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/data/StateMachine.kt index 9b60d5a563..f3e3f39a9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/data/StateMachine.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/data/StateMachine.kt @@ -1,5 +1,8 @@ package org.thoughtcrime.securesms.webrtc.data +import androidx.compose.runtime.mutableStateOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.webrtc.data.State.Companion.CAN_DECLINE_STATES import org.thoughtcrime.securesms.webrtc.data.State.Companion.CAN_HANGUP_STATES @@ -83,6 +86,7 @@ sealed class Event(vararg val expectedStates: State, val outputState: State) { outputState = State.Disconnected ) + object IgnoreCall : Event(*State.ALL_STATES, outputState = State.Disconnected) object Error : Event(*State.ALL_STATES, outputState = State.Disconnected) object DeclineCall : Event(*CAN_DECLINE_STATES, outputState = State.Disconnected) object Hangup : Event(*CAN_HANGUP_STATES, outputState = State.Disconnected) @@ -90,8 +94,9 @@ sealed class Event(vararg val expectedStates: State, val outputState: State) { } open class StateProcessor(initialState: State) { - private var _currentState: State = initialState - val currentState get() = _currentState + private var _currentStateFlow: MutableStateFlow = MutableStateFlow(initialState) + val currentStateFlow: StateFlow get() = _currentStateFlow + val currentState get() = _currentStateFlow.value open fun processEvent(event: Event, sideEffect: () -> Unit = {}): Boolean { if (currentState in event.expectedStates) { @@ -99,7 +104,7 @@ open class StateProcessor(initialState: State) { "Loki-Call", "succeeded transitioning from ${currentState::class.simpleName} to ${event.outputState::class.simpleName} with ${event::class.simpleName}" ) - _currentState = event.outputState + _currentStateFlow.value = event.outputState sideEffect() return true } diff --git a/app/src/main/proto/SignalService.proto b/app/src/main/proto/SignalService.proto new file mode 100644 index 0000000000..1f47fecae0 --- /dev/null +++ b/app/src/main/proto/SignalService.proto @@ -0,0 +1,315 @@ +syntax = "proto2"; + +package signalservice; + +option java_package = "org.session.libsignal.protos"; +option java_outer_classname = "SignalServiceProtos"; + +message Envelope { + + enum Type { + SESSION_MESSAGE = 6; + CLOSED_GROUP_MESSAGE = 7; + } + + // @required + required Type type = 1; + optional string source = 2; + optional uint32 sourceDevice = 7; + // @required + required uint64 timestamp = 5; + optional bytes content = 8; + optional uint64 serverTimestamp = 10; +} + +message TypingMessage { + + enum Action { + STARTED = 0; + STOPPED = 1; + } + + // @required + required uint64 timestamp = 1; + // @required + required Action action = 2; +} + +message UnsendRequest { + // @required + required uint64 timestamp = 1; + // @required + required string author = 2; +} + +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; + optional TypingMessage typingMessage = 6; + optional DataExtractionNotification dataExtractionNotification = 8; + optional UnsendRequest unsendRequest = 9; + optional MessageRequestResponse messageRequestResponse = 10; + optional ExpirationType expirationType = 12; + optional uint32 expirationTimer = 13; + optional uint64 sigTimestamp = 15; + + reserved 14; + reserved 11; // Used to be a "sharedConfigMessage" but no longer used + reserved 7; // Used to be a "configurationMessage" but it has been deleted +} + +message KeyPair { + // @required + required bytes publicKey = 1; + // @required + required bytes privateKey = 2; +} + +message DataExtractionNotification { + + enum Type { + SCREENSHOT = 1; + MEDIA_SAVED = 2; // timestamp + } + + // @required + required Type type = 1; + optional uint64 timestamp = 2; +} + +message DataMessage { + + enum Flags { + EXPIRATION_TIMER_UPDATE = 2; + } + + message Quote { + + message QuotedAttachment { + + enum Flags { + VOICE_MESSAGE = 1; + } + + optional string contentType = 1; + optional string fileName = 2; + optional AttachmentPointer thumbnail = 3; + optional uint32 flags = 4; + } + + // @required + required uint64 id = 1; + // @required + required string author = 2; + optional string text = 3; + repeated QuotedAttachment attachments = 4; + } + + message Preview { + // @required + required string url = 1; + optional string title = 2; + optional AttachmentPointer image = 3; + } + + message LokiProfile { + optional string displayName = 1; + optional string profilePicture = 2; + } + + message OpenGroupInvitation { + // @required + required string url = 1; + // @required + required string name = 3; + } + + // New closed group update messages + message GroupUpdateMessage { + optional GroupUpdateInviteMessage inviteMessage = 1; + optional GroupUpdateInfoChangeMessage infoChangeMessage = 2; + optional GroupUpdateMemberChangeMessage memberChangeMessage = 3; + optional GroupUpdatePromoteMessage promoteMessage = 4; + optional GroupUpdateMemberLeftMessage memberLeftMessage = 5; + optional GroupUpdateInviteResponseMessage inviteResponse = 6; + optional GroupUpdateDeleteMemberContentMessage deleteMemberContent = 7; + optional GroupUpdateMemberLeftNotificationMessage memberLeftNotificationMessage = 8; + } + + // New closed groups + message GroupUpdateInviteMessage { + // @required + required string groupSessionId = 1; // The `groupIdentityPublicKey` with a `03` prefix + // @required + required string name = 2; + // @required + required bytes memberAuthData = 3; + // @required + required bytes adminSignature = 4; + } + + message GroupUpdateDeleteMessage { + repeated string memberSessionIds = 1; + // @required + // signature of "DELETE" || timestamp || sessionId[0] || ... || sessionId[n] + required bytes adminSignature = 2; + } + + message GroupUpdatePromoteMessage { + // @required + required bytes groupIdentitySeed = 1; + // @required + required string name = 2; + } + + message GroupUpdateInfoChangeMessage { + enum Type { + NAME = 1; + AVATAR = 2; + DISAPPEARING_MESSAGES = 3; + } + + // @required + required Type type = 1; + optional string updatedName = 2; + optional uint32 updatedExpiration = 3; + // @required + // "INFO_CHANGE" || type || timestamp + required bytes adminSignature = 4; + } + + message GroupUpdateMemberChangeMessage { + enum Type { + ADDED = 1; + REMOVED = 2; + PROMOTED = 3; + } + + // @required + required Type type = 1; + repeated string memberSessionIds = 2; + optional bool historyShared = 3; + // @required + // "MEMBER_CHANGE" || type || timestamp + required bytes adminSignature = 4; + } + + message GroupUpdateMemberLeftMessage { + // the pubkey of the member left is included as part of the closed group encryption logic (senderIdentity on desktop) + } + + message GroupUpdateInviteResponseMessage { + // @required + required bool isApproved = 1; // Whether the request was approved + } + + message GroupUpdateDeleteMemberContentMessage { + repeated string memberSessionIds = 1; + repeated string messageHashes = 2; + optional bytes adminSignature = 3; + } + + message GroupUpdateMemberLeftNotificationMessage { + // the pubkey of the member left is included as part of the closed group encryption logic (senderIdentity on desktop) + } + + message Reaction { + enum Action { + REACT = 0; + REMOVE = 1; + } + // @required + required uint64 id = 1; + // @required + required string author = 2; + optional string emoji = 3; + // @required + required Action action = 4; + } + + optional string body = 1; + repeated AttachmentPointer attachments = 2; + 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 string syncTarget = 105; + optional bool blocksCommunityMessageRequests = 106; + optional GroupUpdateMessage groupUpdateMessage = 120; + + reserved 104; // Used to be "closedGroupControlMessage" but it has been deleted +} + +message CallMessage { + + enum Type { + PRE_OFFER = 6; + OFFER = 1; + ANSWER = 2; + PROVISIONAL_ANSWER = 3; + ICE_CANDIDATES = 4; + END_CALL = 5; + } + + // Multiple ICE candidates may be batched together for performance + + // @required + required Type type = 1; + repeated string sdps = 2; + repeated uint32 sdpMLineIndexes = 3; + repeated string sdpMids = 4; + // @required + required string uuid = 5; +} + +message MessageRequestResponse { + // @required + required bool isApproved = 1; + optional bytes profileKey = 2; + optional DataMessage.LokiProfile profile = 3; +} + +message ReceiptMessage { + + enum Type { + DELIVERY = 0; + READ = 1; + } + + // @required + required Type type = 1; + repeated uint64 timestamp = 2; +} + +message AttachmentPointer { + + enum Flags { + VOICE_MESSAGE = 1; + } + + // @required + required fixed64 id = 1; + optional string contentType = 2; + optional bytes key = 3; + optional uint32 size = 4; + optional bytes thumbnail = 5; + optional bytes digest = 6; + optional string fileName = 7; + optional uint32 flags = 8; + optional uint32 width = 9; + optional uint32 height = 10; + optional string caption = 11; + optional string url = 101; +} \ No newline at end of file diff --git a/libsignal/protobuf/Utils.proto b/app/src/main/proto/Utils.proto similarity index 100% rename from libsignal/protobuf/Utils.proto rename to app/src/main/proto/Utils.proto diff --git a/libsignal/protobuf/WebSocketResources.proto b/app/src/main/proto/WebSocketResources.proto similarity index 100% rename from libsignal/protobuf/WebSocketResources.proto rename to app/src/main/proto/WebSocketResources.proto diff --git a/app/src/main/protobuf/Backups.proto b/app/src/main/protobuf/Backups.proto deleted file mode 100644 index c7efc39146..0000000000 --- a/app/src/main/protobuf/Backups.proto +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Copyright (C) 2018 Open Whisper Systems - * - * Licensed according to the LICENSE file in this repository. - */ - -package signal; - -option java_package = "org.thoughtcrime.securesms.backup"; -option java_outer_classname = "BackupProtos"; - -message SqlStatement { - message SqlParameter { - optional string stringParamter = 1; - optional uint64 integerParameter = 2; - optional double doubleParameter = 3; - optional bytes blobParameter = 4; - optional bool nullparameter = 5; - } - - optional string statement = 1; - repeated SqlParameter parameters = 2; -} - -message SharedPreference { - optional string file = 1; - optional string key = 2; - optional string value = 3; -} - -message Attachment { - optional uint64 rowId = 1; - optional uint64 attachmentId = 2; - optional uint32 length = 3; -} - -message Sticker { - optional uint64 rowId = 1; - optional uint32 length = 2; -} - -message Avatar { - optional string name = 1; - optional uint32 length = 2; -} - -message DatabaseVersion { - optional uint32 version = 1; -} - -message Header { - optional bytes iv = 1; - optional bytes salt = 2; -} - -message BackupFrame { - optional Header header = 1; - optional SqlStatement statement = 2; - optional SharedPreference preference = 3; - optional Attachment attachment = 4; - optional DatabaseVersion version = 5; - optional bool end = 6; - optional Avatar avatar = 7; - optional Sticker sticker = 8; -} \ No newline at end of file diff --git a/app/src/main/protobuf/Makefile b/app/src/main/protobuf/Makefile deleted file mode 100644 index 82129821de..0000000000 --- a/app/src/main/protobuf/Makefile +++ /dev/null @@ -1,3 +0,0 @@ - -all: - protoc --java_out=../java/ WebRtcData.proto Backups.proto DeviceName.proto diff --git a/app/src/main/protobuf/WebRtcData.proto b/app/src/main/protobuf/WebRtcData.proto deleted file mode 100644 index 3e6c3fd713..0000000000 --- a/app/src/main/protobuf/WebRtcData.proto +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Copyright (C) 2014-2016 Open Whisper Systems - * - * Licensed according to the LICENSE file in this repository. - */ - -package signal; - -option java_package = "org.thoughtcrime.securesms.webrtc"; -option java_outer_classname = "WebRtcDataProtos"; - -message Connected { - optional uint64 id = 1; -} - -message Hangup { - optional uint64 id = 1; -} - -message VideoStreamingStatus { - optional uint64 id = 1; - optional bool enabled = 2; -} - -message Data { - - optional Connected connected = 1; - optional Hangup hangup = 2; - optional VideoStreamingStatus videoStreamingStatus = 3; - -} \ No newline at end of file diff --git a/app/src/main/res/animator/appbar_elevation.xml b/app/src/main/res/animator/appbar_elevation.xml deleted file mode 100644 index 7a7f123d25..0000000000 --- a/app/src/main/res/animator/appbar_elevation.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/color/state_list_call_action_background.xml b/app/src/main/res/color/state_list_call_action_background.xml deleted file mode 100644 index c1a337ee76..0000000000 --- a/app/src/main/res/color/state_list_call_action_background.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/color/state_list_call_action_foreground.xml b/app/src/main/res/color/state_list_call_action_foreground.xml index c312df2a58..d37ead5b28 100644 --- a/app/src/main/res/color/state_list_call_action_foreground.xml +++ b/app/src/main/res/color/state_list_call_action_foreground.xml @@ -1,5 +1,5 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/color/state_list_call_action_mic_background.xml b/app/src/main/res/color/state_list_call_action_mic_background.xml deleted file mode 100644 index f8ec990e18..0000000000 --- a/app/src/main/res/color/state_list_call_action_mic_background.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/color/state_list_call_action_mic_foreground.xml b/app/src/main/res/color/state_list_call_action_mic_foreground.xml new file mode 100644 index 0000000000..e948b69fd0 --- /dev/null +++ b/app/src/main/res/color/state_list_call_action_mic_foreground.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi-v24/ic_call_end_grey600_32dp.webp b/app/src/main/res/drawable-anydpi-v24/ic_call_end_grey600_32dp.webp deleted file mode 100644 index 4f2c8b5d6e..0000000000 Binary files a/app/src/main/res/drawable-anydpi-v24/ic_call_end_grey600_32dp.webp and /dev/null differ diff --git a/app/src/main/res/drawable-anydpi-v24/ic_close_grey600_32dp.webp b/app/src/main/res/drawable-anydpi-v24/ic_close_grey600_32dp.webp deleted file mode 100644 index c1431deff9..0000000000 Binary files a/app/src/main/res/drawable-anydpi-v24/ic_close_grey600_32dp.webp and /dev/null differ diff --git a/app/src/main/res/drawable-anydpi-v24/ic_phone_grey600_32dp.webp b/app/src/main/res/drawable-anydpi-v24/ic_phone_grey600_32dp.webp deleted file mode 100644 index 2fab32234a..0000000000 Binary files a/app/src/main/res/drawable-anydpi-v24/ic_phone_grey600_32dp.webp and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/check.png b/app/src/main/res/drawable-hdpi/check.png deleted file mode 100644 index 9eebc48fee..0000000000 Binary files a/app/src/main/res/drawable-hdpi/check.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/divet_lower_right_light.png b/app/src/main/res/drawable-hdpi/divet_lower_right_light.png deleted file mode 100644 index f5a8ea6ae0..0000000000 Binary files a/app/src/main/res/drawable-hdpi/divet_lower_right_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_audio_dark.png b/app/src/main/res/drawable-hdpi/ic_audio_dark.png deleted file mode 100644 index 6b865ebd6d..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_audio_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_backspace_grey600_24dp.png b/app/src/main/res/drawable-hdpi/ic_backspace_grey600_24dp.png deleted file mode 100644 index a007fb4ce7..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_backspace_grey600_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_block_grey600_18dp.png b/app/src/main/res/drawable-hdpi/ic_block_grey600_18dp.png deleted file mode 100644 index 02982bfbe7..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_block_grey600_18dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_broken_link.png b/app/src/main/res/drawable-hdpi/ic_broken_link.png deleted file mode 100644 index f6e1c1550b..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_broken_link.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_brush_highlight_32.png b/app/src/main/res/drawable-hdpi/ic_brush_highlight_32.png deleted file mode 100644 index 96b0c5fbe6..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_brush_highlight_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_brush_marker_32.png b/app/src/main/res/drawable-hdpi/ic_brush_marker_32.png deleted file mode 100644 index 8c01ce8868..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_brush_marker_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_camera_filled_24.png b/app/src/main/res/drawable-hdpi/ic_camera_filled_24.png deleted file mode 100644 index 9dd7a84582..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_camera_filled_24.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_camera_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_camera_white_24dp.png deleted file mode 100644 index 60b4eed405..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_camera_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_caption_28.png b/app/src/main/res/drawable-hdpi/ic_caption_28.png deleted file mode 100644 index 04931524f8..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_caption_28.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_check_circle_32.png b/app/src/main/res/drawable-hdpi/ic_check_circle_32.png deleted file mode 100644 index 92516b8d12..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_check_circle_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_check_white_48dp.png b/app/src/main/res/drawable-hdpi/ic_check_white_48dp.png deleted file mode 100644 index 2c2ad771f7..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_check_white_48dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_close_white_18dp.png b/app/src/main/res/drawable-hdpi/ic_close_white_18dp.png deleted file mode 100644 index b4e25fbf03..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_close_white_18dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_close_white_48dp.png b/app/src/main/res/drawable-hdpi/ic_close_white_48dp.png deleted file mode 100644 index 6b717e0dda..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_close_white_48dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_create_album_outline_32.png b/app/src/main/res/drawable-hdpi/ic_create_album_outline_32.png deleted file mode 100644 index 7f5da89160..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_create_album_outline_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_create_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_create_white_24dp.png deleted file mode 100644 index ea806946d6..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_create_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_crop_32.png b/app/src/main/res/drawable-hdpi/ic_crop_32.png deleted file mode 100644 index f0b4e36b6d..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_crop_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_crop_lock_32.png b/app/src/main/res/drawable-hdpi/ic_crop_lock_32.png deleted file mode 100644 index fe29189068..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_crop_lock_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_crop_unlock_32.png b/app/src/main/res/drawable-hdpi/ic_crop_unlock_32.png deleted file mode 100644 index 574f40cf74..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_crop_unlock_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_delivery_status_delivered.png b/app/src/main/res/drawable-hdpi/ic_delivery_status_delivered.png deleted file mode 100644 index 089afc0b54..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_delivery_status_delivered.png and /dev/null differ 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 deleted file mode 100644 index c5de641a60..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_delivery_status_failed.png and /dev/null 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 deleted file mode 100644 index bc55b7dfee..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_delivery_status_read.png and /dev/null 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 deleted file mode 100644 index 1b8991d98b..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_delivery_status_sending.png and /dev/null 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 deleted file mode 100644 index 96a7b6340c..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_delivery_status_sent.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_document_large_dark.png b/app/src/main/res/drawable-hdpi/ic_document_large_dark.png deleted file mode 100644 index f17a5078a6..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_document_large_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_document_large_light.png b/app/src/main/res/drawable-hdpi/ic_document_large_light.png deleted file mode 100644 index 1c3bd34be6..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_document_large_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_document_small_dark.png b/app/src/main/res/drawable-hdpi/ic_document_small_dark.png deleted file mode 100644 index 4142741f31..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_document_small_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_emoji_32.png b/app/src/main/res/drawable-hdpi/ic_emoji_32.png deleted file mode 100644 index 03d760f73c..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_emoji_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_error.png b/app/src/main/res/drawable-hdpi/ic_error.png deleted file mode 100644 index 93bf72064e..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_error.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_file_download_white_36dp.png b/app/src/main/res/drawable-hdpi/ic_file_download_white_36dp.png deleted file mode 100644 index 9f23ce8eec..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_file_download_white_36dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_flip_32.png b/app/src/main/res/drawable-hdpi/ic_flip_32.png deleted file mode 100644 index dec80d9981..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_flip_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_gif_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_gif_white_24dp.png deleted file mode 100644 index 0978a141a9..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_gif_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_headset_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_headset_white_24dp.png deleted file mode 100644 index d25d3888e1..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_headset_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_image_dark.png b/app/src/main/res/drawable-hdpi/ic_image_dark.png deleted file mode 100644 index 49cecb88f6..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_image_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_image_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_image_white_24dp.png deleted file mode 100644 index b414cf5b68..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_image_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_info_outline_dark.png b/app/src/main/res/drawable-hdpi/ic_info_outline_dark.png deleted file mode 100644 index 50765d9f65..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_info_outline_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_info_outline_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_info_outline_white_24dp.png deleted file mode 100644 index 4ed40f88d0..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_info_outline_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_insert_drive_file_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_insert_drive_file_white_24dp.png deleted file mode 100644 index 84755e4881..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_insert_drive_file_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_local_dining_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_local_dining_white_24dp.png deleted file mode 100644 index 04dec6088b..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_local_dining_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_location_on_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_location_on_white_24dp.png deleted file mode 100644 index 7c281c3f52..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_location_on_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_lock_dark.png b/app/src/main/res/drawable-hdpi/ic_menu_lock_dark.png deleted file mode 100644 index de260b9069..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_menu_lock_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_message_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_message_white_24dp.png deleted file mode 100644 index dc9341cea4..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_message_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_missing_thumbnail_picture.png b/app/src/main/res/drawable-hdpi/ic_missing_thumbnail_picture.png deleted file mode 100644 index b79b403295..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_missing_thumbnail_picture.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_movie_creation_dark.png b/app/src/main/res/drawable-hdpi/ic_movie_creation_dark.png deleted file mode 100644 index 71d25cbb10..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_movie_creation_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_person_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_person_white_24dp.png deleted file mode 100644 index 56708b0bad..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_person_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_pets_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_pets_white_24dp.png deleted file mode 100644 index 9094bb55a5..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_pets_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_photo_camera_dark.png b/app/src/main/res/drawable-hdpi/ic_photo_camera_dark.png deleted file mode 100644 index cbaacfb0f6..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_photo_camera_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_plus_28.png b/app/src/main/res/drawable-hdpi/ic_plus_28.png deleted file mode 100644 index b23db3489e..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_plus_28.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_profile_default.png b/app/src/main/res/drawable-hdpi/ic_profile_default.png deleted file mode 100644 index f6ed109ece..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_profile_default.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_reply.png b/app/src/main/res/drawable-hdpi/ic_reply.png deleted file mode 100644 index b3bae92895..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_reply.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_reply_white_36dp.png b/app/src/main/res/drawable-hdpi/ic_reply_white_36dp.png deleted file mode 100644 index 3f8076f25b..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_reply_white_36dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_rotate_32.png b/app/src/main/res/drawable-hdpi/ic_rotate_32.png deleted file mode 100644 index e01fe36967..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_rotate_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_switch_camera_32.png b/app/src/main/res/drawable-hdpi/ic_switch_camera_32.png deleted file mode 100644 index 59969ff357..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_switch_camera_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_tag_faces_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_tag_faces_white_24dp.png deleted file mode 100644 index 54d12d1b15..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_tag_faces_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_text_32.png b/app/src/main/res/drawable-hdpi/ic_text_32.png deleted file mode 100644 index cc8bffca71..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_text_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_timer.png b/app/src/main/res/drawable-hdpi/ic_timer.png deleted file mode 100644 index c820ea2b0a..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_timer.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_undo_32.png b/app/src/main/res/drawable-hdpi/ic_undo_32.png deleted file mode 100644 index 8843cd0ab7..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_undo_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_video_dark.png b/app/src/main/res/drawable-hdpi/ic_video_dark.png deleted file mode 100644 index ae43e5f842..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_video_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_volume_off_grey600_18dp.png b/app/src/main/res/drawable-hdpi/ic_volume_off_grey600_18dp.png deleted file mode 100644 index 6cf04dc720..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_volume_off_grey600_18dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_volume_up_dark.png b/app/src/main/res/drawable-hdpi/ic_volume_up_dark.png deleted file mode 100644 index ab9c27c5af..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_volume_up_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_warning_dark.png b/app/src/main/res/drawable-hdpi/ic_warning_dark.png deleted file mode 100644 index 3e808b04ce..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_warning_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_wb_sunny_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_wb_sunny_white_24dp.png deleted file mode 100644 index e0bdc4934d..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_wb_sunny_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_work_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_work_white_24dp.png deleted file mode 100644 index 87c5a053d1..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_work_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_x_28.png b/app/src/main/res/drawable-hdpi/ic_x_28.png deleted file mode 100644 index e2f4911569..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_x_28.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/icon_cached.png b/app/src/main/res/drawable-hdpi/icon_cached.png deleted file mode 100644 index 777153ba4d..0000000000 Binary files a/app/src/main/res/drawable-hdpi/icon_cached.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/timer00.png b/app/src/main/res/drawable-hdpi/timer00.png deleted file mode 100644 index 7674e0065c..0000000000 Binary files a/app/src/main/res/drawable-hdpi/timer00.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/timer05.png b/app/src/main/res/drawable-hdpi/timer05.png deleted file mode 100644 index 259b184434..0000000000 Binary files a/app/src/main/res/drawable-hdpi/timer05.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/timer10.png b/app/src/main/res/drawable-hdpi/timer10.png deleted file mode 100644 index 710c6a0755..0000000000 Binary files a/app/src/main/res/drawable-hdpi/timer10.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/timer15.png b/app/src/main/res/drawable-hdpi/timer15.png deleted file mode 100644 index 47767b4cac..0000000000 Binary files a/app/src/main/res/drawable-hdpi/timer15.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/timer20.png b/app/src/main/res/drawable-hdpi/timer20.png deleted file mode 100644 index 4dcb446ef3..0000000000 Binary files a/app/src/main/res/drawable-hdpi/timer20.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/timer25.png b/app/src/main/res/drawable-hdpi/timer25.png deleted file mode 100644 index 194ebc397a..0000000000 Binary files a/app/src/main/res/drawable-hdpi/timer25.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/timer30.png b/app/src/main/res/drawable-hdpi/timer30.png deleted file mode 100644 index 4cffeef993..0000000000 Binary files a/app/src/main/res/drawable-hdpi/timer30.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/timer35.png b/app/src/main/res/drawable-hdpi/timer35.png deleted file mode 100644 index 3fdbb5e902..0000000000 Binary files a/app/src/main/res/drawable-hdpi/timer35.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/timer40.png b/app/src/main/res/drawable-hdpi/timer40.png deleted file mode 100644 index 43d4607b41..0000000000 Binary files a/app/src/main/res/drawable-hdpi/timer40.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/timer45.png b/app/src/main/res/drawable-hdpi/timer45.png deleted file mode 100644 index ddac60676c..0000000000 Binary files a/app/src/main/res/drawable-hdpi/timer45.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/timer50.png b/app/src/main/res/drawable-hdpi/timer50.png deleted file mode 100644 index ba5cbce030..0000000000 Binary files a/app/src/main/res/drawable-hdpi/timer50.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/timer55.png b/app/src/main/res/drawable-hdpi/timer55.png deleted file mode 100644 index 2eeaad9c9f..0000000000 Binary files a/app/src/main/res/drawable-hdpi/timer55.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/timer60.png b/app/src/main/res/drawable-hdpi/timer60.png deleted file mode 100644 index 85fdb05feb..0000000000 Binary files a/app/src/main/res/drawable-hdpi/timer60.png and /dev/null differ diff --git a/app/src/main/res/drawable-ldrtl/ic_arrow_left.xml b/app/src/main/res/drawable-ldrtl/ic_arrow_left.xml deleted file mode 100644 index fed8ba3b3a..0000000000 --- a/app/src/main/res/drawable-ldrtl/ic_arrow_left.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-ldrtl/ic_arrow_right.xml b/app/src/main/res/drawable-ldrtl/ic_arrow_right.xml deleted file mode 100644 index aaa8c18c9a..0000000000 --- a/app/src/main/res/drawable-ldrtl/ic_arrow_right.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-mdpi/check.png b/app/src/main/res/drawable-mdpi/check.png deleted file mode 100644 index 62b60f287b..0000000000 Binary files a/app/src/main/res/drawable-mdpi/check.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/divet_lower_right_light.png b/app/src/main/res/drawable-mdpi/divet_lower_right_light.png deleted file mode 100644 index 6e7fa1fe6c..0000000000 Binary files a/app/src/main/res/drawable-mdpi/divet_lower_right_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_audio_dark.png b/app/src/main/res/drawable-mdpi/ic_audio_dark.png deleted file mode 100644 index 256c04bab6..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_audio_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_backspace_grey600_24dp.png b/app/src/main/res/drawable-mdpi/ic_backspace_grey600_24dp.png deleted file mode 100644 index 46da3d5acc..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_backspace_grey600_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_block_grey600_18dp.png b/app/src/main/res/drawable-mdpi/ic_block_grey600_18dp.png deleted file mode 100644 index ddf39b3faa..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_block_grey600_18dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_broken_link.png b/app/src/main/res/drawable-mdpi/ic_broken_link.png deleted file mode 100644 index 15d2814580..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_broken_link.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_brush_highlight_32.png b/app/src/main/res/drawable-mdpi/ic_brush_highlight_32.png deleted file mode 100644 index 723199fb18..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_brush_highlight_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_brush_marker_32.png b/app/src/main/res/drawable-mdpi/ic_brush_marker_32.png deleted file mode 100644 index 168381447d..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_brush_marker_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_camera_filled_24.png b/app/src/main/res/drawable-mdpi/ic_camera_filled_24.png deleted file mode 100644 index fa5b99e7bf..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_camera_filled_24.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_camera_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_camera_white_24dp.png deleted file mode 100644 index 57756c01ac..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_camera_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_caption_28.png b/app/src/main/res/drawable-mdpi/ic_caption_28.png deleted file mode 100644 index bfb1305411..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_caption_28.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_check_circle_32.png b/app/src/main/res/drawable-mdpi/ic_check_circle_32.png deleted file mode 100644 index bfb3eb0335..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_check_circle_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_check_white_48dp.png b/app/src/main/res/drawable-mdpi/ic_check_white_48dp.png deleted file mode 100644 index 3b2b65d262..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_check_white_48dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_close_white_18dp.png b/app/src/main/res/drawable-mdpi/ic_close_white_18dp.png deleted file mode 100644 index 01bc75c84a..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_close_white_18dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_close_white_48dp.png b/app/src/main/res/drawable-mdpi/ic_close_white_48dp.png deleted file mode 100644 index b7c7ffd0e7..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_close_white_48dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_create_album_outline_32.png b/app/src/main/res/drawable-mdpi/ic_create_album_outline_32.png deleted file mode 100644 index 52ac25a099..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_create_album_outline_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_create_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_create_white_24dp.png deleted file mode 100644 index f5ddc2f921..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_create_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_crop_32.png b/app/src/main/res/drawable-mdpi/ic_crop_32.png deleted file mode 100644 index 1b57301a1f..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_crop_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_crop_lock_32.png b/app/src/main/res/drawable-mdpi/ic_crop_lock_32.png deleted file mode 100644 index 4170c434eb..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_crop_lock_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_crop_unlock_32.png b/app/src/main/res/drawable-mdpi/ic_crop_unlock_32.png deleted file mode 100644 index 4422a0d144..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_crop_unlock_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_delivery_status_delivered.png b/app/src/main/res/drawable-mdpi/ic_delivery_status_delivered.png deleted file mode 100644 index c66da8360b..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_delivery_status_delivered.png and /dev/null 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 deleted file mode 100644 index b13352d079..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_delivery_status_failed.png and /dev/null 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 deleted file mode 100644 index 072dac7b30..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_delivery_status_read.png and /dev/null 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 deleted file mode 100644 index f9b7fe3b79..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_delivery_status_sending.png and /dev/null 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 deleted file mode 100644 index 26fceea79c..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_delivery_status_sent.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_document_large_dark.png b/app/src/main/res/drawable-mdpi/ic_document_large_dark.png deleted file mode 100644 index 52f8d3654c..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_document_large_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_document_large_light.png b/app/src/main/res/drawable-mdpi/ic_document_large_light.png deleted file mode 100644 index d0b6f2a2ff..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_document_large_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_document_small_dark.png b/app/src/main/res/drawable-mdpi/ic_document_small_dark.png deleted file mode 100644 index 2dad4a5ddc..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_document_small_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_emoji_32.png b/app/src/main/res/drawable-mdpi/ic_emoji_32.png deleted file mode 100644 index 34df7aee85..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_emoji_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_error.png b/app/src/main/res/drawable-mdpi/ic_error.png deleted file mode 100644 index bf1a9f376d..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_error.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_file_download_white_36dp.png b/app/src/main/res/drawable-mdpi/ic_file_download_white_36dp.png deleted file mode 100644 index 6c2665d245..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_file_download_white_36dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_flip_32.png b/app/src/main/res/drawable-mdpi/ic_flip_32.png deleted file mode 100644 index 53d441235d..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_flip_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_gif_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_gif_white_24dp.png deleted file mode 100644 index 1efc6fb72a..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_gif_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_headset_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_headset_white_24dp.png deleted file mode 100644 index df063799d8..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_headset_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_image_dark.png b/app/src/main/res/drawable-mdpi/ic_image_dark.png deleted file mode 100644 index 3d0143714b..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_image_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_image_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_image_white_24dp.png deleted file mode 100644 index d474bd577d..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_image_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_info_outline_dark.png b/app/src/main/res/drawable-mdpi/ic_info_outline_dark.png deleted file mode 100644 index 6a5be39a00..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_info_outline_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_info_outline_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_info_outline_white_24dp.png deleted file mode 100644 index 556a6ff083..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_info_outline_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_insert_drive_file_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_insert_drive_file_white_24dp.png deleted file mode 100644 index b51ce3ed95..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_insert_drive_file_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_local_dining_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_local_dining_white_24dp.png deleted file mode 100644 index 5b68bb59ae..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_local_dining_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_location_on_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_location_on_white_24dp.png deleted file mode 100644 index 933eb5148f..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_location_on_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_lock_dark.png b/app/src/main/res/drawable-mdpi/ic_menu_lock_dark.png deleted file mode 100644 index 530ca8c6e6..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_menu_lock_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_message_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_message_white_24dp.png deleted file mode 100644 index 6979bbe5b1..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_message_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_missing_thumbnail_picture.png b/app/src/main/res/drawable-mdpi/ic_missing_thumbnail_picture.png deleted file mode 100644 index 50e615333b..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_missing_thumbnail_picture.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_movie_creation_dark.png b/app/src/main/res/drawable-mdpi/ic_movie_creation_dark.png deleted file mode 100644 index 1638f00a7c..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_movie_creation_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_person_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_person_white_24dp.png deleted file mode 100644 index f0b1c725da..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_person_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_pets_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_pets_white_24dp.png deleted file mode 100644 index 1194342fb5..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_pets_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_photo_camera_dark.png b/app/src/main/res/drawable-mdpi/ic_photo_camera_dark.png deleted file mode 100644 index cf9524a2f8..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_photo_camera_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_plus_24.png b/app/src/main/res/drawable-mdpi/ic_plus_24.png deleted file mode 100644 index 5a11ea9a1d..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_plus_24.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_plus_28.png b/app/src/main/res/drawable-mdpi/ic_plus_28.png deleted file mode 100644 index 20028b9af5..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_plus_28.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_profile_default.png b/app/src/main/res/drawable-mdpi/ic_profile_default.png deleted file mode 100644 index c17844bd5d..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_profile_default.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_reply.png b/app/src/main/res/drawable-mdpi/ic_reply.png deleted file mode 100644 index ce00dbc4b4..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_reply.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_reply_white_36dp.png b/app/src/main/res/drawable-mdpi/ic_reply_white_36dp.png deleted file mode 100644 index fcf2096dd8..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_reply_white_36dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_rotate_32.png b/app/src/main/res/drawable-mdpi/ic_rotate_32.png deleted file mode 100644 index 8c92376b03..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_rotate_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_switch_camera_32.png b/app/src/main/res/drawable-mdpi/ic_switch_camera_32.png deleted file mode 100644 index f7afd18f28..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_switch_camera_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_tag_faces_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_tag_faces_white_24dp.png deleted file mode 100644 index 01088fa437..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_tag_faces_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_text_32.png b/app/src/main/res/drawable-mdpi/ic_text_32.png deleted file mode 100644 index 17b40ed2c5..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_text_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_timer.png b/app/src/main/res/drawable-mdpi/ic_timer.png deleted file mode 100644 index c999a9e2c6..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_timer.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_undo_32.png b/app/src/main/res/drawable-mdpi/ic_undo_32.png deleted file mode 100644 index badf4d708f..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_undo_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_video_dark.png b/app/src/main/res/drawable-mdpi/ic_video_dark.png deleted file mode 100644 index 4390851edd..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_video_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_volume_off_grey600_18dp.png b/app/src/main/res/drawable-mdpi/ic_volume_off_grey600_18dp.png deleted file mode 100644 index db6550370c..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_volume_off_grey600_18dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_volume_up_dark.png b/app/src/main/res/drawable-mdpi/ic_volume_up_dark.png deleted file mode 100644 index 5b8a65b56d..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_volume_up_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_warning_dark.png b/app/src/main/res/drawable-mdpi/ic_warning_dark.png deleted file mode 100644 index cc81594263..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_warning_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_wb_sunny_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_wb_sunny_white_24dp.png deleted file mode 100644 index 58458b22a5..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_wb_sunny_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_work_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_work_white_24dp.png deleted file mode 100644 index ba06d79a76..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_work_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_x_28.png b/app/src/main/res/drawable-mdpi/ic_x_28.png deleted file mode 100644 index b02b88e2ca..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_x_28.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/icon_cached.png b/app/src/main/res/drawable-mdpi/icon_cached.png deleted file mode 100644 index 5654c49147..0000000000 Binary files a/app/src/main/res/drawable-mdpi/icon_cached.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/timer00.png b/app/src/main/res/drawable-mdpi/timer00.png deleted file mode 100644 index 0cfff1ec1e..0000000000 Binary files a/app/src/main/res/drawable-mdpi/timer00.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/timer05.png b/app/src/main/res/drawable-mdpi/timer05.png deleted file mode 100644 index 4e4502c6db..0000000000 Binary files a/app/src/main/res/drawable-mdpi/timer05.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/timer10.png b/app/src/main/res/drawable-mdpi/timer10.png deleted file mode 100644 index 99f6178335..0000000000 Binary files a/app/src/main/res/drawable-mdpi/timer10.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/timer15.png b/app/src/main/res/drawable-mdpi/timer15.png deleted file mode 100644 index 0f2a68de40..0000000000 Binary files a/app/src/main/res/drawable-mdpi/timer15.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/timer20.png b/app/src/main/res/drawable-mdpi/timer20.png deleted file mode 100644 index 8d5f3b58d8..0000000000 Binary files a/app/src/main/res/drawable-mdpi/timer20.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/timer25.png b/app/src/main/res/drawable-mdpi/timer25.png deleted file mode 100644 index 9527b34208..0000000000 Binary files a/app/src/main/res/drawable-mdpi/timer25.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/timer30.png b/app/src/main/res/drawable-mdpi/timer30.png deleted file mode 100644 index 038d94a9e5..0000000000 Binary files a/app/src/main/res/drawable-mdpi/timer30.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/timer35.png b/app/src/main/res/drawable-mdpi/timer35.png deleted file mode 100644 index 2d3d3f7de4..0000000000 Binary files a/app/src/main/res/drawable-mdpi/timer35.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/timer40.png b/app/src/main/res/drawable-mdpi/timer40.png deleted file mode 100644 index 2a72f42410..0000000000 Binary files a/app/src/main/res/drawable-mdpi/timer40.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/timer45.png b/app/src/main/res/drawable-mdpi/timer45.png deleted file mode 100644 index a5b2b454f0..0000000000 Binary files a/app/src/main/res/drawable-mdpi/timer45.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/timer50.png b/app/src/main/res/drawable-mdpi/timer50.png deleted file mode 100644 index ac0dfc30a6..0000000000 Binary files a/app/src/main/res/drawable-mdpi/timer50.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/timer55.png b/app/src/main/res/drawable-mdpi/timer55.png deleted file mode 100644 index dc887764a4..0000000000 Binary files a/app/src/main/res/drawable-mdpi/timer55.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/timer60.png b/app/src/main/res/drawable-mdpi/timer60.png deleted file mode 100644 index f641da09d5..0000000000 Binary files a/app/src/main/res/drawable-mdpi/timer60.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/check.png b/app/src/main/res/drawable-xhdpi/check.png deleted file mode 100644 index a2e651eeea..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/check.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/divet_lower_right_light.png b/app/src/main/res/drawable-xhdpi/divet_lower_right_light.png deleted file mode 100644 index 9757f0f095..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/divet_lower_right_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_audio_dark.png b/app/src/main/res/drawable-xhdpi/ic_audio_dark.png deleted file mode 100644 index 907ff68a1f..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_audio_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_backspace_grey600_24dp.png b/app/src/main/res/drawable-xhdpi/ic_backspace_grey600_24dp.png deleted file mode 100644 index 12ca458818..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_backspace_grey600_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_block_grey600_18dp.png b/app/src/main/res/drawable-xhdpi/ic_block_grey600_18dp.png deleted file mode 100644 index 3b9c38b4cc..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_block_grey600_18dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_broken_link.png b/app/src/main/res/drawable-xhdpi/ic_broken_link.png deleted file mode 100644 index 5d2c290ed7..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_broken_link.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_brush_highlight_32.png b/app/src/main/res/drawable-xhdpi/ic_brush_highlight_32.png deleted file mode 100644 index decc74514a..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_brush_highlight_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_brush_marker_32.png b/app/src/main/res/drawable-xhdpi/ic_brush_marker_32.png deleted file mode 100644 index 77a8d596b1..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_brush_marker_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_camera_filled_24.png b/app/src/main/res/drawable-xhdpi/ic_camera_filled_24.png deleted file mode 100644 index f91cf25d89..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_camera_filled_24.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_camera_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_camera_white_24dp.png deleted file mode 100644 index 32fa295bc6..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_camera_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_caption_28.png b/app/src/main/res/drawable-xhdpi/ic_caption_28.png deleted file mode 100644 index eeba8d5ca4..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_caption_28.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_check_circle_32.png b/app/src/main/res/drawable-xhdpi/ic_check_circle_32.png deleted file mode 100644 index cb4f9bd360..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_check_circle_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_check_white_48dp.png b/app/src/main/res/drawable-xhdpi/ic_check_white_48dp.png deleted file mode 100644 index d670618c7e..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_check_white_48dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_close_white_18dp.png b/app/src/main/res/drawable-xhdpi/ic_close_white_18dp.png deleted file mode 100644 index ceb1a1eebf..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_close_white_18dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_close_white_48dp.png b/app/src/main/res/drawable-xhdpi/ic_close_white_48dp.png deleted file mode 100644 index 3964192192..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_close_white_48dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_create_album_outline_32.png b/app/src/main/res/drawable-xhdpi/ic_create_album_outline_32.png deleted file mode 100644 index 901e33cc20..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_create_album_outline_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_create_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_create_white_24dp.png deleted file mode 100644 index 548f6638cd..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_create_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_crop_32.png b/app/src/main/res/drawable-xhdpi/ic_crop_32.png deleted file mode 100644 index ebddc2d9bb..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_crop_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_crop_lock_32.png b/app/src/main/res/drawable-xhdpi/ic_crop_lock_32.png deleted file mode 100644 index c0f7380539..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_crop_lock_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_crop_unlock_32.png b/app/src/main/res/drawable-xhdpi/ic_crop_unlock_32.png deleted file mode 100644 index 476a768680..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_crop_unlock_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_delivery_status_delivered.png b/app/src/main/res/drawable-xhdpi/ic_delivery_status_delivered.png deleted file mode 100644 index 5d42fec8f7..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_delivery_status_delivered.png and /dev/null 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 deleted file mode 100644 index 79aaa03f2b..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_delivery_status_failed.png and /dev/null 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 deleted file mode 100644 index af79508abf..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_delivery_status_read.png and /dev/null 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 deleted file mode 100644 index 74b8694dd4..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_delivery_status_sending.png and /dev/null 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 deleted file mode 100644 index 094d8b34ce..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_delivery_status_sent.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_document_large_dark.png b/app/src/main/res/drawable-xhdpi/ic_document_large_dark.png deleted file mode 100644 index cb4e3f6def..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_document_large_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_document_large_light.png b/app/src/main/res/drawable-xhdpi/ic_document_large_light.png deleted file mode 100644 index 24027f5450..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_document_large_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_document_small_dark.png b/app/src/main/res/drawable-xhdpi/ic_document_small_dark.png deleted file mode 100644 index 801b63381f..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_document_small_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_emoji_32.png b/app/src/main/res/drawable-xhdpi/ic_emoji_32.png deleted file mode 100644 index 04536082e8..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_emoji_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_error.png b/app/src/main/res/drawable-xhdpi/ic_error.png deleted file mode 100644 index 5d36611473..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_error.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_file_download_white_36dp.png b/app/src/main/res/drawable-xhdpi/ic_file_download_white_36dp.png deleted file mode 100644 index d508aa948f..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_file_download_white_36dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_flip_32.png b/app/src/main/res/drawable-xhdpi/ic_flip_32.png deleted file mode 100644 index ad44000f30..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_flip_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_gif_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_gif_white_24dp.png deleted file mode 100644 index 80324f70a9..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_gif_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_headset_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_headset_white_24dp.png deleted file mode 100644 index d7a741b612..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_headset_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_image_dark.png b/app/src/main/res/drawable-xhdpi/ic_image_dark.png deleted file mode 100644 index e11a15faf2..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_image_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_image_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_image_white_24dp.png deleted file mode 100644 index 2642b9e09e..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_image_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_info_outline_dark.png b/app/src/main/res/drawable-xhdpi/ic_info_outline_dark.png deleted file mode 100644 index 09a0c88068..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_info_outline_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_info_outline_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_info_outline_white_24dp.png deleted file mode 100644 index ab03ef4d9a..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_info_outline_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_insert_drive_file_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_insert_drive_file_white_24dp.png deleted file mode 100644 index 798ebd4e25..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_insert_drive_file_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_local_dining_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_local_dining_white_24dp.png deleted file mode 100644 index e081e1afb8..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_local_dining_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_location_on_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_location_on_white_24dp.png deleted file mode 100644 index 814ca8ddc4..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_location_on_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_lock_dark.png b/app/src/main/res/drawable-xhdpi/ic_menu_lock_dark.png deleted file mode 100644 index 34b753d588..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_menu_lock_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_message_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_message_white_24dp.png deleted file mode 100644 index 8eedc8a387..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_message_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_missing_thumbnail_picture.png b/app/src/main/res/drawable-xhdpi/ic_missing_thumbnail_picture.png deleted file mode 100644 index e4aaf33a4b..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_missing_thumbnail_picture.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_movie_creation_dark.png b/app/src/main/res/drawable-xhdpi/ic_movie_creation_dark.png deleted file mode 100644 index fc862cc1cd..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_movie_creation_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_person_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_person_white_24dp.png deleted file mode 100644 index aea15f0be5..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_person_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pets_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_pets_white_24dp.png deleted file mode 100644 index f28287f359..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_pets_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_photo_camera_dark.png b/app/src/main/res/drawable-xhdpi/ic_photo_camera_dark.png deleted file mode 100644 index 378b8de05c..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_photo_camera_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_plus_24.png b/app/src/main/res/drawable-xhdpi/ic_plus_24.png deleted file mode 100644 index 1421a562e9..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_plus_24.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_plus_28.png b/app/src/main/res/drawable-xhdpi/ic_plus_28.png deleted file mode 100644 index 362b614b5f..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_plus_28.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_profile_default.png b/app/src/main/res/drawable-xhdpi/ic_profile_default.png deleted file mode 100644 index 79d9beff28..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_profile_default.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_reply.png b/app/src/main/res/drawable-xhdpi/ic_reply.png deleted file mode 100644 index 31df111267..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_reply.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_reply_white_36dp.png b/app/src/main/res/drawable-xhdpi/ic_reply_white_36dp.png deleted file mode 100644 index 0f11be4956..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_reply_white_36dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_rotate_32.png b/app/src/main/res/drawable-xhdpi/ic_rotate_32.png deleted file mode 100644 index 319ab86053..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_rotate_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_switch_camera_32.png b/app/src/main/res/drawable-xhdpi/ic_switch_camera_32.png deleted file mode 100644 index e3a1a6cf82..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_switch_camera_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_tag_faces_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_tag_faces_white_24dp.png deleted file mode 100644 index 4aac3940c6..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_tag_faces_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_text_32.png b/app/src/main/res/drawable-xhdpi/ic_text_32.png deleted file mode 100644 index 368f7282a2..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_text_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_timer.png b/app/src/main/res/drawable-xhdpi/ic_timer.png deleted file mode 100644 index eacdf73a07..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_timer.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_undo_32.png b/app/src/main/res/drawable-xhdpi/ic_undo_32.png deleted file mode 100644 index 3674cff176..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_undo_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_video_dark.png b/app/src/main/res/drawable-xhdpi/ic_video_dark.png deleted file mode 100644 index 76c3e88833..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_video_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_volume_off_grey600_18dp.png b/app/src/main/res/drawable-xhdpi/ic_volume_off_grey600_18dp.png deleted file mode 100644 index 3a074ee278..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_volume_off_grey600_18dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_volume_up_dark.png b/app/src/main/res/drawable-xhdpi/ic_volume_up_dark.png deleted file mode 100644 index d613fa72b7..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_volume_up_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_warning_dark.png b/app/src/main/res/drawable-xhdpi/ic_warning_dark.png deleted file mode 100644 index 2dccad27da..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_warning_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_wb_sunny_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_wb_sunny_white_24dp.png deleted file mode 100644 index 123f780c71..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_wb_sunny_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_work_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_work_white_24dp.png deleted file mode 100644 index 10ddce1027..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_work_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_x_28.png b/app/src/main/res/drawable-xhdpi/ic_x_28.png deleted file mode 100644 index 539ee4ad7f..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_x_28.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/icon_cached.png b/app/src/main/res/drawable-xhdpi/icon_cached.png deleted file mode 100644 index 66a4b60dd3..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/icon_cached.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/timer00.png b/app/src/main/res/drawable-xhdpi/timer00.png deleted file mode 100644 index 74d03ec1e8..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/timer00.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/timer05.png b/app/src/main/res/drawable-xhdpi/timer05.png deleted file mode 100644 index 5536951ed1..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/timer05.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/timer10.png b/app/src/main/res/drawable-xhdpi/timer10.png deleted file mode 100644 index a0efa5a282..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/timer10.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/timer15.png b/app/src/main/res/drawable-xhdpi/timer15.png deleted file mode 100644 index 58ecf78ca9..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/timer15.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/timer20.png b/app/src/main/res/drawable-xhdpi/timer20.png deleted file mode 100644 index ea87eab10c..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/timer20.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/timer25.png b/app/src/main/res/drawable-xhdpi/timer25.png deleted file mode 100644 index edf88b9efc..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/timer25.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/timer30.png b/app/src/main/res/drawable-xhdpi/timer30.png deleted file mode 100644 index 941e0bed73..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/timer30.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/timer35.png b/app/src/main/res/drawable-xhdpi/timer35.png deleted file mode 100644 index 64444a7960..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/timer35.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/timer40.png b/app/src/main/res/drawable-xhdpi/timer40.png deleted file mode 100644 index a57dad11a9..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/timer40.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/timer45.png b/app/src/main/res/drawable-xhdpi/timer45.png deleted file mode 100644 index fff7ec324f..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/timer45.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/timer50.png b/app/src/main/res/drawable-xhdpi/timer50.png deleted file mode 100644 index eba73f7576..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/timer50.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/timer55.png b/app/src/main/res/drawable-xhdpi/timer55.png deleted file mode 100644 index c999a9e2c6..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/timer55.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/timer60.png b/app/src/main/res/drawable-xhdpi/timer60.png deleted file mode 100644 index 4066835b03..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/timer60.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/check.png b/app/src/main/res/drawable-xxhdpi/check.png deleted file mode 100644 index 636216910b..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/check.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/divet_lower_right_light.png b/app/src/main/res/drawable-xxhdpi/divet_lower_right_light.png deleted file mode 100644 index 788898011a..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/divet_lower_right_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_audio_dark.png b/app/src/main/res/drawable-xxhdpi/ic_audio_dark.png deleted file mode 100644 index 85f9998417..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_audio_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_backspace_grey600_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_backspace_grey600_24dp.png deleted file mode 100644 index 5afef7c443..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_backspace_grey600_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_block_grey600_18dp.png b/app/src/main/res/drawable-xxhdpi/ic_block_grey600_18dp.png deleted file mode 100644 index cb43d2814f..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_block_grey600_18dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_broken_link.png b/app/src/main/res/drawable-xxhdpi/ic_broken_link.png deleted file mode 100644 index 1cf1bce1d7..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_broken_link.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_brush_highlight_32.png b/app/src/main/res/drawable-xxhdpi/ic_brush_highlight_32.png deleted file mode 100644 index 3b21d3b324..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_brush_highlight_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_brush_marker_32.png b/app/src/main/res/drawable-xxhdpi/ic_brush_marker_32.png deleted file mode 100644 index 8e90632c39..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_brush_marker_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_camera_filled_24.png b/app/src/main/res/drawable-xxhdpi/ic_camera_filled_24.png deleted file mode 100644 index 2fbd146470..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_camera_filled_24.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_camera_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_camera_white_24dp.png deleted file mode 100644 index 0fe586ad04..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_camera_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_caption_28.png b/app/src/main/res/drawable-xxhdpi/ic_caption_28.png deleted file mode 100644 index 885fa26c3a..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_caption_28.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_check_circle_32.png b/app/src/main/res/drawable-xxhdpi/ic_check_circle_32.png deleted file mode 100644 index 9e669e9317..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_check_circle_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_check_white_48dp.png b/app/src/main/res/drawable-xxhdpi/ic_check_white_48dp.png deleted file mode 100644 index bfd7b82aaa..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_check_white_48dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_close_white_18dp.png b/app/src/main/res/drawable-xxhdpi/ic_close_white_18dp.png deleted file mode 100644 index 86bd673afc..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_close_white_18dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_close_white_48dp.png b/app/src/main/res/drawable-xxhdpi/ic_close_white_48dp.png deleted file mode 100644 index 4927bc242e..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_close_white_48dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_create_album_outline_32.png b/app/src/main/res/drawable-xxhdpi/ic_create_album_outline_32.png deleted file mode 100644 index 2e675e3b39..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_create_album_outline_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_create_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_create_white_24dp.png deleted file mode 100644 index 8452719edd..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_create_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_crop_32.png b/app/src/main/res/drawable-xxhdpi/ic_crop_32.png deleted file mode 100644 index c69c5c049c..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_crop_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_crop_lock_32.png b/app/src/main/res/drawable-xxhdpi/ic_crop_lock_32.png deleted file mode 100644 index a86488e13c..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_crop_lock_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_crop_unlock_32.png b/app/src/main/res/drawable-xxhdpi/ic_crop_unlock_32.png deleted file mode 100644 index 8a5cc3d8cb..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_crop_unlock_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_delivered.png b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_delivered.png deleted file mode 100644 index 27d3b3e21b..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_delivered.png and /dev/null 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 deleted file mode 100644 index 4ed4e2d8b1..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_failed.png and /dev/null 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 deleted file mode 100644 index 69376c9a20..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_read.png and /dev/null 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 deleted file mode 100644 index 3dce4a05ea..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_sending.png and /dev/null 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 deleted file mode 100644 index 302afb8370..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_sent.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_document_large_dark.png b/app/src/main/res/drawable-xxhdpi/ic_document_large_dark.png deleted file mode 100644 index f6f21a9cdb..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_document_large_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_document_large_light.png b/app/src/main/res/drawable-xxhdpi/ic_document_large_light.png deleted file mode 100644 index 26c2172dba..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_document_large_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_document_small_dark.png b/app/src/main/res/drawable-xxhdpi/ic_document_small_dark.png deleted file mode 100644 index 9b9e773af0..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_document_small_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_emoji_32.png b/app/src/main/res/drawable-xxhdpi/ic_emoji_32.png deleted file mode 100644 index d9dba7da96..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_emoji_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_error.png b/app/src/main/res/drawable-xxhdpi/ic_error.png deleted file mode 100644 index 8dbbc3292a..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_error.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_file_download_white_36dp.png b/app/src/main/res/drawable-xxhdpi/ic_file_download_white_36dp.png deleted file mode 100644 index 30b762d70f..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_file_download_white_36dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_flip_32.png b/app/src/main/res/drawable-xxhdpi/ic_flip_32.png deleted file mode 100644 index 79bb2b30bf..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_flip_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_gif_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_gif_white_24dp.png deleted file mode 100644 index acdb6d0b90..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_gif_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_headset_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_headset_white_24dp.png deleted file mode 100644 index 82db5427b7..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_headset_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_image_dark.png b/app/src/main/res/drawable-xxhdpi/ic_image_dark.png deleted file mode 100644 index 95bac3e67c..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_image_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_image_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_image_white_24dp.png deleted file mode 100644 index f9f1defa6d..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_image_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_info_outline_dark.png b/app/src/main/res/drawable-xxhdpi/ic_info_outline_dark.png deleted file mode 100644 index dcdacc4623..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_info_outline_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_info_outline_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_info_outline_white_24dp.png deleted file mode 100644 index bdbde04c23..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_info_outline_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_insert_drive_file_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_insert_drive_file_white_24dp.png deleted file mode 100644 index f3e153b45e..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_insert_drive_file_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_local_dining_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_local_dining_white_24dp.png deleted file mode 100644 index 8c43c1c243..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_local_dining_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_location_on_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_location_on_white_24dp.png deleted file mode 100644 index 078b10d4fb..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_location_on_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_lock_dark.png b/app/src/main/res/drawable-xxhdpi/ic_menu_lock_dark.png deleted file mode 100644 index 2090e9b25d..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_menu_lock_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_message_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_message_white_24dp.png deleted file mode 100644 index e1d812bb58..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_message_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_missing_thumbnail_picture.png b/app/src/main/res/drawable-xxhdpi/ic_missing_thumbnail_picture.png deleted file mode 100644 index 8a04517020..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_missing_thumbnail_picture.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_movie_creation_dark.png b/app/src/main/res/drawable-xxhdpi/ic_movie_creation_dark.png deleted file mode 100644 index bb5c3ed9b6..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_movie_creation_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_person_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_person_white_24dp.png deleted file mode 100644 index 184f7418d5..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_person_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pets_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_pets_white_24dp.png deleted file mode 100644 index 6996e7cad4..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_pets_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_photo_camera_dark.png b/app/src/main/res/drawable-xxhdpi/ic_photo_camera_dark.png deleted file mode 100644 index 4c9226587b..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_photo_camera_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_plus_24.png b/app/src/main/res/drawable-xxhdpi/ic_plus_24.png deleted file mode 100644 index d63c48c714..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_plus_24.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_plus_28.png b/app/src/main/res/drawable-xxhdpi/ic_plus_28.png deleted file mode 100644 index e09c7ae8c2..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_plus_28.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_profile_default.png b/app/src/main/res/drawable-xxhdpi/ic_profile_default.png deleted file mode 100644 index 9e1c67e6cc..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_profile_default.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_reply.png b/app/src/main/res/drawable-xxhdpi/ic_reply.png deleted file mode 100644 index 119006014d..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_reply.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_reply_white_36dp.png b/app/src/main/res/drawable-xxhdpi/ic_reply_white_36dp.png deleted file mode 100644 index 24e00b2408..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_reply_white_36dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_rotate_32.png b/app/src/main/res/drawable-xxhdpi/ic_rotate_32.png deleted file mode 100644 index d13035a146..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_rotate_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_switch_camera_32.png b/app/src/main/res/drawable-xxhdpi/ic_switch_camera_32.png deleted file mode 100644 index 2fda7bf89c..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_switch_camera_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_tag_faces_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_tag_faces_white_24dp.png deleted file mode 100644 index 6105b8637e..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_tag_faces_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_text_32.png b/app/src/main/res/drawable-xxhdpi/ic_text_32.png deleted file mode 100644 index 4dd242e401..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_text_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_timer.png b/app/src/main/res/drawable-xxhdpi/ic_timer.png deleted file mode 100644 index 48adc80b74..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_timer.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_undo_32.png b/app/src/main/res/drawable-xxhdpi/ic_undo_32.png deleted file mode 100644 index 372fe355d0..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_undo_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_video_dark.png b/app/src/main/res/drawable-xxhdpi/ic_video_dark.png deleted file mode 100644 index b7e9e10faf..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_video_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_volume_off_grey600_18dp.png b/app/src/main/res/drawable-xxhdpi/ic_volume_off_grey600_18dp.png deleted file mode 100644 index df2042188c..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_volume_off_grey600_18dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_volume_up_dark.png b/app/src/main/res/drawable-xxhdpi/ic_volume_up_dark.png deleted file mode 100644 index ed4105b0fb..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_volume_up_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_warning_dark.png b/app/src/main/res/drawable-xxhdpi/ic_warning_dark.png deleted file mode 100644 index 93326709f5..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_warning_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_wb_sunny_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_wb_sunny_white_24dp.png deleted file mode 100644 index f0b22b6eff..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_wb_sunny_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_work_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_work_white_24dp.png deleted file mode 100644 index af82415d5f..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_work_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_x_28.png b/app/src/main/res/drawable-xxhdpi/ic_x_28.png deleted file mode 100644 index 14fc11201a..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_x_28.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/icon_cached.png b/app/src/main/res/drawable-xxhdpi/icon_cached.png deleted file mode 100644 index d17e250790..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/icon_cached.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/timer00.png b/app/src/main/res/drawable-xxhdpi/timer00.png deleted file mode 100644 index aa0be20b2d..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/timer00.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/timer05.png b/app/src/main/res/drawable-xxhdpi/timer05.png deleted file mode 100644 index a26c7a701d..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/timer05.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/timer10.png b/app/src/main/res/drawable-xxhdpi/timer10.png deleted file mode 100644 index 1d77cf314a..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/timer10.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/timer15.png b/app/src/main/res/drawable-xxhdpi/timer15.png deleted file mode 100644 index 73d67a5fd2..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/timer15.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/timer20.png b/app/src/main/res/drawable-xxhdpi/timer20.png deleted file mode 100644 index ac0aee0b52..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/timer20.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/timer25.png b/app/src/main/res/drawable-xxhdpi/timer25.png deleted file mode 100644 index 7fd3966f20..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/timer25.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/timer30.png b/app/src/main/res/drawable-xxhdpi/timer30.png deleted file mode 100644 index f31ca334fb..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/timer30.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/timer35.png b/app/src/main/res/drawable-xxhdpi/timer35.png deleted file mode 100644 index 922c95886c..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/timer35.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/timer40.png b/app/src/main/res/drawable-xxhdpi/timer40.png deleted file mode 100644 index 53f431cbdd..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/timer40.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/timer45.png b/app/src/main/res/drawable-xxhdpi/timer45.png deleted file mode 100644 index 8781c30bfc..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/timer45.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/timer50.png b/app/src/main/res/drawable-xxhdpi/timer50.png deleted file mode 100644 index dcae6b3401..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/timer50.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/timer55.png b/app/src/main/res/drawable-xxhdpi/timer55.png deleted file mode 100644 index c820ea2b0a..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/timer55.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/timer60.png b/app/src/main/res/drawable-xxhdpi/timer60.png deleted file mode 100644 index e4e8756966..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/timer60.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_block_grey600_18dp.png b/app/src/main/res/drawable-xxxhdpi/ic_block_grey600_18dp.png deleted file mode 100644 index 17bd1f090a..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_block_grey600_18dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_broken_link.png b/app/src/main/res/drawable-xxxhdpi/ic_broken_link.png deleted file mode 100644 index 9019f9296e..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_broken_link.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_brush_highlight_32.png b/app/src/main/res/drawable-xxxhdpi/ic_brush_highlight_32.png deleted file mode 100644 index 331b5615d7..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_brush_highlight_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_brush_marker_32.png b/app/src/main/res/drawable-xxxhdpi/ic_brush_marker_32.png deleted file mode 100644 index 9e52f0f45b..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_brush_marker_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_camera_filled_24.png b/app/src/main/res/drawable-xxxhdpi/ic_camera_filled_24.png deleted file mode 100644 index 0f54e607a3..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_camera_filled_24.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_camera_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_camera_white_24dp.png deleted file mode 100644 index d3716553e2..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_camera_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_caption_28.png b/app/src/main/res/drawable-xxxhdpi/ic_caption_28.png deleted file mode 100644 index 3d4b47158b..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_caption_28.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_check_circle_32.png b/app/src/main/res/drawable-xxxhdpi/ic_check_circle_32.png deleted file mode 100644 index 8241bd663c..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_check_circle_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_check_white_48dp.png b/app/src/main/res/drawable-xxxhdpi/ic_check_white_48dp.png deleted file mode 100644 index 23a197082a..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_check_white_48dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_close_white_18dp.png b/app/src/main/res/drawable-xxxhdpi/ic_close_white_18dp.png deleted file mode 100644 index 6b717e0dda..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_close_white_18dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_close_white_48dp.png b/app/src/main/res/drawable-xxxhdpi/ic_close_white_48dp.png deleted file mode 100644 index 1ab2312754..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_close_white_48dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_create_album_outline_32.png b/app/src/main/res/drawable-xxxhdpi/ic_create_album_outline_32.png deleted file mode 100644 index 63ccb10945..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_create_album_outline_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_crop_32.png b/app/src/main/res/drawable-xxxhdpi/ic_crop_32.png deleted file mode 100644 index 80c85cc5ed..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_crop_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_crop_lock_32.png b/app/src/main/res/drawable-xxxhdpi/ic_crop_lock_32.png deleted file mode 100644 index 934d2883f4..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_crop_lock_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_crop_unlock_32.png b/app/src/main/res/drawable-xxxhdpi/ic_crop_unlock_32.png deleted file mode 100644 index 6a3e084b35..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_crop_unlock_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_delivered.png b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_delivered.png deleted file mode 100644 index 039cf55b27..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_delivered.png and /dev/null 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 deleted file mode 100644 index 6780212b8b..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_failed.png and /dev/null 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 deleted file mode 100644 index d0b16705c0..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_read.png and /dev/null 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 deleted file mode 100644 index 411dc9d502..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sending.png and /dev/null 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 deleted file mode 100644 index 657d454f6e..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sent.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_document_large_dark.png b/app/src/main/res/drawable-xxxhdpi/ic_document_large_dark.png deleted file mode 100644 index 911a1c2f49..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_document_large_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_document_large_light.png b/app/src/main/res/drawable-xxxhdpi/ic_document_large_light.png deleted file mode 100644 index fdcf64d6bb..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_document_large_light.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_document_small_dark.png b/app/src/main/res/drawable-xxxhdpi/ic_document_small_dark.png deleted file mode 100644 index 182067089d..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_document_small_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_emoji_32.png b/app/src/main/res/drawable-xxxhdpi/ic_emoji_32.png deleted file mode 100644 index 2d5d626fe4..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_emoji_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_error.png b/app/src/main/res/drawable-xxxhdpi/ic_error.png deleted file mode 100644 index 9016eb7b98..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_error.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_file_download_white_36dp.png b/app/src/main/res/drawable-xxxhdpi/ic_file_download_white_36dp.png deleted file mode 100644 index e261f9a870..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_file_download_white_36dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_flip_32.png b/app/src/main/res/drawable-xxxhdpi/ic_flip_32.png deleted file mode 100644 index 8e86977d6a..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_flip_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_gif_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_gif_white_24dp.png deleted file mode 100644 index f7a70d8471..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_gif_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_headset_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_headset_white_24dp.png deleted file mode 100644 index 0f0b2e1544..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_headset_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_image_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_image_white_24dp.png deleted file mode 100644 index 2ffdb55f26..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_image_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_insert_drive_file_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_insert_drive_file_white_24dp.png deleted file mode 100644 index 5bd56903d0..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_insert_drive_file_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_local_dining_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_local_dining_white_24dp.png deleted file mode 100644 index 8ad36ced7d..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_local_dining_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_location_on_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_location_on_white_24dp.png deleted file mode 100644 index 8bcb6f620d..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_location_on_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_menu_lock_dark.png b/app/src/main/res/drawable-xxxhdpi/ic_menu_lock_dark.png deleted file mode 100644 index 22f75edfb9..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_menu_lock_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_message_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_message_white_24dp.png deleted file mode 100644 index 494b8bf531..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_message_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_person_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_person_white_24dp.png deleted file mode 100644 index 33d40d8b62..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_person_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_pets_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_pets_white_24dp.png deleted file mode 100644 index f1fe74c154..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_pets_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_photo_camera_dark.png b/app/src/main/res/drawable-xxxhdpi/ic_photo_camera_dark.png deleted file mode 100644 index 23a9c2efd0..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_photo_camera_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_profile_default.png b/app/src/main/res/drawable-xxxhdpi/ic_profile_default.png deleted file mode 100644 index 86a661a7ab..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_profile_default.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_reply_white_36dp.png b/app/src/main/res/drawable-xxxhdpi/ic_reply_white_36dp.png deleted file mode 100644 index ff6ff92d81..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_reply_white_36dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_rotate_32.png b/app/src/main/res/drawable-xxxhdpi/ic_rotate_32.png deleted file mode 100644 index c66cf2b235..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_rotate_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_switch_camera_32.png b/app/src/main/res/drawable-xxxhdpi/ic_switch_camera_32.png deleted file mode 100644 index 321d5bb418..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_switch_camera_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_tag_faces_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_tag_faces_white_24dp.png deleted file mode 100644 index fcbaca3025..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_tag_faces_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_text_32.png b/app/src/main/res/drawable-xxxhdpi/ic_text_32.png deleted file mode 100644 index b4bbdebff7..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_text_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_timer.png b/app/src/main/res/drawable-xxxhdpi/ic_timer.png deleted file mode 100644 index 4853eb2d9d..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_timer.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_undo_32.png b/app/src/main/res/drawable-xxxhdpi/ic_undo_32.png deleted file mode 100644 index 656c45b39f..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_undo_32.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_volume_off_grey600_18dp.png b/app/src/main/res/drawable-xxxhdpi/ic_volume_off_grey600_18dp.png deleted file mode 100644 index 359e1aed80..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_volume_off_grey600_18dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_wb_sunny_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_wb_sunny_white_24dp.png deleted file mode 100644 index 97c34a0e00..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_wb_sunny_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_work_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_work_white_24dp.png deleted file mode 100644 index 1bc11a8bbb..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_work_white_24dp.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_x_28.png b/app/src/main/res/drawable-xxxhdpi/ic_x_28.png deleted file mode 100644 index 907056a5af..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_x_28.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/icon_cached.png b/app/src/main/res/drawable-xxxhdpi/icon_cached.png deleted file mode 100644 index fc6a833547..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/icon_cached.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/oxen_light_mode.png b/app/src/main/res/drawable-xxxhdpi/oxen_light_mode.png deleted file mode 100644 index f2c7e60fbc..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/oxen_light_mode.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/timer00.png b/app/src/main/res/drawable-xxxhdpi/timer00.png deleted file mode 100644 index a724af4d9c..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/timer00.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/timer05.png b/app/src/main/res/drawable-xxxhdpi/timer05.png deleted file mode 100644 index 10987c9de6..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/timer05.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/timer10.png b/app/src/main/res/drawable-xxxhdpi/timer10.png deleted file mode 100644 index 959a8dde47..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/timer10.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/timer15.png b/app/src/main/res/drawable-xxxhdpi/timer15.png deleted file mode 100644 index e0fe28c894..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/timer15.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/timer20.png b/app/src/main/res/drawable-xxxhdpi/timer20.png deleted file mode 100644 index a81cc471a4..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/timer20.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/timer25.png b/app/src/main/res/drawable-xxxhdpi/timer25.png deleted file mode 100644 index fe06795b78..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/timer25.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/timer30.png b/app/src/main/res/drawable-xxxhdpi/timer30.png deleted file mode 100644 index 0815499c68..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/timer30.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/timer35.png b/app/src/main/res/drawable-xxxhdpi/timer35.png deleted file mode 100644 index 91a863fd7e..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/timer35.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/timer40.png b/app/src/main/res/drawable-xxxhdpi/timer40.png deleted file mode 100644 index 2ad5ff358b..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/timer40.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/timer45.png b/app/src/main/res/drawable-xxxhdpi/timer45.png deleted file mode 100644 index 8cc51a2d44..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/timer45.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/timer50.png b/app/src/main/res/drawable-xxxhdpi/timer50.png deleted file mode 100644 index 89cdd98299..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/timer50.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/timer55.png b/app/src/main/res/drawable-xxxhdpi/timer55.png deleted file mode 100644 index eacdf73a07..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/timer55.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/timer60.png b/app/src/main/res/drawable-xxxhdpi/timer60.png deleted file mode 100644 index d66cf127be..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/timer60.png and /dev/null differ diff --git a/app/src/main/res/drawable/account_id_text_view_background.xml b/app/src/main/res/drawable/account_id_text_view_background.xml deleted file mode 100644 index d86bc87bcd..0000000000 --- a/app/src/main/res/drawable/account_id_text_view_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/libsession/src/main/res/drawable/avatar_gradient_dark.xml b/app/src/main/res/drawable/avatar_gradient_dark.xml similarity index 100% rename from libsession/src/main/res/drawable/avatar_gradient_dark.xml rename to app/src/main/res/drawable/avatar_gradient_dark.xml diff --git a/libsession/src/main/res/drawable/avatar_gradient_light.xml b/app/src/main/res/drawable/avatar_gradient_light.xml similarity index 100% rename from libsession/src/main/res/drawable/avatar_gradient_light.xml rename to app/src/main/res/drawable/avatar_gradient_light.xml diff --git a/app/src/main/res/drawable/bg_round_transparent.xml b/app/src/main/res/drawable/bg_round_transparent.xml new file mode 100644 index 0000000000..5032a69355 --- /dev/null +++ b/app/src/main/res/drawable/bg_round_transparent.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/borderless_button_medium_background.xml b/app/src/main/res/drawable/borderless_button_medium_background.xml index 6c72f9e72b..3d9e6c7c72 100644 --- a/app/src/main/res/drawable/borderless_button_medium_background.xml +++ b/app/src/main/res/drawable/borderless_button_medium_background.xml @@ -2,7 +2,7 @@ - + diff --git a/app/src/main/res/drawable/call_controls_background.xml b/app/src/main/res/drawable/call_controls_background.xml index 34137af62c..8a86216f9e 100644 --- a/app/src/main/res/drawable/call_controls_background.xml +++ b/app/src/main/res/drawable/call_controls_background.xml @@ -2,12 +2,12 @@ - + - + \ No newline at end of file diff --git a/app/src/main/res/drawable/call_controls_mic_background.xml b/app/src/main/res/drawable/call_controls_mic_background.xml new file mode 100644 index 0000000000..ca52a8301b --- /dev/null +++ b/app/src/main/res/drawable/call_controls_mic_background.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/call_message_background.xml b/app/src/main/res/drawable/call_message_background.xml deleted file mode 100644 index 7713909167..0000000000 --- a/app/src/main/res/drawable/call_message_background.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_alpha.xml b/app/src/main/res/drawable/circle_alpha.xml deleted file mode 100644 index f38e68224f..0000000000 --- a/app/src/main/res/drawable/circle_alpha.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/compose_background_camera.xml b/app/src/main/res/drawable/compose_background_camera.xml deleted file mode 100644 index e9b6aa6954..0000000000 --- a/app/src/main/res/drawable/compose_background_camera.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/compose_background_dark.xml b/app/src/main/res/drawable/compose_background_dark.xml deleted file mode 100644 index 56859db153..0000000000 --- a/app/src/main/res/drawable/compose_background_dark.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/context_menu_background.xml b/app/src/main/res/drawable/context_menu_background.xml index 3691575836..7a447a9ec7 100644 --- a/app/src/main/res/drawable/context_menu_background.xml +++ b/app/src/main/res/drawable/context_menu_background.xml @@ -4,6 +4,6 @@ android:shape="rectangle"> - + \ No newline at end of file diff --git a/app/src/main/res/drawable/control_message_bubble_background.xml b/app/src/main/res/drawable/control_message_bubble_background.xml new file mode 100644 index 0000000000..77928e871e --- /dev/null +++ b/app/src/main/res/drawable/control_message_bubble_background.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/conversation_attachment_edit.xml b/app/src/main/res/drawable/conversation_attachment_edit.xml deleted file mode 100644 index 4e3413c1cb..0000000000 --- a/app/src/main/res/drawable/conversation_attachment_edit.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/conversation_item_sent_indicator_text_shape_dark.xml b/app/src/main/res/drawable/conversation_item_sent_indicator_text_shape_dark.xml deleted file mode 100644 index d02a391561..0000000000 --- a/app/src/main/res/drawable/conversation_item_sent_indicator_text_shape_dark.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/conversation_list_divider_shape_dark.xml b/app/src/main/res/drawable/conversation_list_divider_shape_dark.xml deleted file mode 100644 index 425c926052..0000000000 --- a/app/src/main/res/drawable/conversation_list_divider_shape_dark.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/conversation_list_item_background_dark.xml b/app/src/main/res/drawable/conversation_list_item_background_dark.xml deleted file mode 100644 index 2e3818f7e3..0000000000 --- a/app/src/main/res/drawable/conversation_list_item_background_dark.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/conversation_menu_divider.xml b/app/src/main/res/drawable/conversation_menu_divider.xml deleted file mode 100644 index f6c8a9a710..0000000000 --- a/app/src/main/res/drawable/conversation_menu_divider.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/conversation_reaction_overlay_background.xml b/app/src/main/res/drawable/conversation_reaction_overlay_background.xml index 1a8b214ed9..7d34b290f4 100644 --- a/app/src/main/res/drawable/conversation_reaction_overlay_background.xml +++ b/app/src/main/res/drawable/conversation_reaction_overlay_background.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/cta_hero_animated_bg.webp b/app/src/main/res/drawable/cta_hero_animated_bg.webp new file mode 100644 index 0000000000..9d2ee88e15 Binary files /dev/null and b/app/src/main/res/drawable/cta_hero_animated_bg.webp differ diff --git a/app/src/main/res/drawable/cta_hero_animated_fg.webp b/app/src/main/res/drawable/cta_hero_animated_fg.webp new file mode 100644 index 0000000000..c217e67c79 Binary files /dev/null and b/app/src/main/res/drawable/cta_hero_animated_fg.webp differ diff --git a/app/src/main/res/drawable/cta_hero_char_limit.webp b/app/src/main/res/drawable/cta_hero_char_limit.webp new file mode 100644 index 0000000000..87e7e3e56f Binary files /dev/null and b/app/src/main/res/drawable/cta_hero_char_limit.webp differ diff --git a/app/src/main/res/drawable/cta_hero_generic_bg.webp b/app/src/main/res/drawable/cta_hero_generic_bg.webp new file mode 100644 index 0000000000..f4d150ea6a Binary files /dev/null and b/app/src/main/res/drawable/cta_hero_generic_bg.webp differ diff --git a/app/src/main/res/drawable/cta_hero_generic_fg.webp b/app/src/main/res/drawable/cta_hero_generic_fg.webp new file mode 100644 index 0000000000..3c5974c6fc Binary files /dev/null and b/app/src/main/res/drawable/cta_hero_generic_fg.webp differ diff --git a/app/src/main/res/drawable/cta_hero_group.webp b/app/src/main/res/drawable/cta_hero_group.webp new file mode 100644 index 0000000000..7b78b71ad8 Binary files /dev/null and b/app/src/main/res/drawable/cta_hero_group.webp differ diff --git a/app/src/main/res/drawable/cta_hero_pins.webp b/app/src/main/res/drawable/cta_hero_pins.webp new file mode 100644 index 0000000000..8a5070fb59 Binary files /dev/null and b/app/src/main/res/drawable/cta_hero_pins.webp differ diff --git a/app/src/main/res/drawable/debug_border.xml b/app/src/main/res/drawable/debug_border.xml deleted file mode 100644 index e0a28b77e4..0000000000 --- a/app/src/main/res/drawable/debug_border.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/dismiss_background.xml b/app/src/main/res/drawable/dismiss_background.xml deleted file mode 100644 index 06554646a2..0000000000 --- a/app/src/main/res/drawable/dismiss_background.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/fade_gradient.xml b/app/src/main/res/drawable/fade_gradient.xml new file mode 100644 index 0000000000..c2a6a33eac --- /dev/null +++ b/app/src/main/res/drawable/fade_gradient.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_appearance.xml b/app/src/main/res/drawable/ic_appearance.xml deleted file mode 100644 index bda7bf3550..0000000000 --- a/app/src/main/res/drawable/ic_appearance.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_arrow_down_circle_filled.xml b/app/src/main/res/drawable/ic_arrow_down_circle_filled.xml deleted file mode 100644 index be0bc5b4ac..0000000000 --- a/app/src/main/res/drawable/ic_arrow_down_circle_filled.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_arrow_down_to_line.xml b/app/src/main/res/drawable/ic_arrow_down_to_line.xml new file mode 100644 index 0000000000..436c0d3fde --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_down_to_line.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_arrow_left.xml b/app/src/main/res/drawable/ic_arrow_left.xml deleted file mode 100644 index b4d562a13e..0000000000 --- a/app/src/main/res/drawable/ic_arrow_left.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_arrow_right.xml b/app/src/main/res/drawable/ic_arrow_right.xml deleted file mode 100644 index 3710c7ecb7..0000000000 --- a/app/src/main/res/drawable/ic_arrow_right.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_arrow_up.xml b/app/src/main/res/drawable/ic_arrow_up.xml index bbf9960747..7ee404adff 100644 --- a/app/src/main/res/drawable/ic_arrow_up.xml +++ b/app/src/main/res/drawable/ic_arrow_up.xml @@ -1,15 +1,7 @@ - - - - + + + + + + diff --git a/app/src/main/res/drawable/ic_at_sign.xml b/app/src/main/res/drawable/ic_at_sign.xml new file mode 100644 index 0000000000..751ebf96ef --- /dev/null +++ b/app/src/main/res/drawable/ic_at_sign.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_ban.xml b/app/src/main/res/drawable/ic_ban.xml new file mode 100644 index 0000000000..744936981f --- /dev/null +++ b/app/src/main/res/drawable/ic_ban.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_baseline_add_24.xml b/app/src/main/res/drawable/ic_baseline_add_24.xml deleted file mode 100644 index eb232541d8..0000000000 --- a/app/src/main/res/drawable/ic_baseline_add_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_block_24.xml b/app/src/main/res/drawable/ic_baseline_block_24.xml deleted file mode 100644 index 9fefeec67e..0000000000 --- a/app/src/main/res/drawable/ic_baseline_block_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_call_24.xml b/app/src/main/res/drawable/ic_baseline_call_24.xml deleted file mode 100644 index 567e303fc5..0000000000 --- a/app/src/main/res/drawable/ic_baseline_call_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_call_end_24.xml b/app/src/main/res/drawable/ic_baseline_call_end_24.xml deleted file mode 100644 index dd6ff40926..0000000000 --- a/app/src/main/res/drawable/ic_baseline_call_end_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_call_split_24.xml b/app/src/main/res/drawable/ic_baseline_call_split_24.xml deleted file mode 100644 index ada5e609da..0000000000 --- a/app/src/main/res/drawable/ic_baseline_call_split_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_check_circle_outline_24.xml b/app/src/main/res/drawable/ic_baseline_check_circle_outline_24.xml deleted file mode 100644 index b985a9d03c..0000000000 --- a/app/src/main/res/drawable/ic_baseline_check_circle_outline_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_clear_24.xml b/app/src/main/res/drawable/ic_baseline_clear_24.xml deleted file mode 100644 index a20716a20f..0000000000 --- a/app/src/main/res/drawable/ic_baseline_clear_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_close_24.xml b/app/src/main/res/drawable/ic_baseline_close_24.xml deleted file mode 100644 index 95cc170f36..0000000000 --- a/app/src/main/res/drawable/ic_baseline_close_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_dashboard_24.xml b/app/src/main/res/drawable/ic_baseline_dashboard_24.xml deleted file mode 100644 index a882fd8334..0000000000 --- a/app/src/main/res/drawable/ic_baseline_dashboard_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_delete_24.xml b/app/src/main/res/drawable/ic_baseline_delete_24.xml deleted file mode 100644 index 3c4030b03e..0000000000 --- a/app/src/main/res/drawable/ic_baseline_delete_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_done_24.xml b/app/src/main/res/drawable/ic_baseline_done_24.xml deleted file mode 100644 index 899cbb6840..0000000000 --- a/app/src/main/res/drawable/ic_baseline_done_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_edit_24.xml b/app/src/main/res/drawable/ic_baseline_edit_24.xml deleted file mode 100644 index 2844bafebe..0000000000 --- a/app/src/main/res/drawable/ic_baseline_edit_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_flip_camera_android_24.xml b/app/src/main/res/drawable/ic_baseline_flip_camera_android_24.xml deleted file mode 100644 index 951aa1f621..0000000000 --- a/app/src/main/res/drawable/ic_baseline_flip_camera_android_24.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_baseline_forward_24.xml b/app/src/main/res/drawable/ic_baseline_forward_24.xml deleted file mode 100644 index 3f0ba0b613..0000000000 --- a/app/src/main/res/drawable/ic_baseline_forward_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_24.xml deleted file mode 100644 index 533fc1562a..0000000000 --- a/app/src/main/res/drawable/ic_baseline_keyboard_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_down_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_down_24.xml deleted file mode 100644 index 884bee144c..0000000000 --- a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_down_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_up_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_up_24.xml deleted file mode 100644 index 9b15755e53..0000000000 --- a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_up_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_mic_off_24.xml b/app/src/main/res/drawable/ic_baseline_mic_off_24.xml deleted file mode 100644 index 8e199f115a..0000000000 --- a/app/src/main/res/drawable/ic_baseline_mic_off_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_photo_camera_24.xml b/app/src/main/res/drawable/ic_baseline_photo_camera_24.xml deleted file mode 100644 index 13186deefc..0000000000 --- a/app/src/main/res/drawable/ic_baseline_photo_camera_24.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_baseline_photo_library_24.xml b/app/src/main/res/drawable/ic_baseline_photo_library_24.xml deleted file mode 100644 index ee0f6931c5..0000000000 --- a/app/src/main/res/drawable/ic_baseline_photo_library_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_reply_24.xml b/app/src/main/res/drawable/ic_baseline_reply_24.xml deleted file mode 100644 index f49d97d888..0000000000 --- a/app/src/main/res/drawable/ic_baseline_reply_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_save_24.xml b/app/src/main/res/drawable/ic_baseline_save_24.xml deleted file mode 100644 index 1a8d86d20c..0000000000 --- a/app/src/main/res/drawable/ic_baseline_save_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_screen_rotation_alt_24.xml b/app/src/main/res/drawable/ic_baseline_screen_rotation_alt_24.xml deleted file mode 100644 index 553db9c082..0000000000 --- a/app/src/main/res/drawable/ic_baseline_screen_rotation_alt_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_search_24.xml b/app/src/main/res/drawable/ic_baseline_search_24.xml deleted file mode 100644 index 07b76d6275..0000000000 --- a/app/src/main/res/drawable/ic_baseline_search_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_videocam_24.xml b/app/src/main/res/drawable/ic_baseline_videocam_24.xml deleted file mode 100644 index 340bff20c2..0000000000 --- a/app/src/main/res/drawable/ic_baseline_videocam_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_videocam_off_24.xml b/app/src/main/res/drawable/ic_baseline_videocam_off_24.xml deleted file mode 100644 index 7977a645c3..0000000000 --- a/app/src/main/res/drawable/ic_baseline_videocam_off_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_view_stream_24.xml b/app/src/main/res/drawable/ic_baseline_view_stream_24.xml deleted file mode 100644 index 3906c77212..0000000000 --- a/app/src/main/res/drawable/ic_baseline_view_stream_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_volume_up_24.xml b/app/src/main/res/drawable/ic_baseline_volume_up_24.xml deleted file mode 100644 index 0db34695f1..0000000000 --- a/app/src/main/res/drawable/ic_baseline_volume_up_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_briefcase.xml b/app/src/main/res/drawable/ic_briefcase.xml new file mode 100644 index 0000000000..1171996f42 --- /dev/null +++ b/app/src/main/res/drawable/ic_briefcase.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_brush.xml b/app/src/main/res/drawable/ic_brush.xml new file mode 100644 index 0000000000..50a186837b --- /dev/null +++ b/app/src/main/res/drawable/ic_brush.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_camera.xml b/app/src/main/res/drawable/ic_camera.xml new file mode 100644 index 0000000000..410d8945c4 --- /dev/null +++ b/app/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_case_sensitive.xml b/app/src/main/res/drawable/ic_case_sensitive.xml new file mode 100644 index 0000000000..84aec265cd --- /dev/null +++ b/app/src/main/res/drawable/ic_case_sensitive.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000000..72b8d31748 --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_chevron_down.xml b/app/src/main/res/drawable/ic_chevron_down.xml new file mode 100644 index 0000000000..71023874c5 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_down.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_chevron_left.xml b/app/src/main/res/drawable/ic_chevron_left.xml new file mode 100644 index 0000000000..06f616e2c2 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_left.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_chevron_right.xml b/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 0000000000..3c52cf3b7c --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_chevron_up.xml b/app/src/main/res/drawable/ic_chevron_up.xml index c54db3bcc6..52fd22581f 100644 --- a/app/src/main/res/drawable/ic_chevron_up.xml +++ b/app/src/main/res/drawable/ic_chevron_up.xml @@ -1,9 +1,5 @@ - - + + + + diff --git a/app/src/main/res/drawable/ic_chevron_up_light.xml b/app/src/main/res/drawable/ic_chevron_up_light.xml deleted file mode 100644 index 728180ab6d..0000000000 --- a/app/src/main/res/drawable/ic_chevron_up_light.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_circle_check.xml b/app/src/main/res/drawable/ic_circle_check.xml index e88b286a9d..93ec1f4362 100644 --- a/app/src/main/res/drawable/ic_circle_check.xml +++ b/app/src/main/res/drawable/ic_circle_check.xml @@ -1,15 +1,7 @@ - - - + + + + + + diff --git a/app/src/main/res/drawable/ic_circle_dot_dot_dot.xml b/app/src/main/res/drawable/ic_circle_dot_dot_dot.xml deleted file mode 100644 index 0d1a076b41..0000000000 --- a/app/src/main/res/drawable/ic_circle_dot_dot_dot.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_circle_dots_custom.xml b/app/src/main/res/drawable/ic_circle_dots_custom.xml new file mode 100644 index 0000000000..7b9fd9682a --- /dev/null +++ b/app/src/main/res/drawable/ic_circle_dots_custom.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_circle_help.xml b/app/src/main/res/drawable/ic_circle_help.xml new file mode 100644 index 0000000000..3b3b13768b --- /dev/null +++ b/app/src/main/res/drawable/ic_circle_help.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_circle_question_mark.xml b/app/src/main/res/drawable/ic_circle_question_mark.xml deleted file mode 100644 index 9bc2b817f1..0000000000 --- a/app/src/main/res/drawable/ic_circle_question_mark.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_clear_messages.xml b/app/src/main/res/drawable/ic_clear_messages.xml deleted file mode 100644 index e79703910d..0000000000 --- a/app/src/main/res/drawable/ic_clear_messages.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_clock_0.xml b/app/src/main/res/drawable/ic_clock_0.xml new file mode 100644 index 0000000000..575fc417cd --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_0.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_1.xml b/app/src/main/res/drawable/ic_clock_1.xml new file mode 100644 index 0000000000..a6b932141d --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_1.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_10.xml b/app/src/main/res/drawable/ic_clock_10.xml new file mode 100644 index 0000000000..fb212ac379 --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_10.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_11.xml b/app/src/main/res/drawable/ic_clock_11.xml new file mode 100644 index 0000000000..062dc5bc49 --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_11.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_12.xml b/app/src/main/res/drawable/ic_clock_12.xml new file mode 100644 index 0000000000..6b1283f79a --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_12.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_2.xml b/app/src/main/res/drawable/ic_clock_2.xml new file mode 100644 index 0000000000..e33d149246 --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_2.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_3.xml b/app/src/main/res/drawable/ic_clock_3.xml new file mode 100644 index 0000000000..e86b11160f --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_3.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_4.xml b/app/src/main/res/drawable/ic_clock_4.xml new file mode 100644 index 0000000000..31588fed9d --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_4.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_5.xml b/app/src/main/res/drawable/ic_clock_5.xml new file mode 100644 index 0000000000..5eae3d62bc --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_5.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_6.xml b/app/src/main/res/drawable/ic_clock_6.xml new file mode 100644 index 0000000000..0891d71fa6 --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_6.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_7.xml b/app/src/main/res/drawable/ic_clock_7.xml new file mode 100644 index 0000000000..e2ffdddf73 --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_7.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_8.xml b/app/src/main/res/drawable/ic_clock_8.xml new file mode 100644 index 0000000000..f9db13864c --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_8.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_clock_9.xml b/app/src/main/res/drawable/ic_clock_9.xml new file mode 100644 index 0000000000..7899aa8996 --- /dev/null +++ b/app/src/main/res/drawable/ic_clock_9.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_conversations.xml b/app/src/main/res/drawable/ic_conversations.xml deleted file mode 100644 index a32d5ec910..0000000000 --- a/app/src/main/res/drawable/ic_conversations.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_copy.xml b/app/src/main/res/drawable/ic_copy.xml index 4c6eec2c14..aab9408468 100644 --- a/app/src/main/res/drawable/ic_copy.xml +++ b/app/src/main/res/drawable/ic_copy.xml @@ -1,13 +1,7 @@ - - - + + + + + + diff --git a/app/src/main/res/drawable/ic_crop.xml b/app/src/main/res/drawable/ic_crop.xml new file mode 100644 index 0000000000..234a7b1908 --- /dev/null +++ b/app/src/main/res/drawable/ic_crop.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_crop_lock_custom.xml b/app/src/main/res/drawable/ic_crop_lock_custom.xml new file mode 100644 index 0000000000..64183f5d2b --- /dev/null +++ b/app/src/main/res/drawable/ic_crop_lock_custom.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_crop_unlock_custom.xml b/app/src/main/res/drawable/ic_crop_unlock_custom.xml new file mode 100644 index 0000000000..e5fa04b565 --- /dev/null +++ b/app/src/main/res/drawable/ic_crop_unlock_custom.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_crown_custom.xml b/app/src/main/res/drawable/ic_crown_custom.xml deleted file mode 100644 index bdf3d17cff..0000000000 --- a/app/src/main/res/drawable/ic_crown_custom.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_crown_custom_enlarged.xml b/app/src/main/res/drawable/ic_crown_custom_enlarged.xml new file mode 100644 index 0000000000..41adfe3c8d --- /dev/null +++ b/app/src/main/res/drawable/ic_crown_custom_enlarged.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml deleted file mode 100644 index 1a3e8b4001..0000000000 --- a/app/src/main/res/drawable/ic_delete.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_delete_24.xml b/app/src/main/res/drawable/ic_delete_24.xml deleted file mode 100644 index 48fa95783f..0000000000 --- a/app/src/main/res/drawable/ic_delete_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_dialog_x.xml b/app/src/main/res/drawable/ic_dialog_x.xml deleted file mode 100644 index a65f2abb88..0000000000 --- a/app/src/main/res/drawable/ic_dialog_x.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_disappearing_messages.xml b/app/src/main/res/drawable/ic_disappearing_messages.xml deleted file mode 100644 index 1e2de4e757..0000000000 --- a/app/src/main/res/drawable/ic_disappearing_messages.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_download_circle_filled_48.xml b/app/src/main/res/drawable/ic_download_circle_filled_48.xml deleted file mode 100644 index 78808945a1..0000000000 --- a/app/src/main/res/drawable/ic_download_circle_filled_48.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_edit_group.xml b/app/src/main/res/drawable/ic_edit_group.xml deleted file mode 100644 index f647fea3ea..0000000000 --- a/app/src/main/res/drawable/ic_edit_group.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_emoji_custom.xml b/app/src/main/res/drawable/ic_emoji_custom.xml new file mode 100644 index 0000000000..86353250c5 --- /dev/null +++ b/app/src/main/res/drawable/ic_emoji_custom.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_emoji_filled.xml b/app/src/main/res/drawable/ic_emoji_filled.xml deleted file mode 100644 index ed160bb6d5..0000000000 --- a/app/src/main/res/drawable/ic_emoji_filled.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_emoji_filled_keyboard_24.xml b/app/src/main/res/drawable/ic_emoji_filled_keyboard_24.xml deleted file mode 100644 index af2c68e3c8..0000000000 --- a/app/src/main/res/drawable/ic_emoji_filled_keyboard_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_emoji_outline.xml b/app/src/main/res/drawable/ic_emoji_outline.xml deleted file mode 100644 index 943277e7a3..0000000000 --- a/app/src/main/res/drawable/ic_emoji_outline.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_expand.xml b/app/src/main/res/drawable/ic_expand.xml deleted file mode 100644 index 3b2b816a45..0000000000 --- a/app/src/main/res/drawable/ic_expand.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_external.xml b/app/src/main/res/drawable/ic_external.xml deleted file mode 100644 index fb4803977c..0000000000 --- a/app/src/main/res/drawable/ic_external.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_eye.xml b/app/src/main/res/drawable/ic_eye.xml new file mode 100644 index 0000000000..58d9cfbad0 --- /dev/null +++ b/app/src/main/res/drawable/ic_eye.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_eye_off.xml b/app/src/main/res/drawable/ic_eye_off.xml new file mode 100644 index 0000000000..5ecce50931 --- /dev/null +++ b/app/src/main/res/drawable/ic_eye_off.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_file.xml b/app/src/main/res/drawable/ic_file.xml new file mode 100644 index 0000000000..fa522656cc --- /dev/null +++ b/app/src/main/res/drawable/ic_file.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_filled_circle_check.xml b/app/src/main/res/drawable/ic_filled_circle_check.xml deleted file mode 100644 index 99589252b1..0000000000 --- a/app/src/main/res/drawable/ic_filled_circle_check.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_flip_horizontal_2.xml b/app/src/main/res/drawable/ic_flip_horizontal_2.xml new file mode 100644 index 0000000000..94e6bb7767 --- /dev/null +++ b/app/src/main/res/drawable/ic_flip_horizontal_2.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_gif.xml b/app/src/main/res/drawable/ic_gif.xml new file mode 100644 index 0000000000..e107faac34 --- /dev/null +++ b/app/src/main/res/drawable/ic_gif.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_globe.xml b/app/src/main/res/drawable/ic_globe.xml index d16011ad09..2e8ce84944 100644 --- a/app/src/main/res/drawable/ic_globe.xml +++ b/app/src/main/res/drawable/ic_globe.xml @@ -1,10 +1,9 @@ - - + + + + + + + + diff --git a/app/src/main/res/drawable/ic_group.xml b/app/src/main/res/drawable/ic_group.xml deleted file mode 100644 index 1a4a3feddd..0000000000 --- a/app/src/main/res/drawable/ic_group.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_heart.xml b/app/src/main/res/drawable/ic_heart.xml new file mode 100644 index 0000000000..1266e00c76 --- /dev/null +++ b/app/src/main/res/drawable/ic_heart.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_help.xml b/app/src/main/res/drawable/ic_help.xml deleted file mode 100644 index 670a82c658..0000000000 --- a/app/src/main/res/drawable/ic_help.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_image.xml b/app/src/main/res/drawable/ic_image.xml new file mode 100644 index 0000000000..2f1de316d8 --- /dev/null +++ b/app/src/main/res/drawable/ic_image.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_images.xml b/app/src/main/res/drawable/ic_images.xml index 4e8b9abe3e..ab6b6a312a 100644 --- a/app/src/main/res/drawable/ic_images.xml +++ b/app/src/main/res/drawable/ic_images.xml @@ -1,11 +1,11 @@ - + - + - + - + - + diff --git a/app/src/main/res/drawable/ic_incoming_call.xml b/app/src/main/res/drawable/ic_incoming_call.xml deleted file mode 100644 index 73905f175d..0000000000 --- a/app/src/main/res/drawable/ic_incoming_call.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_info.xml b/app/src/main/res/drawable/ic_info.xml new file mode 100644 index 0000000000..d1c695e9e1 --- /dev/null +++ b/app/src/main/res/drawable/ic_info.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_invite_friend.xml b/app/src/main/res/drawable/ic_invite_friend.xml deleted file mode 100644 index e46d22704e..0000000000 --- a/app/src/main/res/drawable/ic_invite_friend.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/drawable/ic_launcher_calculator_background.xml b/app/src/main/res/drawable/ic_launcher_calculator_background.xml new file mode 100644 index 0000000000..1e9175c0cf --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_calculator_background.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index a3203d25e9..4eb50efd1f 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,15 +1,14 @@ - - - - - + android:viewportWidth="68" + android:viewportHeight="69"> + + diff --git a/app/src/main/res/drawable/ic_launcher_meetings_background.xml b/app/src/main/res/drawable/ic_launcher_meetings_background.xml new file mode 100644 index 0000000000..d5505ca09d --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_meetings_background.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_meetings_foreground.xml b/app/src/main/res/drawable/ic_launcher_meetings_foreground.xml new file mode 100644 index 0000000000..8541179349 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_meetings_foreground.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_notes_background.xml b/app/src/main/res/drawable/ic_launcher_notes_background.xml new file mode 100644 index 0000000000..f5bbeb9e9d --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_notes_background.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_notes_foreground.xml b/app/src/main/res/drawable/ic_launcher_notes_foreground.xml new file mode 100644 index 0000000000..9a4b2abfed --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_notes_foreground.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_stocks_background.xml b/app/src/main/res/drawable/ic_launcher_stocks_background.xml new file mode 100644 index 0000000000..26c9ceba92 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_stocks_background.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_stocks_foreground.xml b/app/src/main/res/drawable/ic_launcher_stocks_foreground.xml new file mode 100644 index 0000000000..0d8e493bbd --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_stocks_foreground.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_weather_background.xml b/app/src/main/res/drawable/ic_launcher_weather_background.xml new file mode 100644 index 0000000000..3f12e75253 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_weather_background.xml @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_weather_foreground.xml b/app/src/main/res/drawable/ic_launcher_weather_foreground.xml new file mode 100644 index 0000000000..6b28dd231b --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_weather_foreground.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_layout_dashboard.xml b/app/src/main/res/drawable/ic_layout_dashboard.xml new file mode 100644 index 0000000000..6a2d30c044 --- /dev/null +++ b/app/src/main/res/drawable/ic_layout_dashboard.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_leave_group.xml b/app/src/main/res/drawable/ic_leave_group.xml deleted file mode 100644 index a6a235aeb7..0000000000 --- a/app/src/main/res/drawable/ic_leave_group.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_link.xml b/app/src/main/res/drawable/ic_link.xml index 9e20c99e12..8902c0a9a4 100644 --- a/app/src/main/res/drawable/ic_link.xml +++ b/app/src/main/res/drawable/ic_link.xml @@ -1,9 +1,7 @@ - - + + + + + + diff --git a/app/src/main/res/drawable/ic_lock.xml b/app/src/main/res/drawable/ic_lock.xml deleted file mode 100644 index b642ae75e5..0000000000 --- a/app/src/main/res/drawable/ic_lock.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_lock_keyhole.xml b/app/src/main/res/drawable/ic_lock_keyhole.xml new file mode 100644 index 0000000000..95592c5287 --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_keyhole.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_lock_keyhole_open.xml b/app/src/main/res/drawable/ic_lock_keyhole_open.xml new file mode 100644 index 0000000000..332be3a6b4 --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_keyhole_open.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_log_out.xml b/app/src/main/res/drawable/ic_log_out.xml index 1ae65b31e1..e17c47efc4 100644 --- a/app/src/main/res/drawable/ic_log_out.xml +++ b/app/src/main/res/drawable/ic_log_out.xml @@ -1,18 +1,9 @@ - - - - + + + + + + + + diff --git a/app/src/main/res/drawable/ic_mail.xml b/app/src/main/res/drawable/ic_mail.xml index 67a21c3500..b6789569a3 100644 --- a/app/src/main/res/drawable/ic_mail.xml +++ b/app/src/main/res/drawable/ic_mail.xml @@ -1,14 +1,7 @@ - - - + + + + + + diff --git a/app/src/main/res/drawable/ic_mail_open.xml b/app/src/main/res/drawable/ic_mail_open.xml new file mode 100644 index 0000000000..a418dc842d --- /dev/null +++ b/app/src/main/res/drawable/ic_mail_open.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_maximize_2.xml b/app/src/main/res/drawable/ic_maximize_2.xml new file mode 100644 index 0000000000..f4e619f645 --- /dev/null +++ b/app/src/main/res/drawable/ic_maximize_2.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_message.xml b/app/src/main/res/drawable/ic_message.xml deleted file mode 100644 index fb194f55d6..0000000000 --- a/app/src/main/res/drawable/ic_message.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_message_details__refresh.xml b/app/src/main/res/drawable/ic_message_details__refresh.xml deleted file mode 100644 index 2aabe6fbe3..0000000000 --- a/app/src/main/res/drawable/ic_message_details__refresh.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_message_details__reply.xml b/app/src/main/res/drawable/ic_message_details__reply.xml deleted file mode 100644 index c9e1591a53..0000000000 --- a/app/src/main/res/drawable/ic_message_details__reply.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_message_requests.xml b/app/src/main/res/drawable/ic_message_requests.xml deleted file mode 100644 index de8e1a6908..0000000000 --- a/app/src/main/res/drawable/ic_message_requests.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_message_square.xml b/app/src/main/res/drawable/ic_message_square.xml new file mode 100644 index 0000000000..a936f4bc47 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_square.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_message_square_warning.xml b/app/src/main/res/drawable/ic_message_square_warning.xml new file mode 100644 index 0000000000..389ca0ad30 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_square_warning.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_message_trash_custom.xml b/app/src/main/res/drawable/ic_message_trash_custom.xml new file mode 100644 index 0000000000..479b6fc864 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_trash_custom.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_mic.xml b/app/src/main/res/drawable/ic_mic.xml new file mode 100644 index 0000000000..970df02b7f --- /dev/null +++ b/app/src/main/res/drawable/ic_mic.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_mic_off.xml b/app/src/main/res/drawable/ic_mic_off.xml new file mode 100644 index 0000000000..2e3c6d855f --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_off.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_microphone.xml b/app/src/main/res/drawable/ic_microphone.xml deleted file mode 100644 index ec9f8e76f2..0000000000 --- a/app/src/main/res/drawable/ic_microphone.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_missed_call.xml b/app/src/main/res/drawable/ic_missed_call.xml deleted file mode 100644 index a9f3d654a8..0000000000 --- a/app/src/main/res/drawable/ic_missed_call.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_more_horiz_white.xml b/app/src/main/res/drawable/ic_more_horiz_white.xml deleted file mode 100644 index efb34a24a3..0000000000 --- a/app/src/main/res/drawable/ic_more_horiz_white.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_next.xml b/app/src/main/res/drawable/ic_next.xml deleted file mode 100644 index 1e72d86cb6..0000000000 --- a/app/src/main/res/drawable/ic_next.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_notification_settings.xml b/app/src/main/res/drawable/ic_notification_settings.xml deleted file mode 100644 index e3dea6f2a2..0000000000 --- a/app/src/main/res/drawable/ic_notification_settings.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_notifications_mentions.xml b/app/src/main/res/drawable/ic_notifications_mentions.xml deleted file mode 100644 index c078377a5a..0000000000 --- a/app/src/main/res/drawable/ic_notifications_mentions.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_outgoing_call.xml b/app/src/main/res/drawable/ic_outgoing_call.xml deleted file mode 100644 index b6962afb83..0000000000 --- a/app/src/main/res/drawable/ic_outgoing_call.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_outline_disabled_by_default_24.xml b/app/src/main/res/drawable/ic_outline_disabled_by_default_24.xml deleted file mode 100644 index 7db0071309..0000000000 --- a/app/src/main/res/drawable/ic_outline_disabled_by_default_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_outline_mark_chat_read_24.xml b/app/src/main/res/drawable/ic_outline_mark_chat_read_24.xml deleted file mode 100644 index 28f57645ae..0000000000 --- a/app/src/main/res/drawable/ic_outline_mark_chat_read_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_outline_notification_important_24.xml b/app/src/main/res/drawable/ic_outline_notification_important_24.xml deleted file mode 100644 index 5a4d7814d4..0000000000 --- a/app/src/main/res/drawable/ic_outline_notification_important_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_outline_notifications_active_24.xml b/app/src/main/res/drawable/ic_outline_notifications_active_24.xml deleted file mode 100644 index 0a327617e4..0000000000 --- a/app/src/main/res/drawable/ic_outline_notifications_active_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_outline_notifications_off_24.xml b/app/src/main/res/drawable/ic_outline_notifications_off_24.xml deleted file mode 100644 index cdb23eefa6..0000000000 --- a/app/src/main/res/drawable/ic_outline_notifications_off_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_outline_pin_24.xml b/app/src/main/res/drawable/ic_outline_pin_24.xml deleted file mode 100644 index d96e4236f4..0000000000 --- a/app/src/main/res/drawable/ic_outline_pin_24.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_outline_pin_off_24.xml b/app/src/main/res/drawable/ic_outline_pin_off_24.xml deleted file mode 100644 index 056009e496..0000000000 --- a/app/src/main/res/drawable/ic_outline_pin_off_24.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_paintbrush_vertical.xml b/app/src/main/res/drawable/ic_paintbrush_vertical.xml new file mode 100644 index 0000000000..0d8f221342 --- /dev/null +++ b/app/src/main/res/drawable/ic_paintbrush_vertical.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_path_yellow.xml b/app/src/main/res/drawable/ic_path_yellow.xml deleted file mode 100644 index 04f0c51545..0000000000 --- a/app/src/main/res/drawable/ic_path_yellow.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_paw_print.xml b/app/src/main/res/drawable/ic_paw_print.xml new file mode 100644 index 0000000000..581be65bb7 --- /dev/null +++ b/app/src/main/res/drawable/ic_paw_print.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pencil.xml b/app/src/main/res/drawable/ic_pencil.xml new file mode 100644 index 0000000000..db5d7f9338 --- /dev/null +++ b/app/src/main/res/drawable/ic_pencil.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_phone.xml b/app/src/main/res/drawable/ic_phone.xml new file mode 100644 index 0000000000..59e879372d --- /dev/null +++ b/app/src/main/res/drawable/ic_phone.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_phone_fill_answer_custom.xml b/app/src/main/res/drawable/ic_phone_fill_answer_custom.xml new file mode 100644 index 0000000000..ffe2588678 --- /dev/null +++ b/app/src/main/res/drawable/ic_phone_fill_answer_custom.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_phone_fill_custom.xml b/app/src/main/res/drawable/ic_phone_fill_custom.xml new file mode 100644 index 0000000000..a35dd8c3e6 --- /dev/null +++ b/app/src/main/res/drawable/ic_phone_fill_custom.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_phone_incoming.xml b/app/src/main/res/drawable/ic_phone_incoming.xml new file mode 100644 index 0000000000..9e4c8450c8 --- /dev/null +++ b/app/src/main/res/drawable/ic_phone_incoming.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_phone_missed.xml b/app/src/main/res/drawable/ic_phone_missed.xml new file mode 100644 index 0000000000..98991257b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_phone_missed.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_phone_outgoing.xml b/app/src/main/res/drawable/ic_phone_outgoing.xml new file mode 100644 index 0000000000..96c1746a65 --- /dev/null +++ b/app/src/main/res/drawable/ic_phone_outgoing.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_phonelink_erase_white_24dp.xml b/app/src/main/res/drawable/ic_phonelink_erase_white_24dp.xml deleted file mode 100644 index fbd3ad6578..0000000000 --- a/app/src/main/res/drawable/ic_phonelink_erase_white_24dp.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_pictures.xml b/app/src/main/res/drawable/ic_pictures.xml deleted file mode 100644 index 967d0a65b0..0000000000 --- a/app/src/main/res/drawable/ic_pictures.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_pin.xml b/app/src/main/res/drawable/ic_pin.xml index d241d605b1..0202d67c69 100644 --- a/app/src/main/res/drawable/ic_pin.xml +++ b/app/src/main/res/drawable/ic_pin.xml @@ -1,10 +1,7 @@ - - + + + + + + diff --git a/app/src/main/res/drawable/ic_pin_conversation.xml b/app/src/main/res/drawable/ic_pin_conversation.xml deleted file mode 100644 index b2ff304b35..0000000000 --- a/app/src/main/res/drawable/ic_pin_conversation.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_pin_off.xml b/app/src/main/res/drawable/ic_pin_off.xml new file mode 100644 index 0000000000..e92774add3 --- /dev/null +++ b/app/src/main/res/drawable/ic_pin_off.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_plus.xml b/app/src/main/res/drawable/ic_plus.xml index a300f234cc..ccf534851f 100644 --- a/app/src/main/res/drawable/ic_plus.xml +++ b/app/src/main/res/drawable/ic_plus.xml @@ -1,13 +1,7 @@ - - - + + + + + + diff --git a/app/src/main/res/drawable/ic_prev.xml b/app/src/main/res/drawable/ic_prev.xml deleted file mode 100644 index f720261670..0000000000 --- a/app/src/main/res/drawable/ic_prev.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_privacy_icon.xml b/app/src/main/res/drawable/ic_privacy_icon.xml deleted file mode 100644 index 4162beb8d1..0000000000 --- a/app/src/main/res/drawable/ic_privacy_icon.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_pro_badge.xml b/app/src/main/res/drawable/ic_pro_badge.xml new file mode 100644 index 0000000000..1690bff8c3 --- /dev/null +++ b/app/src/main/res/drawable/ic_pro_badge.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_pro_badge_bg.xml b/app/src/main/res/drawable/ic_pro_badge_bg.xml new file mode 100644 index 0000000000..60398f8532 --- /dev/null +++ b/app/src/main/res/drawable/ic_pro_badge_bg.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pro_badge_fg.xml b/app/src/main/res/drawable/ic_pro_badge_fg.xml new file mode 100644 index 0000000000..c3c5bded9b --- /dev/null +++ b/app/src/main/res/drawable/ic_pro_badge_fg.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_pro_sparkle_custom.xml b/app/src/main/res/drawable/ic_pro_sparkle_custom.xml new file mode 100644 index 0000000000..7c3f2f8b5b --- /dev/null +++ b/app/src/main/res/drawable/ic_pro_sparkle_custom.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_qr_code.xml b/app/src/main/res/drawable/ic_qr_code.xml new file mode 100644 index 0000000000..7f0470dd26 --- /dev/null +++ b/app/src/main/res/drawable/ic_qr_code.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_qr_code_24.xml b/app/src/main/res/drawable/ic_qr_code_24.xml deleted file mode 100644 index acd7573fdd..0000000000 --- a/app/src/main/res/drawable/ic_qr_code_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_question_custom.xml b/app/src/main/res/drawable/ic_question_custom.xml new file mode 100644 index 0000000000..148e732324 --- /dev/null +++ b/app/src/main/res/drawable/ic_question_custom.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_question_mark.xml b/app/src/main/res/drawable/ic_question_mark.xml deleted file mode 100644 index d0c4088dcf..0000000000 --- a/app/src/main/res/drawable/ic_question_mark.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_recovery_password_custom.xml b/app/src/main/res/drawable/ic_recovery_password_custom.xml new file mode 100644 index 0000000000..b26652e22d --- /dev/null +++ b/app/src/main/res/drawable/ic_recovery_password_custom.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_refresh_cw.xml b/app/src/main/res/drawable/ic_refresh_cw.xml new file mode 100644 index 0000000000..1586fa8d57 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_cw.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_repeat_2.xml b/app/src/main/res/drawable/ic_repeat_2.xml new file mode 100644 index 0000000000..5cd55874e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_repeat_2.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_reply.xml b/app/src/main/res/drawable/ic_reply.xml new file mode 100644 index 0000000000..21a626ae5a --- /dev/null +++ b/app/src/main/res/drawable/ic_reply.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_rotate_ccw.xml b/app/src/main/res/drawable/ic_rotate_ccw.xml new file mode 100644 index 0000000000..6e58844424 --- /dev/null +++ b/app/src/main/res/drawable/ic_rotate_ccw.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_rotate_ccw_square.xml b/app/src/main/res/drawable/ic_rotate_ccw_square.xml new file mode 100644 index 0000000000..8f3a9b31ca --- /dev/null +++ b/app/src/main/res/drawable/ic_rotate_ccw_square.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000000..878b326c8d --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_search_24.xml b/app/src/main/res/drawable/ic_search_24.xml deleted file mode 100644 index 1d79a060b1..0000000000 --- a/app/src/main/res/drawable/ic_search_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_search_conversation.xml b/app/src/main/res/drawable/ic_search_conversation.xml deleted file mode 100644 index bd9eaad36c..0000000000 --- a/app/src/main/res/drawable/ic_search_conversation.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml index 9fd7185331..6fd41c822d 100644 --- a/app/src/main/res/drawable/ic_settings.xml +++ b/app/src/main/res/drawable/ic_settings.xml @@ -1,12 +1,12 @@ diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000000..946d0d84c5 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_shield_outline.xml b/app/src/main/res/drawable/ic_shield_outline.xml deleted file mode 100644 index 3db98f53d0..0000000000 --- a/app/src/main/res/drawable/ic_shield_outline.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_speaker.xml b/app/src/main/res/drawable/ic_speaker.xml deleted file mode 100644 index 505bf988b4..0000000000 --- a/app/src/main/res/drawable/ic_speaker.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_square_arrow_up_right.xml b/app/src/main/res/drawable/ic_square_arrow_up_right.xml new file mode 100644 index 0000000000..dcb0c853b4 --- /dev/null +++ b/app/src/main/res/drawable/ic_square_arrow_up_right.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_square_play.xml b/app/src/main/res/drawable/ic_square_play.xml new file mode 100644 index 0000000000..ddec1f8d6c --- /dev/null +++ b/app/src/main/res/drawable/ic_square_play.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_square_x.xml b/app/src/main/res/drawable/ic_square_x.xml new file mode 100644 index 0000000000..9b7a29bfd5 --- /dev/null +++ b/app/src/main/res/drawable/ic_square_x.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_status.xml b/app/src/main/res/drawable/ic_status.xml deleted file mode 100644 index 7b19ad1413..0000000000 --- a/app/src/main/res/drawable/ic_status.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_sticker_filled_keyboard_24.xml b/app/src/main/res/drawable/ic_sticker_filled_keyboard_24.xml deleted file mode 100644 index de5399d3e7..0000000000 --- a/app/src/main/res/drawable/ic_sticker_filled_keyboard_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_stretch_horizontal.xml b/app/src/main/res/drawable/ic_stretch_horizontal.xml new file mode 100644 index 0000000000..8b2db31278 --- /dev/null +++ b/app/src/main/res/drawable/ic_stretch_horizontal.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_sun.xml b/app/src/main/res/drawable/ic_sun.xml new file mode 100644 index 0000000000..7a25a991de --- /dev/null +++ b/app/src/main/res/drawable/ic_sun.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_switch_camera.xml b/app/src/main/res/drawable/ic_switch_camera.xml new file mode 100644 index 0000000000..2252ff554a --- /dev/null +++ b/app/src/main/res/drawable/ic_switch_camera.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_timer.xml b/app/src/main/res/drawable/ic_timer.xml new file mode 100644 index 0000000000..1a69346067 --- /dev/null +++ b/app/src/main/res/drawable/ic_timer.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_trash_2.xml b/app/src/main/res/drawable/ic_trash_2.xml new file mode 100644 index 0000000000..994c13a4c4 --- /dev/null +++ b/app/src/main/res/drawable/ic_trash_2.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_triangle_alert.xml b/app/src/main/res/drawable/ic_triangle_alert.xml new file mode 100644 index 0000000000..d68a52c3b5 --- /dev/null +++ b/app/src/main/res/drawable/ic_triangle_alert.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_user_filled_custom.xml b/app/src/main/res/drawable/ic_user_filled_custom.xml new file mode 100644 index 0000000000..8cfa447da5 --- /dev/null +++ b/app/src/main/res/drawable/ic_user_filled_custom.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_user_filled_custom_padded.xml b/app/src/main/res/drawable/ic_user_filled_custom_padded.xml new file mode 100644 index 0000000000..d5ba4fd972 --- /dev/null +++ b/app/src/main/res/drawable/ic_user_filled_custom_padded.xml @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_user_round_pen.xml b/app/src/main/res/drawable/ic_user_round_pen.xml new file mode 100644 index 0000000000..8e4af30f4b --- /dev/null +++ b/app/src/main/res/drawable/ic_user_round_pen.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_user_round_plus.xml b/app/src/main/res/drawable/ic_user_round_plus.xml new file mode 100644 index 0000000000..8ec062fcc4 --- /dev/null +++ b/app/src/main/res/drawable/ic_user_round_plus.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_user_round_tick.xml b/app/src/main/res/drawable/ic_user_round_tick.xml new file mode 100644 index 0000000000..46e76f1324 --- /dev/null +++ b/app/src/main/res/drawable/ic_user_round_tick.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_user_round_trash.xml b/app/src/main/res/drawable/ic_user_round_trash.xml new file mode 100644 index 0000000000..aaa2631159 --- /dev/null +++ b/app/src/main/res/drawable/ic_user_round_trash.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_user_round_x.xml b/app/src/main/res/drawable/ic_user_round_x.xml new file mode 100644 index 0000000000..4bcbd0d2c1 --- /dev/null +++ b/app/src/main/res/drawable/ic_user_round_x.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_users_group_custom.xml b/app/src/main/res/drawable/ic_users_group_custom.xml new file mode 100644 index 0000000000..bc8259099a --- /dev/null +++ b/app/src/main/res/drawable/ic_users_group_custom.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_users_round.xml b/app/src/main/res/drawable/ic_users_round.xml new file mode 100644 index 0000000000..0e9f44cf6c --- /dev/null +++ b/app/src/main/res/drawable/ic_users_round.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_utensils_crossed.xml b/app/src/main/res/drawable/ic_utensils_crossed.xml new file mode 100644 index 0000000000..4a64aa5e74 --- /dev/null +++ b/app/src/main/res/drawable/ic_utensils_crossed.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_video.xml b/app/src/main/res/drawable/ic_video.xml new file mode 100644 index 0000000000..88146abb08 --- /dev/null +++ b/app/src/main/res/drawable/ic_video.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_video_off.xml b/app/src/main/res/drawable/ic_video_off.xml new file mode 100644 index 0000000000..5c468aed0d --- /dev/null +++ b/app/src/main/res/drawable/ic_video_off.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_volume_2.xml b/app/src/main/res/drawable/ic_volume_2.xml new file mode 100644 index 0000000000..3d708ecc66 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_2.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_volume_off.xml b/app/src/main/res/drawable/ic_volume_off.xml new file mode 100644 index 0000000000..e53a86ae7e --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_off.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_x.xml b/app/src/main/res/drawable/ic_x.xml index 4fa8aa41a3..16b833b30d 100644 --- a/app/src/main/res/drawable/ic_x.xml +++ b/app/src/main/res/drawable/ic_x.xml @@ -1,9 +1,7 @@ - - + + + + + + diff --git a/app/src/main/res/drawable/labeled_edit_text_background_active.xml b/app/src/main/res/drawable/labeled_edit_text_background_active.xml deleted file mode 100644 index 00a2dc992e..0000000000 --- a/app/src/main/res/drawable/labeled_edit_text_background_active.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/labeled_edit_text_background_inactive.xml b/app/src/main/res/drawable/labeled_edit_text_background_inactive.xml deleted file mode 100644 index 6360ef28e8..0000000000 --- a/app/src/main/res/drawable/labeled_edit_text_background_inactive.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/media_keyboard_selected_background_dark.xml b/app/src/main/res/drawable/media_keyboard_selected_background_dark.xml deleted file mode 100644 index b41f2cf6d8..0000000000 --- a/app/src/main/res/drawable/media_keyboard_selected_background_dark.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/media_keyboard_selected_background_light.xml b/app/src/main/res/drawable/media_keyboard_selected_background_light.xml deleted file mode 100644 index e0afb0fc4a..0000000000 --- a/app/src/main/res/drawable/media_keyboard_selected_background_light.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/mediarail_button_background.xml b/app/src/main/res/drawable/mediarail_button_background.xml index 035512fbff..0c6c37bc36 100644 --- a/app/src/main/res/drawable/mediarail_button_background.xml +++ b/app/src/main/res/drawable/mediarail_button_background.xml @@ -3,17 +3,10 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:color="@color/transparent_white_40"> - - - - - - - - + - + diff --git a/app/src/main/res/drawable/message_bubble_background.xml b/app/src/main/res/drawable/message_bubble_background.xml new file mode 100644 index 0000000000..c509652f94 --- /dev/null +++ b/app/src/main/res/drawable/message_bubble_background.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/message_bubble_background_received_alone.xml b/app/src/main/res/drawable/message_bubble_background_received_alone.xml deleted file mode 100644 index 5e657c9793..0000000000 --- a/app/src/main/res/drawable/message_bubble_background_received_alone.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/padded_circle_accent.xml b/app/src/main/res/drawable/padded_circle_accent.xml deleted file mode 100644 index 797c6bf007..0000000000 --- a/app/src/main/res/drawable/padded_circle_accent.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/preference_single_no_padding.xml b/app/src/main/res/drawable/preference_single_no_padding.xml deleted file mode 100644 index 483894fcc2..0000000000 --- a/app/src/main/res/drawable/preference_single_no_padding.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file 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 deleted file mode 100644 index 9b90660803..0000000000 --- a/app/src/main/res/drawable/profile_picture_view_large_background.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - \ 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 deleted file mode 100644 index 698a67c0a9..0000000000 --- a/app/src/main/res/drawable/prominent_filled_button_medium_background.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/ses_logo.xml b/app/src/main/res/drawable/ses_logo.xml new file mode 100644 index 0000000000..deb7aa9d1b --- /dev/null +++ b/app/src/main/res/drawable/ses_logo.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/session_network_logo.xml b/app/src/main/res/drawable/session_network_logo.xml new file mode 100644 index 0000000000..285ccee0a6 --- /dev/null +++ b/app/src/main/res/drawable/session_network_logo.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_1.xml b/app/src/main/res/drawable/session_node_lines_1.xml new file mode 100644 index 0000000000..8abadc694d --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_1.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_10.xml b/app/src/main/res/drawable/session_node_lines_10.xml new file mode 100644 index 0000000000..afd23275f5 --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_10.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_2.xml b/app/src/main/res/drawable/session_node_lines_2.xml new file mode 100644 index 0000000000..4fe0589a0d --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_2.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_3.xml b/app/src/main/res/drawable/session_node_lines_3.xml new file mode 100644 index 0000000000..fbaefe3a82 --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_3.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_4.xml b/app/src/main/res/drawable/session_node_lines_4.xml new file mode 100644 index 0000000000..641d0229ac --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_4.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_5.xml b/app/src/main/res/drawable/session_node_lines_5.xml new file mode 100644 index 0000000000..4cfeb0ec45 --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_5.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_6.xml b/app/src/main/res/drawable/session_node_lines_6.xml new file mode 100644 index 0000000000..c1df45092b --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_6.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_7.xml b/app/src/main/res/drawable/session_node_lines_7.xml new file mode 100644 index 0000000000..f11999108a --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_7.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_8.xml b/app/src/main/res/drawable/session_node_lines_8.xml new file mode 100644 index 0000000000..16c2552dce --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_8.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_node_lines_9.xml b/app/src/main/res/drawable/session_node_lines_9.xml new file mode 100644 index 0000000000..b37ce417f9 --- /dev/null +++ b/app/src/main/res/drawable/session_node_lines_9.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/session_nodes_1.xml b/app/src/main/res/drawable/session_nodes_1.xml new file mode 100644 index 0000000000..94f3b408e4 --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_1.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/session_nodes_10.xml b/app/src/main/res/drawable/session_nodes_10.xml new file mode 100644 index 0000000000..219c0e6e35 --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_10.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/session_nodes_2.xml b/app/src/main/res/drawable/session_nodes_2.xml new file mode 100644 index 0000000000..5ab0ae04d5 --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_2.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/session_nodes_3.xml b/app/src/main/res/drawable/session_nodes_3.xml new file mode 100644 index 0000000000..5e4c490675 --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_3.xml @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/session_nodes_4.xml b/app/src/main/res/drawable/session_nodes_4.xml new file mode 100644 index 0000000000..2700dcb250 --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_4.xml @@ -0,0 +1,35 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/session_nodes_5.xml b/app/src/main/res/drawable/session_nodes_5.xml new file mode 100644 index 0000000000..3eecdf0f0e --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_5.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/session_nodes_6.xml b/app/src/main/res/drawable/session_nodes_6.xml new file mode 100644 index 0000000000..7fa062d182 --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_6.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/session_nodes_7.xml b/app/src/main/res/drawable/session_nodes_7.xml new file mode 100644 index 0000000000..093877b0ba --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_7.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/session_nodes_8.xml b/app/src/main/res/drawable/session_nodes_8.xml new file mode 100644 index 0000000000..309aea6d36 --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_8.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/session_nodes_9.xml b/app/src/main/res/drawable/session_nodes_9.xml new file mode 100644 index 0000000000..8f67bf9777 --- /dev/null +++ b/app/src/main/res/drawable/session_nodes_9.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/session_shield.xml b/app/src/main/res/drawable/session_shield.xml deleted file mode 100644 index a7c6d1a24a..0000000000 --- a/app/src/main/res/drawable/session_shield.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/sticker_button_dark.xml b/app/src/main/res/drawable/sticker_button_dark.xml deleted file mode 100644 index bbd18b7e9f..0000000000 --- a/app/src/main/res/drawable/sticker_button_dark.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/sticky_date_header_background_dark.xml b/app/src/main/res/drawable/sticky_date_header_background_dark.xml deleted file mode 100644 index d17517d372..0000000000 --- a/app/src/main/res/drawable/sticky_date_header_background_dark.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/tab_indicator_dot.xml b/app/src/main/res/drawable/tab_indicator_dot.xml deleted file mode 100644 index 72f57dc8bc..0000000000 --- a/app/src/main/res/drawable/tab_indicator_dot.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/transfer_controls_background.xml b/app/src/main/res/drawable/transfer_controls_background.xml deleted file mode 100644 index 1ba1aa2ece..0000000000 --- a/app/src/main/res/drawable/transfer_controls_background.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/unread_count_background_dark.xml b/app/src/main/res/drawable/unread_count_background_dark.xml deleted file mode 100644 index 8666e43f67..0000000000 --- a/app/src/main/res/drawable/unread_count_background_dark.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/view_doc_attachment_icon_background.xml b/app/src/main/res/drawable/view_doc_attachment_icon_background.xml new file mode 100644 index 0000000000..56b9f3007d --- /dev/null +++ b/app/src/main/res/drawable/view_doc_attachment_icon_background.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/view_separator.xml b/app/src/main/res/drawable/view_separator.xml deleted file mode 100644 index 27dd4bc967..0000000000 --- a/app/src/main/res/drawable/view_separator.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/font/space_mono_bold.ttf b/app/src/main/res/font/space_mono_bold.ttf deleted file mode 100644 index 4acd36ac38..0000000000 Binary files a/app/src/main/res/font/space_mono_bold.ttf and /dev/null differ diff --git a/app/src/main/res/font/space_mono_regular.ttf b/app/src/main/res/font/space_mono_regular.ttf deleted file mode 100644 index 28d7ff7177..0000000000 Binary files a/app/src/main/res/font/space_mono_regular.ttf and /dev/null differ diff --git a/app/src/main/res/layout/activity_appearance_settings.xml b/app/src/main/res/layout/activity_appearance_settings.xml index b58b3ca5c3..92c691bc41 100644 --- a/app/src/main/res/layout/activity_appearance_settings.xml +++ b/app/src/main/res/layout/activity_appearance_settings.xml @@ -10,7 +10,8 @@ + android:layout_height="wrap_content" + android:paddingBottom="@dimen/medium_spacing"> @@ -348,25 +350,29 @@ android:layout_margin="@dimen/medium_spacing" android:layout_marginBottom="@dimen/massive_spacing" android:layout_width="match_parent" - android:layout_height="wrap_content"> + android:layout_height="wrap_content" + android:minHeight="56dp"> + - + + + + + + + + + \ 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 deleted file mode 100644 index 02caf4b7a8..0000000000 --- a/app/src/main/res/layout/activity_blocked_contacts.xml +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - - - \ 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 55fa1bd1af..202a67a80a 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -9,29 +9,26 @@ android:layout_height="match_parent" android:orientation="vertical"> - + app:layout_constraintEnd_toEndOf="parent"/> - - - + - @@ -70,26 +68,9 @@ android:id="@+id/searchBottomBar" android:layout_width="match_parent" android:layout_height="wrap_content" - app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintBottom_toTopOf="@+id/bottomSpacer" android:visibility="gone"/> - - - - @@ -223,13 +203,13 @@ android:layout_height="wrap_content" android:layout_marginBottom="-12dp" android:visibility="gone" - app:layout_constraintBottom_toBottomOf="parent" /> + app:layout_constraintBottom_toTopOf="@+id/bottomSpacer" /> @@ -257,9 +239,18 @@ tools:visibility="visible" android:visibility="gone" /> + + + + + + - + android:layout_gravity="center" + android:indeterminateTint="?colorAccent" + android:indeterminate="true"/> diff --git a/app/src/main/res/layout/activity_disappearing_messages.xml b/app/src/main/res/layout/activity_disappearing_messages.xml deleted file mode 100644 index 461767ff43..0000000000 --- a/app/src/main/res/layout/activity_disappearing_messages.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_edit_closed_group.xml b/app/src/main/res/layout/activity_edit_closed_group.xml deleted file mode 100644 index d6881200ce..0000000000 --- a/app/src/main/res/layout/activity_edit_closed_group.xml +++ /dev/null @@ -1,175 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -