Merge branch 'dev' into pr/1298

This commit is contained in:
ThomasSession
2024-06-24 17:22:06 +10:00
371 changed files with 10808 additions and 7240 deletions

88
.drone.jsonnet Normal file
View File

@@ -0,0 +1,88 @@
local docker_base = 'registry.oxen.rocks/lokinet-ci-';
// Log a bunch of version information to make it easier for debugging
local version_info = {
name: 'Version Information',
image: docker_base + 'android',
commands: [
'cmake --version',
'apt --installed list'
]
};
// Intentionally doing a depth of 2 as libSession-util has it's own submodules (and libLokinet likely will as well)
local clone_submodules = {
name: 'Clone Submodules',
image: 'drone/git',
commands: ['git fetch --tags', 'git submodule update --init --recursive --depth=2 --jobs=4']
};
// cmake options for static deps mirror
local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https://oxen.rocks/deps ' else '');
[
// Unit tests (PRs only)
{
kind: 'pipeline',
type: 'docker',
name: 'Unit Tests',
platform: { arch: 'amd64' },
trigger: { event: { exclude: [ 'push' ] } },
steps: [
version_info,
clone_submodules,
{
name: 'Run Unit Tests',
image: docker_base + 'android',
pull: 'always',
environment: { ANDROID_HOME: '/usr/lib/android-sdk' },
commands: [
'apt-get install -y ninja-build',
'./gradlew testPlayDebugUnitTestCoverageReport'
],
}
],
},
// Validate build artifact was created by the direct branch push (PRs only)
{
kind: 'pipeline',
type: 'docker',
name: 'Check Build Artifact Existence',
platform: { arch: 'amd64' },
trigger: { event: { exclude: [ 'push' ] } },
steps: [
{
name: 'Poll for build artifact existence',
image: docker_base + 'android',
pull: 'always',
commands: [
'./scripts/drone-upload-exists.sh'
]
}
]
},
// Debug APK build (non-PRs only)
{
kind: 'pipeline',
type: 'docker',
name: 'Debug APK Build',
platform: { arch: 'amd64' },
trigger: { event: { exclude: [ 'pull_request' ] } },
steps: [
version_info,
clone_submodules,
{
name: 'Build and upload',
image: docker_base + 'android',
pull: 'always',
environment: { SSH_KEY: { from_secret: 'SSH_KEY' }, ANDROID_HOME: '/usr/lib/android-sdk' },
commands: [
'apt-get install -y ninja-build',
'./gradlew assemblePlayDebug',
'./scripts/drone-static-upload.sh'
],
}
],
}
]

View File

@@ -1,45 +0,0 @@
<!-- This is a bug report template. By following the instructions below and filling out the sections with your information, you will help the developers get all the necessary data to fix your issue.
You can also preview your report before submitting it. You may remove sections that aren't relevant to your particular case.
Before we begin, please note that this tracker is only for issues. It is not for questions, comments, or feature requests.
If you are looking for support, please file an issue or email team@oxen.io.
Let's begin with a checklist: Replace the empty checkboxes [ ] below with checked ones [x] accordingly. -->
- [ ] I have read and agree to adhere to the [Code of Conduct](https://github.com/oxen-io/session-android/blob/master/CODE_OF_CONDUCT.md).
- [ ] I have searched open and closed issues for duplicates
- [ ] I am submitting a bug report for existing functionality that does not work as intended
- [ ] This isn't a feature request or a discussion topic
----------------------------------------
### Bug description
Describe here the issue that you are experiencing.
### Steps to reproduce
- using hyphens as bullet points
- list the steps
- that reproduce the bug
**Actual result:**
Describe here what happens after you run the steps above (i.e. the buggy behaviour)
**Expected result:**
Describe here what should happen after you run the steps above (i.e. what would be the correct behaviour)
### Screenshots
<!-- you can drag and drop images below -->
### Device info
<!-- replace the examples with your info -->
**Device:** Manufacturer Model XVI
**Android version:** 0.0.0
**Session version:** 0.0.0
### Link to debug log

View File

@@ -1,34 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Code of conduct**
- [ ] I have read and agree to adhere to the [Code of Conduct](https://github.com/oxen-io/session-android/blob/master/CODE_OF_CONDUCT.md).
**Describe the bug**
A clear and concise description of what the bug is.
**To reproduce**
Steps to reproduce the behavior:
**Screenshots or logs**
If applicable, add screenshots or logs to help explain your problem.
**Smartphone (please complete the following information):**
- Device: [e.g. Samsung Galaxy S8]
- OS: [e.g. Android Pie]
- Version of Session or latest commit hash
**Additional context**
Add any other context about the problem here.

74
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,74 @@
name: 🐞 Bug Report
description: Create a report to help us improve
title: "[BUG] <title>"
labels: [bug]
body:
- type: checkboxes
attributes:
label: Code of conduct
description: I have read and agree to adhere to the [Code of Conduct](https://github.com/oxen-io/session-android/blob/master/CODE_OF_CONDUCT.md).
options:
- label: I have read and agree to adhere to the [Code of Conduct](https://github.com/oxen-io/session-android/blob/master/CODE_OF_CONDUCT.md)
required: true
- type: checkboxes
attributes:
label: Self-training on how to write a bug report
description: High quality bug reports can help the team save time and improve the chance of getting your issue fixed. Please read [how to write a bug report](https://www.browserstack.com/guide/how-to-write-a-bug-report) before submitting your issue.
options:
- label: I have learned [how to write a bug report](https://www.browserstack.com/guide/how-to-write-a-bug-report)
required: true
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
options:
- label: I have searched the existing issues
required: true
- type: textarea
attributes:
label: Current Behavior
description: A concise description of what you're experiencing.
validations:
required: false
- type: textarea
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: false
- type: textarea
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. In this environment...
2. With this config...
3. Run '...'
4. See error...
validations:
required: false
- type: input
attributes:
label: Android Version
description: What version of Android are you running?
placeholder: ex. Android 11
validations:
required: false
- type: input
attributes:
label: Session Version
description: What version of Session are you running? (This can be found at the bottom of the app settings)
placeholder: ex. 1.17.0 (3425)
validations:
required: false
- type: textarea
attributes:
label: Anything else?
description: |
Add any other context about the problem here.
Tip: You can attach screenshots or log files to help explain your problem by clicking this area to highlight it and then dragging files in.
validations:
required: false

View File

@@ -0,0 +1,26 @@
name: 🚀 Feature request
description: Suggest an idea for Session
title: '[Feature] <title>'
labels: [feature-request]
body:
- type: checkboxes
attributes:
label: Is there an existing request for feature?
description: Please search to see if an issue already exists for the feature you are requesting.
options:
- label: I have searched the existing issues
required: true
- type: textarea
attributes:
label: What feature would you like?
description: |
A clear and concise description of the feature you would like added to Session
validations:
required: true
- type: textarea
attributes:
label: Anything else?
description: |
Add any other context or screenshots about the feature request here
validations:
required: false

6
.gitignore vendored
View File

@@ -15,4 +15,8 @@ signing.properties
ffpr ffpr
*.sh *.sh
pkcs11.password pkcs11.password
play app/play
app/huawei
!/scripts/drone-static-upload.sh
!/scripts/drone-upload-exists.sh

24
.run/Run Tests.run.xml Normal file
View File

@@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Tests" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="testPlayDebugUnitTestCoverageReport" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>

View File

@@ -34,6 +34,12 @@ Setting up a development environment and building from Android Studio
6. Project initialization and building should proceed. 6. Project initialization and building should proceed.
7. Clone submodules with `git submodule update --init --recursive` 7. Clone submodules with `git submodule update --init --recursive`
If you would like to build the Huawei Flavor with Huawei HMS push notifications you will need to pass 'huawei' as a command line arg to include the required dependencies.
e.g. `./gradlew assembleHuaweiDebug -Phuawei`
If you are building in Android Studio then add `-Phuawei` to `Preferences > Build, Execution, Deployment > Gradle-Android Compiler > Command-line Options`
Contributing code Contributing code
----------------- -----------------

View File

@@ -1,4 +1,4 @@
# Session Android # Session Android
[Download on the Google Play Store](https://getsession.org/android) [Download on the Google Play Store](https://getsession.org/android)

View File

@@ -24,160 +24,15 @@ apply plugin: 'kotlin-android'
apply plugin: 'witness' apply plugin: 'witness'
apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-parcelize' apply plugin: 'kotlin-parcelize'
apply plugin: 'com.google.gms.google-services'
apply plugin: 'kotlinx-serialization' apply plugin: 'kotlinx-serialization'
apply plugin: 'dagger.hilt.android.plugin' apply plugin: 'dagger.hilt.android.plugin'
configurations.all { configurations.all {
exclude module: "commons-logging" exclude module: "commons-logging"
} }
dependencies { def canonicalVersionCode = 373
def canonicalVersionName = "1.18.4"
implementation("com.google.dagger:hilt-android:2.46.1")
kapt("com.google.dagger:hilt-android-compiler:2.44")
implementation "androidx.appcompat:appcompat:$appcompatVersion"
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation "com.google.android.material:material:$materialVersion"
implementation 'com.google.android:flexbox:2.0.1'
implementation 'androidx.legacy:legacy-support-v13:1.0.0'
implementation 'androidx.cardview:cardview:1.0.0'
implementation "androidx.preference:preference-ktx:$preferenceVersion"
implementation 'androidx.legacy:legacy-preference-v14:1.0.0'
implementation 'androidx.gridlayout:gridlayout:1.0.0'
implementation 'androidx.exifinterface:exifinterface:1.3.4'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
implementation "androidx.paging:paging-runtime-ktx:$pagingVersion"
implementation 'androidx.activity:activity-ktx:1.5.1'
implementation 'androidx.fragment:fragment-ktx:1.5.3'
implementation "androidx.core:core-ktx:$coreVersion"
implementation "androidx.work:work-runtime-ktx:2.7.1"
implementation ("com.google.firebase:firebase-messaging:18.0.0") {
exclude group: 'com.google.firebase', module: 'firebase-core'
exclude group: 'com.google.firebase', module: 'firebase-analytics'
exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
}
implementation 'com.google.android.exoplayer:exoplayer-core:2.9.1'
implementation 'com.google.android.exoplayer:exoplayer-ui:2.9.1'
implementation 'org.conscrypt:conscrypt-android:2.0.0'
implementation 'org.signal:aesgcmprovider:0.0.3'
implementation 'org.webrtc:google-webrtc:1.0.32006'
implementation "me.leolin:ShortcutBadger:1.1.16"
implementation 'se.emilsjolander:stickylistheaders:2.7.0'
implementation 'com.jpardogo.materialtabstrip:library:1.0.9'
implementation 'org.apache.httpcomponents:httpclient-android:4.3.5'
implementation 'commons-net:commons-net:3.7.2'
implementation 'com.github.chrisbanes:PhotoView:2.1.3'
implementation "com.github.bumptech.glide:glide:$glideVersion"
annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion"
kapt "com.github.bumptech.glide:compiler:$glideVersion"
implementation 'com.makeramen:roundedimageview:2.1.0'
implementation 'com.pnikosis:materialish-progress:1.5'
implementation 'org.greenrobot:eventbus:3.0.0'
implementation 'pl.tajchert:waitingdots:0.1.0'
implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0'
implementation 'com.melnykov:floatingactionbutton:1.3.0'
implementation 'com.google.zxing:android-integration:3.1.0'
implementation "com.google.dagger:hilt-android:$daggerVersion"
kapt "com.google.dagger:hilt-compiler:$daggerVersion"
implementation 'mobi.upod:time-duration-picker:1.1.3'
implementation 'com.google.zxing:core:3.2.1'
implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.6.0') {
exclude group: 'com.android.support', module: 'support-annotations'
}
implementation ('cn.carbswang.android:NumberPickerView:1.0.9') {
exclude group: 'com.android.support', module: 'appcompat-v7'
}
implementation ('com.tomergoldst.android:tooltips:1.0.6') {
exclude group: 'com.android.support', module: 'appcompat-v7'
}
implementation ('com.klinkerapps:android-smsmms:4.0.1') {
exclude group: 'com.squareup.okhttp', module: 'okhttp'
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
}
implementation 'com.annimon:stream:1.1.8'
implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
implementation 'androidx.sqlite:sqlite-ktx:2.3.1'
implementation 'net.zetetic:sqlcipher-android:4.5.4@aar'
implementation project(":libsignal")
implementation project(":libsession")
implementation project(":libsession-util")
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion"
implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version"
implementation project(":liblazysodium")
implementation "net.java.dev.jna:jna:5.8.0@aar"
implementation "com.google.protobuf:protobuf-java:$protobufVersion"
implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion"
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation 'app.cash.copper:copper-flow:1.0.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion"
implementation "com.jakewharton.rxbinding3:rxbinding:3.1.0"
implementation "com.github.tbruyelle:rxpermissions:0.10.2"
implementation "com.github.ybq:Android-SpinKit:1.4.0"
implementation "com.opencsv:opencsv:4.6"
testImplementation "junit:junit:$junitVersion"
testImplementation 'org.assertj:assertj-core:3.11.1'
testImplementation "org.mockito:mockito-inline:4.10.0"
testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
androidTestImplementation "org.mockito:mockito-android:4.10.0"
androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
testImplementation "androidx.test:core:$testCoreVersion"
testImplementation "androidx.arch.core:core-testing:2.2.0"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
// Core library
androidTestImplementation "androidx.test:core:$testCoreVersion"
androidTestImplementation('com.adevinta.android:barista:4.2.0') {
exclude group: 'org.jetbrains.kotlin'
}
// AndroidJUnitRunner and JUnit Rules
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
// Assertions
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.ext:truth:1.5.0'
androidTestImplementation 'com.google.truth:truth:1.1.3'
// Espresso dependencies
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1'
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
androidTestUtil 'androidx.test:orchestrator:1.4.2'
testImplementation 'org.robolectric:robolectric:4.4'
testImplementation 'org.robolectric:shadows-multidex:4.4'
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.1'
implementation 'androidx.compose.ui:ui:1.4.3'
implementation 'androidx.compose.ui:ui-tooling:1.4.3'
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.31.5-beta"
implementation "com.google.accompanist:accompanist-pager-indicators:0.31.5-beta"
implementation "androidx.compose.runtime:runtime-livedata:1.4.3"
implementation 'androidx.compose.foundation:foundation-layout:1.5.0-alpha02'
implementation 'androidx.compose.material:material:1.5.0-alpha02'
}
def canonicalVersionCode = 354
def canonicalVersionName = "1.17.0"
def postFixSize = 10 def postFixSize = 10
def abiPostFix = ['armeabi-v7a' : 1, def abiPostFix = ['armeabi-v7a' : 1,
@@ -186,6 +41,17 @@ def abiPostFix = ['armeabi-v7a' : 1,
'x86_64' : 4, 'x86_64' : 4,
'universal' : 5] '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 { android {
compileSdkVersion androidCompileSdkVersion compileSdkVersion androidCompileSdkVersion
namespace 'network.loki.messenger' namespace 'network.loki.messenger'
@@ -239,6 +105,7 @@ android {
project.ext.set("archivesBaseName", "session") project.ext.set("archivesBaseName", "session")
buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L" buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L"
buildConfigField "String", "GIT_HASH", "\"$getGitHash\""
buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\"" buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
buildConfigField "int", "CONTENT_PROXY_PORT", "443" buildConfigField "int", "CONTENT_PROXY_PORT", "443"
buildConfigField "String", "USER_AGENT", "\"OWA\"" buildConfigField "String", "USER_AGENT", "\"OWA\""
@@ -267,22 +134,41 @@ android {
minifyEnabled false minifyEnabled false
} }
debug { debug {
isDefault true
minifyEnabled false minifyEnabled false
enableUnitTestCoverage true
} }
} }
flavorDimensions "distribution" flavorDimensions "distribution"
productFlavors { productFlavors {
play { play {
isDefault true
dimension "distribution"
apply plugin: 'com.google.gms.google-services'
ext.websiteUpdateUrl = "null" ext.websiteUpdateUrl = "null"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false" 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", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
buildConfigField 'String', 'PUSH_KEY_SUFFIX', '\"\"'
}
huawei {
dimension "distribution"
ext.websiteUpdateUrl = "null"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "true"
buildConfigField "org.session.libsession.utilities.Device", "DEVICE", "org.session.libsession.utilities.Device.HUAWEI"
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
buildConfigField 'String', 'PUSH_KEY_SUFFIX', '\"_HUAWEI\"'
} }
website { website {
dimension "distribution"
ext.websiteUpdateUrl = "https://github.com/oxen-io/session-android/releases" ext.websiteUpdateUrl = "https://github.com/oxen-io/session-android/releases"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "true" 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", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\""
buildConfigField 'String', 'PUSH_KEY_SUFFIX', '\"\"'
} }
} }
@@ -312,6 +198,188 @@ android {
dataBinding true dataBinding true
viewBinding true viewBinding true
} }
def huaweiEnabled = project.properties['huawei'] != null
applicationVariants.configureEach { variant ->
if (variant.flavorName == 'huawei') {
variant.getPreBuildProvider().configure { task ->
task.doFirst {
if (!huaweiEnabled) {
def message = 'Huawei is not enabled. Please add -Phuawei command line arg. See BUILDING.md'
logger.error(message)
throw new GradleException(message)
}
}
}
}
}
task testPlayDebugUnitTestCoverageReport(type: JacocoReport, dependsOn: "testPlayDebugUnitTest") {
reports {
xml.enabled = true
}
// Add files that should not be listed in the report (e.g. generated Files from dagger)
def fileFilter = []
def mainSrc = "$projectDir/src/main/java"
def kotlinDebugTree = fileTree(dir: "${buildDir}/tmp/kotlin-classes/playDebug", excludes: fileFilter)
// Compiled Kotlin class files are written into build-variant-specific subdirectories of 'build/tmp/kotlin-classes'.
classDirectories.from = files([kotlinDebugTree])
// To produce an accurate report, the bytecode is mapped back to the original source code.
sourceDirectories.from = files([mainSrc])
// Execution data generated when running the tests against classes instrumented by the JaCoCo agent.
// This is enabled with 'enableUnitTestCoverage' in the 'debug' build type.
executionData.from = "${project.buildDir}/outputs/unit_test_code_coverage/playDebugUnitTest/testPlayDebugUnitTest.exec"
}
}
dependencies {
implementation("com.google.dagger:hilt-android:2.46.1")
kapt("com.google.dagger:hilt-android-compiler:2.44")
implementation "androidx.appcompat:appcompat:$appcompatVersion"
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation "com.google.android.material:material:$materialVersion"
implementation 'com.google.android:flexbox:2.0.1'
implementation 'androidx.legacy:legacy-support-v13:1.0.0'
implementation 'androidx.cardview:cardview:1.0.0'
implementation "androidx.preference:preference-ktx:$preferenceVersion"
implementation 'androidx.legacy:legacy-preference-v14:1.0.0'
implementation 'androidx.gridlayout:gridlayout:1.0.0'
implementation 'androidx.exifinterface:exifinterface:1.3.4'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
implementation "androidx.paging:paging-runtime-ktx:$pagingVersion"
implementation 'androidx.activity:activity-ktx:1.5.1'
implementation 'androidx.fragment:fragment-ktx:1.5.3'
implementation "androidx.core:core-ktx:$coreVersion"
implementation "androidx.work:work-runtime-ktx:2.7.1"
playImplementation ("com.google.firebase:firebase-messaging:18.0.0") {
exclude group: 'com.google.firebase', module: 'firebase-core'
exclude group: 'com.google.firebase', module: 'firebase-analytics'
exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
}
if (project.hasProperty('huawei')) huaweiImplementation 'com.huawei.hms:push:6.7.0.300'
implementation 'com.google.android.exoplayer:exoplayer-core:2.9.1'
implementation 'com.google.android.exoplayer:exoplayer-ui:2.9.1'
implementation 'org.conscrypt:conscrypt-android:2.0.0'
implementation 'org.signal:aesgcmprovider:0.0.3'
implementation 'org.webrtc:google-webrtc:1.0.32006'
implementation "me.leolin:ShortcutBadger:1.1.16"
implementation 'se.emilsjolander:stickylistheaders:2.7.0'
implementation 'com.jpardogo.materialtabstrip:library:1.0.9'
implementation 'org.apache.httpcomponents:httpclient-android:4.3.5'
implementation 'commons-net:commons-net:3.7.2'
implementation 'com.github.chrisbanes:PhotoView:2.1.3'
implementation "com.github.bumptech.glide:glide:$glideVersion"
annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion"
kapt "com.github.bumptech.glide:compiler:$glideVersion"
implementation 'com.makeramen:roundedimageview:2.1.0'
implementation 'com.pnikosis:materialish-progress:1.5'
implementation 'org.greenrobot:eventbus:3.0.0'
implementation 'pl.tajchert:waitingdots:0.1.0'
implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0'
implementation 'com.melnykov:floatingactionbutton:1.3.0'
implementation 'com.google.zxing:android-integration:3.1.0'
implementation "com.google.dagger:hilt-android:$daggerVersion"
kapt "com.google.dagger:hilt-compiler:$daggerVersion"
implementation 'mobi.upod:time-duration-picker:1.1.3'
implementation 'com.google.zxing:core:3.2.1'
implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.6.0') {
exclude group: 'com.android.support', module: 'support-annotations'
}
implementation ('cn.carbswang.android:NumberPickerView:1.0.9') {
exclude group: 'com.android.support', module: 'appcompat-v7'
}
implementation ('com.tomergoldst.android:tooltips:1.0.6') {
exclude group: 'com.android.support', module: 'appcompat-v7'
}
implementation ('com.klinkerapps:android-smsmms:4.0.1') {
exclude group: 'com.squareup.okhttp', module: 'okhttp'
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
}
implementation 'com.annimon:stream:1.1.8'
implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
implementation 'androidx.sqlite:sqlite-ktx:2.3.1'
implementation 'net.zetetic:sqlcipher-android:4.5.4@aar'
implementation project(":libsignal")
implementation project(":libsession")
implementation project(":libsession-util")
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion"
implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version"
implementation project(":liblazysodium")
implementation "net.java.dev.jna:jna:5.12.1@aar"
implementation "com.google.protobuf:protobuf-java:$protobufVersion"
implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion"
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation 'app.cash.copper:copper-flow:1.0.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion"
implementation "com.jakewharton.rxbinding3:rxbinding:3.1.0"
implementation "com.github.tbruyelle:rxpermissions:0.10.2"
implementation "com.github.ybq:Android-SpinKit:1.4.0"
implementation "com.opencsv:opencsv:4.6"
testImplementation "junit:junit:$junitVersion"
testImplementation 'org.assertj:assertj-core:3.11.1'
testImplementation "org.mockito:mockito-inline:4.11.0"
testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
androidTestImplementation "org.mockito:mockito-android:4.11.0"
androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
testImplementation "androidx.test:core:$testCoreVersion"
testImplementation "androidx.arch.core:core-testing:2.2.0"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
// Core library
androidTestImplementation "androidx.test:core:$testCoreVersion"
androidTestImplementation('com.adevinta.android:barista:4.2.0') {
exclude group: 'org.jetbrains.kotlin'
}
// AndroidJUnitRunner and JUnit Rules
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
// Assertions
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.ext:truth:1.5.0'
testImplementation 'com.google.truth:truth:1.1.3'
androidTestImplementation 'com.google.truth:truth:1.1.3'
// Espresso dependencies
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1'
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
androidTestUtil 'androidx.test:orchestrator:1.4.2'
testImplementation 'org.robolectric:robolectric:4.4'
testImplementation 'org.robolectric:shadows-multidex:4.4'
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5'
implementation 'androidx.compose.ui:ui:1.5.2'
implementation 'androidx.compose.ui:ui-tooling:1.5.2'
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha"
implementation "com.google.accompanist:accompanist-pager-indicators:0.33.1-alpha"
implementation "androidx.compose.runtime:runtime-livedata:1.5.2"
implementation 'androidx.compose.foundation:foundation-layout:1.5.2'
implementation 'androidx.compose.material:material:1.5.2'
} }
static def getLastCommitTimestamp() { static def getLastCommitTimestamp() {

View File

@@ -158,6 +158,7 @@ class HomeActivityTests {
val dialogPromptText = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.dialog_open_url_explanation, amazonPuny) val dialogPromptText = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.dialog_open_url_explanation, amazonPuny)
onView(isRoot()).perform(waitFor(1000)) // no other way for this to work apparently
onView(withText(dialogPromptText)).check(matches(isDisplayed())) onView(withText(dialogPromptText)).check(matches(isDisplayed()))
} }

View File

@@ -7,16 +7,25 @@ import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import network.loki.messenger.libsession_util.ConfigBase import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.Contacts import network.loki.messenger.libsession_util.Contacts
import network.loki.messenger.libsession_util.ConversationVolatileConfig
import network.loki.messenger.libsession_util.util.Contact import network.loki.messenger.libsession_util.util.Contact
import network.loki.messenger.libsession_util.util.Conversation
import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.ExpiryMode
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.CoreMatchers.instanceOf
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.argThat import org.mockito.kotlin.argThat
import org.mockito.kotlin.argWhere
import org.mockito.kotlin.eq import org.mockito.kotlin.eq
import org.mockito.kotlin.spy import org.mockito.kotlin.spy
import org.mockito.kotlin.verify import org.mockito.kotlin.verify
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.hexEncodedPublicKey
@@ -50,13 +59,22 @@ class LibSessionTests {
private fun buildContactMessage(contactList: List<Contact>): ByteArray { private fun buildContactMessage(contactList: List<Contact>): ByteArray {
val (key,_) = maybeGetUserInfo()!! val (key,_) = maybeGetUserInfo()!!
val contacts = Contacts.Companion.newInstance(key) val contacts = Contacts.newInstance(key)
contactList.forEach { contact -> contactList.forEach { contact ->
contacts.set(contact) contacts.set(contact)
} }
return contacts.push().config return contacts.push().config
} }
private fun buildVolatileMessage(conversations: List<Conversation>): ByteArray {
val (key, _) = maybeGetUserInfo()!!
val volatile = ConversationVolatileConfig.newInstance(key)
conversations.forEach { conversation ->
volatile.set(conversation)
}
return volatile.push().config
}
private fun fakePollNewConfig(configBase: ConfigBase, toMerge: ByteArray) { private fun fakePollNewConfig(configBase: ConfigBase, toMerge: ByteArray) {
configBase.merge(nextFakeHash to toMerge) configBase.merge(nextFakeHash to toMerge)
MessagingModuleConfiguration.shared.configFactory.persist(configBase, System.currentTimeMillis()) MessagingModuleConfiguration.shared.configFactory.persist(configBase, System.currentTimeMillis())
@@ -95,8 +113,83 @@ class LibSessionTests {
fakePollNewConfig(contacts, newContactMerge) fakePollNewConfig(contacts, newContactMerge)
verify(storageSpy).addLibSessionContacts(argThat { verify(storageSpy).addLibSessionContacts(argThat {
first().let { it.id == newContactId && it.approved } && size == 1 first().let { it.id == newContactId && it.approved } && size == 1
}) }, any())
verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true)) verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true))
} }
@Test
fun test_expected_configs() {
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
val storageSpy = spy(app.storage)
app.storage = storageSpy
val randomRecipient = randomSessionId()
val newContact = Contact(
id = randomRecipient,
approved = true,
expiryMode = ExpiryMode.AfterSend(1000)
)
val newConvo = Conversation.OneToOne(
randomRecipient,
SnodeAPI.nowWithOffset,
false
)
val volatiles = MessagingModuleConfiguration.shared.configFactory.convoVolatile!!
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
val newContactMerge = buildContactMessage(listOf(newContact))
val newVolatileMerge = buildVolatileMessage(listOf(newConvo))
fakePollNewConfig(contacts, newContactMerge)
fakePollNewConfig(volatiles, newVolatileMerge)
verify(storageSpy).setExpirationConfiguration(argWhere { config ->
config.expiryMode is ExpiryMode.AfterSend
&& config.expiryMode.expirySeconds == 1000L
})
val threadId = storageSpy.getThreadId(Address.fromSerialized(randomRecipient))!!
val newExpiry = storageSpy.getExpirationConfiguration(threadId)!!
assertThat(newExpiry.expiryMode, instanceOf(ExpiryMode.AfterSend::class.java))
assertThat(newExpiry.expiryMode.expirySeconds, equalTo(1000))
assertThat(newExpiry.expiryMode.expiryMillis, equalTo(1000000))
}
@Test
fun test_overwrite_config() {
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
val storageSpy = spy(app.storage)
app.storage = storageSpy
// Initial state
val randomRecipient = randomSessionId()
val currentContact = Contact(
id = randomRecipient,
approved = true,
expiryMode = ExpiryMode.NONE
)
val newConvo = Conversation.OneToOne(
randomRecipient,
SnodeAPI.nowWithOffset,
false
)
val volatiles = MessagingModuleConfiguration.shared.configFactory.convoVolatile!!
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
val newContactMerge = buildContactMessage(listOf(currentContact))
val newVolatileMerge = buildVolatileMessage(listOf(newConvo))
fakePollNewConfig(contacts, newContactMerge)
fakePollNewConfig(volatiles, newVolatileMerge)
verify(storageSpy).setExpirationConfiguration(argWhere { config ->
config.expiryMode == ExpiryMode.NONE
})
val threadId = storageSpy.getThreadId(Address.fromSerialized(randomRecipient))!!
val currentExpiryConfig = storageSpy.getExpirationConfiguration(threadId)!!
assertThat(currentExpiryConfig.expiryMode, equalTo(ExpiryMode.NONE))
assertThat(currentExpiryConfig.expiryMode.expirySeconds, equalTo(0))
assertThat(currentExpiryConfig.expiryMode.expiryMillis, equalTo(0))
// Set new state and overwrite
val updatedContact = currentContact.copy(expiryMode = ExpiryMode.AfterSend(1000))
val updateContactMerge = buildContactMessage(listOf(updatedContact))
fakePollNewConfig(contacts, updateContactMerge)
val updatedExpiryConfig = storageSpy.getExpirationConfiguration(threadId)!!
assertThat(updatedExpiryConfig.expiryMode, instanceOf(ExpiryMode.AfterSend::class.java))
assertThat(updatedExpiryConfig.expiryMode.expirySeconds, equalTo(1000))
}
} }

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application tools:node="merge">
<meta-data
android:name="com.huawei.hms.client.appid"
android:value="appid=107205081">
</meta-data>
<meta-data
android:name="com.huawei.hms.client.cpid"
android:value="cpid=30061000024605000">
</meta-data>
<service
android:name="org.thoughtcrime.securesms.notifications.HuaweiPushService"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.huawei.push.action.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -0,0 +1,96 @@
{
"agcgw":{
"backurl":"connect-dre.hispace.hicloud.com",
"url":"connect-dre.dbankcloud.cn",
"websocketbackurl":"connect-ws-dre.hispace.dbankcloud.com",
"websocketurl":"connect-ws-dre.hispace.dbankcloud.cn"
},
"agcgw_all":{
"CN":"connect-drcn.dbankcloud.cn",
"CN_back":"connect-drcn.hispace.hicloud.com",
"DE":"connect-dre.dbankcloud.cn",
"DE_back":"connect-dre.hispace.hicloud.com",
"RU":"connect-drru.hispace.dbankcloud.ru",
"RU_back":"connect-drru.hispace.dbankcloud.cn",
"SG":"connect-dra.dbankcloud.cn",
"SG_back":"connect-dra.hispace.hicloud.com"
},
"websocketgw_all":{
"CN":"connect-ws-drcn.hispace.dbankcloud.cn",
"CN_back":"connect-ws-drcn.hispace.dbankcloud.com",
"DE":"connect-ws-dre.hispace.dbankcloud.cn",
"DE_back":"connect-ws-dre.hispace.dbankcloud.com",
"RU":"connect-ws-drru.hispace.dbankcloud.ru",
"RU_back":"connect-ws-drru.hispace.dbankcloud.cn",
"SG":"connect-ws-dra.hispace.dbankcloud.cn",
"SG_back":"connect-ws-dra.hispace.dbankcloud.com"
},
"client":{
"cp_id":"890061000023000573",
"product_id":"99536292102532562",
"client_id":"954244311350791232",
"client_secret":"555999202D718B6744DAD2E923B386DC17F3F4E29F5105CE0D061EED328DADEE",
"project_id":"99536292102532562",
"app_id":"107205081",
"api_key":"DAEDABeddLEqUy0QRwa1THLwRA0OqrSuyci/HjNvVSmsdWsXRM2U2hRaCyqfvGYH1IFOKrauArssz/WPMLRHCYxliWf+DTj9bDwlWA==",
"package_name":"network.loki.messenger"
},
"oauth_client":{
"client_id":"107205081",
"client_type":1
},
"app_info":{
"app_id":"107205081",
"package_name":"network.loki.messenger"
},
"service":{
"analytics":{
"collector_url":"datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn",
"collector_url_ru":"datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com",
"collector_url_sg":"datacollector-dra.dt.hicloud.com,datacollector-dra.dt.dbankcloud.cn",
"collector_url_de":"datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn",
"collector_url_cn":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn",
"resource_id":"p1",
"channel_id":""
},
"edukit":{
"edu_url":"edukit.edu.cloud.huawei.com.cn",
"dh_url":"edukit.edu.cloud.huawei.com.cn"
},
"search":{
"url":"https://search-dre.cloud.huawei.com"
},
"cloudstorage":{
"storage_url_sg_back":"https://agc-storage-dra.cloud.huawei.asia",
"storage_url_ru_back":"https://agc-storage-drru.cloud.huawei.ru",
"storage_url_ru":"https://agc-storage-drru.cloud.huawei.ru",
"storage_url_de_back":"https://agc-storage-dre.cloud.huawei.eu",
"storage_url_de":"https://ops-dre.agcstorage.link",
"storage_url":"https://agc-storage-drcn.platform.dbankcloud.cn",
"storage_url_sg":"https://ops-dra.agcstorage.link",
"storage_url_cn_back":"https://agc-storage-drcn.cloud.huawei.com.cn",
"storage_url_cn":"https://agc-storage-drcn.platform.dbankcloud.cn"
},
"ml":{
"mlservice_url":"ml-api-dre.ai.dbankcloud.com,ml-api-dre.ai.dbankcloud.cn"
}
},
"region":"DE",
"configuration_version":"3.0",
"appInfos":[
{
"package_name":"network.loki.messenger",
"client":{
"app_id":"107205081"
},
"app_info":{
"package_name":"network.loki.messenger",
"app_id":"107205081"
},
"oauth_client":{
"client_type":1,
"client_id":"107205081"
}
}
]
}

View File

@@ -0,0 +1,13 @@
package org.thoughtcrime.securesms.notifications
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
abstract class HuaweiBindingModule {
@Binds
abstract fun bindTokenFetcher(tokenFetcher: HuaweiTokenFetcher): TokenFetcher
}

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.notifications
import android.os.Bundle
import com.huawei.hms.push.HmsMessageService
import com.huawei.hms.push.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import org.json.JSONException
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Log
import java.lang.Exception
import javax.inject.Inject
private val TAG = HuaweiPushService::class.java.simpleName
@AndroidEntryPoint
class HuaweiPushService: HmsMessageService() {
@Inject lateinit var pushRegistry: PushRegistry
@Inject lateinit var pushReceiver: PushReceiver
override fun onMessageReceived(message: RemoteMessage?) {
Log.d(TAG, "onMessageReceived")
message?.dataOfMap?.takeIf { it.isNotEmpty() }?.let(pushReceiver::onPush) ?:
pushReceiver.onPush(message?.data?.let(Base64::decode))
}
override fun onNewToken(token: String?) {
pushRegistry.register(token)
}
override fun onNewToken(token: String?, bundle: Bundle?) {
Log.d(TAG, "New HCM token: $token.")
pushRegistry.register(token)
}
override fun onDeletedMessages() {
Log.d(TAG, "onDeletedMessages")
pushRegistry.refresh(false)
}
}

View File

@@ -0,0 +1,29 @@
package org.thoughtcrime.securesms.notifications
import android.content.Context
import com.huawei.hms.aaid.HmsInstanceId
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.session.libsignal.utilities.Log
import javax.inject.Inject
import javax.inject.Singleton
private const val APP_ID = "107205081"
private const val TOKEN_SCOPE = "HCM"
@Singleton
class HuaweiTokenFetcher @Inject constructor(
@ApplicationContext private val context: Context,
private val pushRegistry: Lazy<PushRegistry>,
): TokenFetcher {
override suspend fun fetch(): String? = HmsInstanceId.getInstance(context).run {
// https://developer.huawei.com/consumer/en/doc/development/HMS-Guides/push-basic-capability#h2-1576218800370
// getToken may return an empty string, if so HuaweiPushService#onNewToken will be called.
withContext(Dispatchers.IO) { getToken(APP_ID, TOKEN_SCOPE) }
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="preferences_notifications_strategy_category_fast_mode_summary">You\'ll be notified of new messages reliably and immediately using Huaweis notification servers.</string>
<string name="activity_pn_mode_fast_mode_explanation">You\'ll be notified of new messages reliably and immediately using Huaweis notification servers.</string>
</resources>

View File

@@ -34,11 +34,14 @@
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" /> <uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
<uses-permission android:name="network.loki.messenger.ACCESS_SESSION_SECRETS" /> <uses-permission android:name="network.loki.messenger.ACCESS_SESSION_SECRETS" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
@@ -104,11 +107,6 @@
android:name="org.thoughtcrime.securesms.onboarding.RegisterActivity" android:name="org.thoughtcrime.securesms.onboarding.RegisterActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" /> android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.RecoveryPhraseRestoreActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity <activity
android:name="org.thoughtcrime.securesms.onboarding.LinkDeviceActivity" android:name="org.thoughtcrime.securesms.onboarding.LinkDeviceActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
@@ -176,6 +174,9 @@
android:screenOrientation="portrait" /> android:screenOrientation="portrait" />
<activity android:name="org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity" <activity android:name="org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity"
android:screenOrientation="portrait"/> android:screenOrientation="portrait"/>
<activity android:name="org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.NoActionBar" />
<activity <activity
android:exported="true" android:exported="true"
@@ -225,20 +226,18 @@
android:name="org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2" android:name="org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:parentActivityName="org.thoughtcrime.securesms.home.HomeActivity" android:parentActivityName="org.thoughtcrime.securesms.home.HomeActivity"
android:theme="@style/Theme.Session.DayNight.NoActionBar"> android:theme="@style/Theme.Session.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize" >
<meta-data <meta-data
android:name="android.support.PARENT_ACTIVITY" android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.home.HomeActivity" /> android:value="org.thoughtcrime.securesms.home.HomeActivity" />
</activity> </activity>
<activity <activity
android:name="org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity" android:name="org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight"> android:theme="@style/Theme.Session.DayNight">
</activity> </activity>
<activity
android:name="org.thoughtcrime.securesms.longmessage.LongMessageActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight" />
<activity <activity
android:name="org.thoughtcrime.securesms.DatabaseUpgradeActivity" android:name="org.thoughtcrime.securesms.DatabaseUpgradeActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
@@ -310,20 +309,16 @@
android:name="android.support.PARENT_ACTIVITY" android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.home.HomeActivity" /> android:value="org.thoughtcrime.securesms.home.HomeActivity" />
</activity> </activity>
<service
android:name="org.thoughtcrime.securesms.notifications.PushNotificationService"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService" <service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService"
android:foregroundServiceType="microphone"
android:exported="false" /> android:exported="false" />
<service <service
android:name="org.thoughtcrime.securesms.service.KeyCachingService" android:name="org.thoughtcrime.securesms.service.KeyCachingService"
android:enabled="true" android:enabled="true"
android:exported="false" /> android:exported="false" android:foregroundServiceType="specialUse">
<!-- <property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"-->
<!-- android:value="@string/preferences_app_protection__lock_signal_access_with_android_screen_lock_or_fingerprint"/>-->
</service>
<service <service
android:name="org.thoughtcrime.securesms.service.DirectShareService" android:name="org.thoughtcrime.securesms.service.DirectShareService"
android:exported="true" android:exported="true"

View File

@@ -1,5 +1,7 @@
package org.thoughtcrime.securesms package org.thoughtcrime.securesms
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import nl.komponents.kovenant.Kovenant import nl.komponents.kovenant.Kovenant
import nl.komponents.kovenant.jvm.asDispatcher import nl.komponents.kovenant.jvm.asDispatcher
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
@@ -11,7 +13,7 @@ object AppContext {
fun configureKovenant() { fun configureKovenant() {
Kovenant.context { Kovenant.context {
callbackContext.dispatcher = Executors.newSingleThreadExecutor().asDispatcher() callbackContext.dispatcher = Executors.newSingleThreadExecutor().asDispatcher()
workerContext.dispatcher = ThreadUtils.executorPool.asDispatcher() workerContext.dispatcher = Dispatchers.IO.asExecutor().asDispatcher()
multipleCompletion = { v1, v2 -> multipleCompletion = { v1, v2 ->
Log.d("Loki", "Promise resolved more than once (first with $v1, then with $v2); ignoring $v2.") Log.d("Loki", "Promise resolved more than once (first with $v1, then with $v2); ignoring $v2.")
} }

View File

@@ -41,6 +41,7 @@ import org.session.libsession.messaging.sending_receiving.pollers.Poller;
import org.session.libsession.snode.SnodeModule; import org.session.libsession.snode.SnodeModule;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.ConfigFactoryUpdateListener; import org.session.libsession.utilities.ConfigFactoryUpdateListener;
import org.session.libsession.utilities.Device;
import org.session.libsession.utilities.ProfilePictureUtilities; import org.session.libsession.utilities.ProfilePictureUtilities;
import org.session.libsession.utilities.SSKEnvironment; import org.session.libsession.utilities.SSKEnvironment;
import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.TextSecurePreferences;
@@ -56,6 +57,7 @@ import org.signal.aesgcmprovider.AesGcmProvider;
import org.thoughtcrime.securesms.components.TypingStatusSender; import org.thoughtcrime.securesms.components.TypingStatusSender;
import org.thoughtcrime.securesms.crypto.KeyPairUtilities; import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
import org.thoughtcrime.securesms.database.EmojiSearchDatabase; import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
import org.thoughtcrime.securesms.database.LastSentTimestampCache;
import org.thoughtcrime.securesms.database.LokiAPIDatabase; import org.thoughtcrime.securesms.database.LokiAPIDatabase;
import org.thoughtcrime.securesms.database.Storage; import org.thoughtcrime.securesms.database.Storage;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
@@ -73,10 +75,9 @@ import org.thoughtcrime.securesms.logging.PersistentLogger;
import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger; import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger;
import org.thoughtcrime.securesms.notifications.BackgroundPollWorker; import org.thoughtcrime.securesms.notifications.BackgroundPollWorker;
import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier; import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier;
import org.thoughtcrime.securesms.notifications.FcmUtils;
import org.thoughtcrime.securesms.notifications.LokiPushNotificationManager;
import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier; import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier;
import org.thoughtcrime.securesms.notifications.PushRegistry;
import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.service.KeyCachingService;
@@ -109,6 +110,7 @@ import dagger.hilt.EntryPoints;
import dagger.hilt.android.HiltAndroidApp; import dagger.hilt.android.HiltAndroidApp;
import kotlin.Unit; import kotlin.Unit;
import kotlinx.coroutines.Job; import kotlinx.coroutines.Job;
import network.loki.messenger.BuildConfig;
import network.loki.messenger.libsession_util.ConfigBase; import network.loki.messenger.libsession_util.ConfigBase;
import network.loki.messenger.libsession_util.UserProfile; import network.loki.messenger.libsession_util.UserProfile;
@@ -143,9 +145,12 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
@Inject LokiAPIDatabase lokiAPIDatabase; @Inject LokiAPIDatabase lokiAPIDatabase;
@Inject public Storage storage; @Inject public Storage storage;
@Inject Device device;
@Inject MessageDataProvider messageDataProvider; @Inject MessageDataProvider messageDataProvider;
@Inject TextSecurePreferences textSecurePreferences; @Inject TextSecurePreferences textSecurePreferences;
@Inject PushRegistry pushRegistry;
@Inject ConfigFactory configFactory; @Inject ConfigFactory configFactory;
@Inject LastSentTimestampCache lastSentTimestampCache;
CallMessageProcessor callMessageProcessor; CallMessageProcessor callMessageProcessor;
MessagingModuleConfiguration messagingModuleConfiguration; MessagingModuleConfiguration messagingModuleConfiguration;
@@ -194,24 +199,29 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
} }
@Override @Override
public void notifyUpdates(@NonNull ConfigBase forConfigObject) { public void notifyUpdates(@NonNull ConfigBase forConfigObject, long messageTimestamp) {
// forward to the config factory / storage ig // forward to the config factory / storage ig
if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) { if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) {
textSecurePreferences.setConfigurationMessageSynced(true); textSecurePreferences.setConfigurationMessageSynced(true);
} }
storage.notifyConfigUpdates(forConfigObject); storage.notifyConfigUpdates(forConfigObject, messageTimestamp);
} }
@Override @Override
public void onCreate() { public void onCreate() {
TextSecurePreferences.setPushSuffix(BuildConfig.PUSH_KEY_SUFFIX);
DatabaseModule.init(this); DatabaseModule.init(this);
MessagingModuleConfiguration.configure(this); MessagingModuleConfiguration.configure(this);
super.onCreate(); super.onCreate();
messagingModuleConfiguration = new MessagingModuleConfiguration(this, messagingModuleConfiguration = new MessagingModuleConfiguration(
this,
storage, storage,
device,
messageDataProvider, messageDataProvider,
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this), ()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this),
configFactory configFactory,
lastSentTimestampCache
); );
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage); callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
Log.i(TAG, "onCreate()"); Log.i(TAG, "onCreate()");
@@ -226,10 +236,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
broadcaster = new Broadcaster(this); broadcaster = new Broadcaster(this);
LokiAPIDatabase apiDB = getDatabaseComponent().lokiAPIDatabase(); LokiAPIDatabase apiDB = getDatabaseComponent().lokiAPIDatabase();
SnodeModule.Companion.configure(apiDB, broadcaster); SnodeModule.Companion.configure(apiDB, broadcaster);
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
if (userPublicKey != null) {
registerForFCMIfNeeded(false);
}
initializeExpiringMessageManager(); initializeExpiringMessageManager();
initializeTypingStatusRepository(); initializeTypingStatusRepository();
initializeTypingStatusSender(); initializeTypingStatusSender();
@@ -427,33 +433,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
} }
private static class ProviderInitializationException extends RuntimeException { } private static class ProviderInitializationException extends RuntimeException { }
public void registerForFCMIfNeeded(final Boolean force) {
if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive() && !force) return;
if (force && firebaseInstanceIdJob != null) {
firebaseInstanceIdJob.cancel(null);
}
firebaseInstanceIdJob = FcmUtils.getFcmInstanceId(task->{
if (!task.isSuccessful()) {
Log.w("Loki", "FirebaseInstanceId.getInstance().getInstanceId() failed." + task.getException());
return Unit.INSTANCE;
}
String token = task.getResult().getToken();
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
if (userPublicKey == null) return Unit.INSTANCE;
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
if (TextSecurePreferences.isUsingFCM(this)) {
LokiPushNotificationManager.register(token, userPublicKey, this, force);
} else {
LokiPushNotificationManager.unregister(token, this);
}
});
return Unit.INSTANCE;
});
}
private void setUpPollingIfNeeded() { private void setUpPollingIfNeeded() {
String userPublicKey = TextSecurePreferences.getLocalNumber(this); String userPublicKey = TextSecurePreferences.getLocalNumber(this);
if (userPublicKey == null) return; if (userPublicKey == null) return;
@@ -502,9 +481,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
Log.d("Loki-Avatar", "Uploading Avatar Finished"); Log.d("Loki-Avatar", "Uploading Avatar Finished");
return Unit.INSTANCE; return Unit.INSTANCE;
}); });
} catch (Exception exception) { } catch (Exception e) {
// Do nothing Log.e("Loki-Avatar", "Uploading avatar failed.");
Log.e("Loki-Avatar", "Uploading avatar failed", exception);
} }
}); });
} }
@@ -524,18 +502,14 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
} }
public void clearAllData(boolean isMigratingToV2KeyPair) { public void clearAllData(boolean isMigratingToV2KeyPair) {
String token = TextSecurePreferences.getFCMToken(this);
if (token != null && !token.isEmpty()) {
LokiPushNotificationManager.unregister(token, this);
}
if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive()) { if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive()) {
firebaseInstanceIdJob.cancel(null); firebaseInstanceIdJob.cancel(null);
} }
String displayName = TextSecurePreferences.getProfileName(this); String displayName = TextSecurePreferences.getProfileName(this);
boolean isUsingFCM = TextSecurePreferences.isUsingFCM(this); boolean isUsingFCM = TextSecurePreferences.isPushEnabled(this);
TextSecurePreferences.clearAll(this); TextSecurePreferences.clearAll(this);
if (isMigratingToV2KeyPair) { if (isMigratingToV2KeyPair) {
TextSecurePreferences.setIsUsingFCM(this, isUsingFCM); TextSecurePreferences.setPushEnabled(this, isUsingFCM);
TextSecurePreferences.setProfileName(this, displayName); TextSecurePreferences.setProfileName(this, displayName);
} }
getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit(); getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit();

View File

@@ -30,30 +30,37 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
private static final String TAG = BaseActionBarActivity.class.getSimpleName(); private static final String TAG = BaseActionBarActivity.class.getSimpleName();
public ThemeState currentThemeState; public ThemeState currentThemeState;
private Resources.Theme modifiedTheme;
private TextSecurePreferences getPreferences() { private TextSecurePreferences getPreferences() {
ApplicationContext appContext = (ApplicationContext) getApplicationContext(); ApplicationContext appContext = (ApplicationContext) getApplicationContext();
return appContext.textSecurePreferences; return appContext.textSecurePreferences;
} }
@StyleRes @StyleRes
public int getDesiredTheme() { private int getDesiredTheme() {
ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences()); ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences());
int userSelectedTheme = themeState.getTheme(); int userSelectedTheme = themeState.getTheme();
// If the user has configured Session to follow the system light/dark theme mode then do so..
if (themeState.getFollowSystem()) { if (themeState.getFollowSystem()) {
// do light or dark based on the selected theme
// Use light or dark versions of the user's theme based on light-mode / dark-mode settings
boolean isDayUi = UiModeUtilities.isDayUiMode(this); boolean isDayUi = UiModeUtilities.isDayUiMode(this);
if (userSelectedTheme == R.style.Ocean_Dark || userSelectedTheme == R.style.Ocean_Light) { if (userSelectedTheme == R.style.Ocean_Dark || userSelectedTheme == R.style.Ocean_Light) {
return isDayUi ? R.style.Ocean_Light : R.style.Ocean_Dark; return isDayUi ? R.style.Ocean_Light : R.style.Ocean_Dark;
} else { } else {
return isDayUi ? R.style.Classic_Light : R.style.Classic_Dark; return isDayUi ? R.style.Classic_Light : R.style.Classic_Dark;
} }
} else { }
else // ..otherwise just return their selected theme.
{
return userSelectedTheme; return userSelectedTheme;
} }
} }
@StyleRes @Nullable @StyleRes @Nullable
public Integer getAccentTheme() { private Integer getAccentTheme() {
if (!getPreferences().hasPreference(SELECTED_ACCENT_COLOR)) return null; if (!getPreferences().hasPreference(SELECTED_ACCENT_COLOR)) return null;
ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences()); ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences());
return themeState.getAccentStyle(); return themeState.getAccentStyle();
@@ -61,8 +68,12 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
@Override @Override
public Resources.Theme getTheme() { public Resources.Theme getTheme() {
if (modifiedTheme != null) {
return modifiedTheme;
}
// New themes // New themes
Resources.Theme modifiedTheme = super.getTheme(); modifiedTheme = super.getTheme();
modifiedTheme.applyStyle(getDesiredTheme(), true); modifiedTheme.applyStyle(getDesiredTheme(), true);
Integer accentTheme = getAccentTheme(); Integer accentTheme = getAccentTheme();
if (accentTheme != null) { if (accentTheme != null) {

View File

@@ -1,39 +0,0 @@
package org.thoughtcrime.securesms;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.session.libsignal.utilities.guava.Optional;
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview;
import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.recipients.Recipient;
import java.util.Locale;
import java.util.Set;
public interface BindableConversationItem extends Unbindable {
void bind(@NonNull MessageRecord messageRecord,
@NonNull Optional<MessageRecord> previousMessageRecord,
@NonNull Optional<MessageRecord> nextMessageRecord,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@NonNull Set<MessageRecord> batchSelected,
@NonNull Recipient recipients,
@Nullable String searchQuery,
boolean pulseHighlight);
MessageRecord getMessageRecord();
void setEventListener(@Nullable EventListener listener);
interface EventListener {
void onQuoteClicked(MmsMessageRecord messageRecord);
void onLinkPreviewClicked(@NonNull LinkPreview linkPreview);
void onMoreTextClicked(@NonNull Address conversationAddress, long messageId, boolean isMms);
}
}

View File

@@ -0,0 +1,16 @@
package org.thoughtcrime.securesms
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import network.loki.messenger.BuildConfig
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DeviceModule {
@Provides
@Singleton
fun provides() = BuildConfig.DEVICE
}

View File

@@ -1,51 +0,0 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.view.LayoutInflater
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import cn.carbswang.android.numberpickerview.library.NumberPickerView
import network.loki.messenger.R
import org.session.libsession.utilities.ExpirationUtil
fun Context.showExpirationDialog(
expiration: Int,
onExpirationTime: (Int) -> Unit
): AlertDialog {
val view = LayoutInflater.from(this).inflate(R.layout.expiration_dialog, null)
val numberPickerView = view.findViewById<NumberPickerView>(R.id.expiration_number_picker)
fun updateText(index: Int) {
view.findViewById<TextView>(R.id.expiration_details).text = when (index) {
0 -> getString(R.string.ExpirationDialog_your_messages_will_not_expire)
else -> getString(
R.string.ExpirationDialog_your_messages_will_disappear_s_after_they_have_been_seen,
numberPickerView.displayedValues[index]
)
}
}
val expirationTimes = resources.getIntArray(R.array.expiration_times)
val expirationDisplayValues = expirationTimes
.map { ExpirationUtil.getExpirationDisplayValue(this, it) }
.toTypedArray()
val selectedIndex = expirationTimes.run { indexOfFirst { it >= expiration }.coerceIn(indices) }
numberPickerView.apply {
displayedValues = expirationDisplayValues
minValue = 0
maxValue = expirationTimes.lastIndex
setOnValueChangedListener { _, _, index -> updateText(index) }
value = selectedIndex
}
updateText(selectedIndex)
return showSessionDialog {
title(getString(R.string.ExpirationDialog_disappearing_messages))
view(view)
okButton { onExpirationTime(numberPickerView.let { expirationTimes[it.value] }) }
cancelButton()
}
}

View File

@@ -21,6 +21,7 @@ import android.annotation.TargetApi;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.database.Cursor; import android.database.Cursor;
import android.database.CursorIndexOutOfBoundsException;
import android.net.Uri; import android.net.Uri;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Build; import android.os.Build;
@@ -47,7 +48,6 @@ import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.core.util.Pair; import androidx.core.util.Pair;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.loader.app.LoaderManager; import androidx.loader.app.LoaderManager;
@@ -146,6 +146,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION); View.SYSTEM_UI_FLAG_HIDE_NAVIGATION);
} }
}; };
private MediaItemAdapter adapter;
public static Intent getPreviewIntent(Context context, MediaPreviewArgs args) { public static Intent getPreviewIntent(Context context, MediaPreviewArgs args) {
return getPreviewIntent(context, args.getSlide(), args.getMmsRecord(), args.getThread()); return getPreviewIntent(context, args.getSlide(), args.getMmsRecord(), args.getThread());
@@ -218,13 +219,6 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
} }
@TargetApi(VERSION_CODES.JELLY_BEAN)
private void setFullscreenIfPossible() {
if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) {
getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_FULLSCREEN);
}
}
@Override @Override
public void onModified(Recipient recipient) { public void onModified(Recipient recipient) {
Util.runOnMain(this::updateActionBar); Util.runOnMain(this::updateActionBar);
@@ -286,9 +280,6 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
mediaPager = findViewById(R.id.media_pager); mediaPager = findViewById(R.id.media_pager);
mediaPager.setOffscreenPageLimit(1); mediaPager.setOffscreenPageLimit(1);
viewPagerListener = new ViewPagerListener();
mediaPager.addOnPageChangeListener(viewPagerListener);
albumRail = findViewById(R.id.media_preview_album_rail); albumRail = findViewById(R.id.media_preview_album_rail);
albumRailAdapter = new MediaRailAdapter(GlideApp.with(this), this, false); albumRailAdapter = new MediaRailAdapter(GlideApp.with(this), this, false);
@@ -379,7 +370,8 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
if (conversationRecipient != null) { if (conversationRecipient != null) {
getSupportLoaderManager().restartLoader(0, null, this); getSupportLoaderManager().restartLoader(0, null, this);
} else { } else {
mediaPager.setAdapter(new SingleItemPagerAdapter(this, GlideApp.with(this), getWindow(), initialMediaUri, initialMediaType, initialMediaSize)); adapter = new SingleItemPagerAdapter(this, GlideApp.with(this), getWindow(), initialMediaUri, initialMediaType, initialMediaSize);
mediaPager.setAdapter(adapter);
if (initialCaption != null) { if (initialCaption != null) {
detailsContainer.setVisibility(View.VISIBLE); detailsContainer.setVisibility(View.VISIBLE);
@@ -507,13 +499,8 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
} }
private @Nullable MediaItem getCurrentMediaItem() { private @Nullable MediaItem getCurrentMediaItem() {
MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter(); if (adapter == null) return null;
return adapter.getMediaItemFor(mediaPager.getCurrentItem());
if (adapter != null) {
return adapter.getMediaItemFor(mediaPager.getCurrentItem());
} else {
return null;
}
} }
public static boolean isContentTypeSupported(final String contentType) { public static boolean isContentTypeSupported(final String contentType) {
@@ -527,19 +514,28 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
@Override @Override
public void onLoadFinished(@NonNull Loader<Pair<Cursor, Integer>> loader, @Nullable Pair<Cursor, Integer> data) { public void onLoadFinished(@NonNull Loader<Pair<Cursor, Integer>> loader, @Nullable Pair<Cursor, Integer> data) {
if (data != null) { if (data == null) return;
CursorPagerAdapter adapter = new CursorPagerAdapter(this, GlideApp.with(this), getWindow(), data.first, data.second, leftIsRecent);
mediaPager.setAdapter(adapter);
adapter.setActive(true);
viewModel.setCursor(this, data.first, leftIsRecent); mediaPager.removeOnPageChangeListener(viewPagerListener);
int item = restartItem >= 0 ? restartItem : data.second; adapter = new CursorPagerAdapter(this, GlideApp.with(this), getWindow(), data.first, data.second, leftIsRecent);
mediaPager.setAdapter(adapter);
viewModel.setCursor(this, data.first, leftIsRecent);
int item = restartItem >= 0 && restartItem < adapter.getCount() ? restartItem : Math.max(Math.min(data.second, adapter.getCount() - 1), 0);
viewPagerListener = new ViewPagerListener();
mediaPager.addOnPageChangeListener(viewPagerListener);
try {
mediaPager.setCurrentItem(item); mediaPager.setCurrentItem(item);
} catch (CursorIndexOutOfBoundsException e) {
throw new RuntimeException("restartItem = " + restartItem + ", data.second = " + data.second + " leftIsRecent = " + leftIsRecent, e);
}
if (item == 0) { if (item == 0) {
viewPagerListener.onPageSelected(0); viewPagerListener.onPageSelected(0);
}
} }
} }
@@ -557,26 +553,26 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
if (currentPage != -1 && currentPage != position) onPageUnselected(currentPage); if (currentPage != -1 && currentPage != position) onPageUnselected(currentPage);
currentPage = position; currentPage = position;
MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter(); if (adapter == null) return;
if (adapter != null) { MediaItem item = adapter.getMediaItemFor(position);
MediaItem item = adapter.getMediaItemFor(position); if (item.recipient != null) item.recipient.addListener(MediaPreviewActivity.this);
if (item.recipient != null) item.recipient.addListener(MediaPreviewActivity.this); viewModel.setActiveAlbumRailItem(MediaPreviewActivity.this, position);
viewModel.setActiveAlbumRailItem(MediaPreviewActivity.this, position); updateActionBar();
updateActionBar();
}
} }
public void onPageUnselected(int position) { public void onPageUnselected(int position) {
MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter(); if (adapter == null) return;
if (adapter != null) { try {
MediaItem item = adapter.getMediaItemFor(position); MediaItem item = adapter.getMediaItemFor(position);
if (item.recipient != null) item.recipient.removeListener(MediaPreviewActivity.this); if (item.recipient != null) item.recipient.removeListener(MediaPreviewActivity.this);
} catch (CursorIndexOutOfBoundsException e) {
adapter.pause(position); throw new RuntimeException("position = " + position + " leftIsRecent = " + leftIsRecent, e);
} }
adapter.pause(position);
} }
@Override @Override
@@ -590,7 +586,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
} }
} }
private static class SingleItemPagerAdapter extends PagerAdapter implements MediaItemAdapter { private static class SingleItemPagerAdapter extends MediaItemAdapter {
private final GlideRequests glideRequests; private final GlideRequests glideRequests;
private final Window window; private final Window window;
@@ -662,7 +658,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
} }
} }
private static class CursorPagerAdapter extends PagerAdapter implements MediaItemAdapter { private static class CursorPagerAdapter extends MediaItemAdapter {
private final WeakHashMap<Integer, MediaView> mediaViews = new WeakHashMap<>(); private final WeakHashMap<Integer, MediaView> mediaViews = new WeakHashMap<>();
@@ -672,7 +668,6 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
private final Cursor cursor; private final Cursor cursor;
private final boolean leftIsRecent; private final boolean leftIsRecent;
private boolean active;
private int autoPlayPosition; private int autoPlayPosition;
CursorPagerAdapter(@NonNull Context context, @NonNull GlideRequests glideRequests, CursorPagerAdapter(@NonNull Context context, @NonNull GlideRequests glideRequests,
@@ -687,15 +682,9 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
this.leftIsRecent = leftIsRecent; this.leftIsRecent = leftIsRecent;
} }
public void setActive(boolean active) {
this.active = active;
notifyDataSetChanged();
}
@Override @Override
public int getCount() { public int getCount() {
if (!active) return 0; return cursor.getCount();
else return cursor.getCount();
} }
@Override @Override
@@ -768,8 +757,8 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
} }
private int getCursorPosition(int position) { private int getCursorPosition(int position) {
if (leftIsRecent) return position; int unclamped = leftIsRecent ? position : cursor.getCount() - 1 - position;
else return cursor.getCount() - 1 - position; return Math.max(Math.min(unclamped, cursor.getCount() - 1), 0);
} }
} }
@@ -797,9 +786,9 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
} }
} }
interface MediaItemAdapter { abstract static class MediaItemAdapter extends PagerAdapter {
MediaItem getMediaItemFor(int position); abstract MediaItem getMediaItemFor(int position);
void pause(int position); abstract void pause(int position);
@Nullable View getPlaybackControls(int position); @Nullable abstract View getPlaybackControls(int position);
} }
} }

View File

@@ -1,111 +0,0 @@
package org.thoughtcrime.securesms;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AbsListView;
import android.widget.BaseAdapter;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.contacts.UserView;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsession.utilities.Conversions;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
class MessageDetailsRecipientAdapter extends BaseAdapter implements AbsListView.RecyclerListener {
private final Context context;
private final GlideRequests glideRequests;
private final MessageRecord record;
private final List<RecipientDeliveryStatus> members;
private final boolean isPushGroup;
MessageDetailsRecipientAdapter(@NonNull Context context, @NonNull GlideRequests glideRequests,
@NonNull MessageRecord record, @NonNull List<RecipientDeliveryStatus> members,
boolean isPushGroup)
{
this.context = context;
this.glideRequests = glideRequests;
this.record = record;
this.isPushGroup = isPushGroup;
this.members = members;
}
@Override
public int getCount() {
return members.size();
}
@Override
public Object getItem(int position) {
return members.get(position);
}
@Override
public long getItemId(int position) {
try {
return Conversions.byteArrayToLong(MessageDigest.getInstance("SHA1").digest(members.get(position).recipient.getAddress().serialize().getBytes()));
} catch (NoSuchAlgorithmException e) {
throw new AssertionError(e);
}
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
UserView result = new UserView(context);
Recipient recipient = members.get(position).getRecipient();
result.setOpenGroupThreadID(record.getThreadId());
result.bind(recipient, glideRequests, UserView.ActionIndicator.None, false);
return result;
}
@Override
public void onMovedToScrapHeap(View view) {
((UserView)view).unbind();
}
static class RecipientDeliveryStatus {
enum Status {
UNKNOWN, PENDING, SENT, DELIVERED, READ
}
private final Recipient recipient;
private final Status deliveryStatus;
private final boolean isUnidentified;
private final long timestamp;
RecipientDeliveryStatus(Recipient recipient, Status deliveryStatus, boolean isUnidentified, long timestamp) {
this.recipient = recipient;
this.deliveryStatus = deliveryStatus;
this.isUnidentified = isUnidentified;
this.timestamp = timestamp;
}
Status getDeliveryStatus() {
return deliveryStatus;
}
boolean isUnidentified() {
return isUnidentified;
}
public long getTimestamp() {
return timestamp;
}
public Recipient getRecipient() {
return recipient;
}
}
}

View File

@@ -9,13 +9,14 @@ import android.os.Bundle;
import androidx.annotation.IdRes; import androidx.annotation.IdRes;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.home.HomeActivity; import org.thoughtcrime.securesms.home.HomeActivity;
import org.thoughtcrime.securesms.onboarding.LandingActivity; import org.thoughtcrime.securesms.onboarding.LandingActivity;
import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.service.KeyCachingService;
import org.session.libsession.utilities.TextSecurePreferences;
import java.util.Locale; import java.util.Locale;
@@ -168,7 +169,13 @@ public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarA
}; };
IntentFilter filter = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT); IntentFilter filter = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT);
registerReceiver(clearKeyReceiver, filter, KeyCachingService.KEY_PERMISSION, null); ContextCompat.registerReceiver(
this,
clearKeyReceiver, filter,
KeyCachingService.KEY_PERMISSION,
null,
ContextCompat.RECEIVER_NOT_EXPORTED
);
} }
private void removeClearKeyReceiver(Context context) { private void removeClearKeyReceiver(Context context) {

View File

@@ -8,6 +8,7 @@ import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.Button import android.widget.Button
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.LinearLayout.VERTICAL import android.widget.LinearLayout.VERTICAL
import android.widget.Space
import android.widget.TextView import android.widget.TextView
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.LayoutRes import androidx.annotation.LayoutRes
@@ -15,13 +16,11 @@ import androidx.annotation.StringRes
import androidx.annotation.StyleRes import androidx.annotation.StyleRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.setMargins import androidx.core.view.setMargins
import androidx.core.view.setPadding
import androidx.core.view.updateMargins import androidx.core.view.updateMargins
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import network.loki.messenger.R import network.loki.messenger.R
import org.thoughtcrime.securesms.util.toPx import org.thoughtcrime.securesms.util.toPx
@DslMarker @DslMarker
@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE) @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE)
annotation class DialogDsl annotation class DialogDsl
@@ -31,13 +30,16 @@ class SessionDialogBuilder(val context: Context) {
private val dp20 = toPx(20, context.resources) private val dp20 = toPx(20, context.resources)
private val dp40 = toPx(40, context.resources) private val dp40 = toPx(40, context.resources)
private val dp60 = toPx(60, context.resources)
private val dialogBuilder: AlertDialog.Builder = AlertDialog.Builder(context) private val dialogBuilder: AlertDialog.Builder = AlertDialog.Builder(context)
private var dialog: AlertDialog? = null private var dialog: AlertDialog? = null
private fun dismiss() = dialog?.dismiss() private fun dismiss() = dialog?.dismiss()
private val topView = LinearLayout(context).apply { orientation = VERTICAL } private val topView = LinearLayout(context)
.apply { setPadding(0, dp20, 0, 0) }
.apply { orientation = VERTICAL }
.also(dialogBuilder::setCustomTitle) .also(dialogBuilder::setCustomTitle)
private val contentView = LinearLayout(context).apply { orientation = VERTICAL } private val contentView = LinearLayout(context).apply { orientation = VERTICAL }
private val buttonLayout = LinearLayout(context) private val buttonLayout = LinearLayout(context)
@@ -53,18 +55,17 @@ class SessionDialogBuilder(val context: Context) {
fun title(text: CharSequence?) = title(text?.toString()) fun title(text: CharSequence?) = title(text?.toString())
fun title(text: String?) { fun title(text: String?) {
text(text, R.style.TextAppearance_AppCompat_Title) { setPadding(dp20) } text(text, R.style.TextAppearance_AppCompat_Title) { setPadding(dp20, 0, dp20, 0) }
} }
fun text(@StringRes id: Int, style: Int = 0) = text(context.getString(id), style) fun text(@StringRes id: Int, style: Int = 0) = text(context.getString(id), style)
fun text(text: CharSequence?, @StyleRes style: Int = 0) { fun text(text: CharSequence?, @StyleRes style: Int = 0) {
text(text, style) { text(text, style) {
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
.apply { updateMargins(dp40, 0, dp40, dp20) } .apply { updateMargins(dp40, 0, dp40, 0) }
} }
} }
private fun text(text: CharSequence?, @StyleRes style: Int, modify: TextView.() -> Unit) { private fun text(text: CharSequence?, @StyleRes style: Int, modify: TextView.() -> Unit) {
text ?: return text ?: return
TextView(context, null, 0, style) TextView(context, null, 0, style)
@@ -73,6 +74,10 @@ class SessionDialogBuilder(val context: Context) {
textAlignment = View.TEXT_ALIGNMENT_CENTER textAlignment = View.TEXT_ALIGNMENT_CENTER
modify() modify()
}.let(topView::addView) }.let(topView::addView)
Space(context).apply {
layoutParams = LinearLayout.LayoutParams(0, dp20)
}.let(topView::addView)
} }
fun view(view: View) = contentView.addView(view) fun view(view: View) = contentView.addView(view)
@@ -105,7 +110,7 @@ class SessionDialogBuilder(val context: Context) {
fun destructiveButton( fun destructiveButton(
@StringRes text: Int, @StringRes text: Int,
@StringRes contentDescription: Int, @StringRes contentDescription: Int = text,
listener: () -> Unit = {} listener: () -> Unit = {}
) = button( ) = button(
text, text,
@@ -120,13 +125,12 @@ class SessionDialogBuilder(val context: Context) {
@StringRes text: Int, @StringRes text: Int,
@StringRes contentDescriptionRes: Int = text, @StringRes contentDescriptionRes: Int = text,
@StyleRes style: Int = R.style.Widget_Session_Button_Dialog_UnimportantText, @StyleRes style: Int = R.style.Widget_Session_Button_Dialog_UnimportantText,
dismiss: Boolean = false, dismiss: Boolean = true,
listener: (() -> Unit) = {} listener: (() -> Unit) = {}
) = Button(context, null, 0, style).apply { ) = Button(context, null, 0, style).apply {
setText(text) setText(text)
contentDescription = resources.getString(contentDescriptionRes) contentDescription = resources.getString(contentDescriptionRes)
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, 1f) layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, dp60, 1f)
.apply { setMargins(toPx(20, resources)) }
setOnClickListener { setOnClickListener {
listener.invoke() listener.invoke()
if (dismiss) dismiss() if (dismiss) dismiss()

View File

@@ -1,5 +0,0 @@
package org.thoughtcrime.securesms;
public interface Unbindable {
public void unbind();
}

View File

@@ -50,7 +50,7 @@ public class AttachmentServer implements Runnable {
throws IOException throws IOException
{ {
try { try {
this.context = context; this.context = context.getApplicationContext();
this.attachment = attachment; this.attachment = attachment;
this.socket = new ServerSocket(0, 0, InetAddress.getByAddress(new byte[]{127, 0, 0, 1})); this.socket = new ServerSocket(0, 0, InetAddress.getByAddress(new byte[]{127, 0, 0, 1}));
this.port = socket.getLocalPort(); this.port = socket.getLocalPort();

View File

@@ -5,6 +5,8 @@ import android.text.TextUtils
import com.google.protobuf.ByteString import com.google.protobuf.ByteString
import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.EventBus
import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.MessageDataProvider
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.control.UnsendRequest
import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId 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.AttachmentState
@@ -184,18 +186,33 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
override fun deleteMessage(messageID: Long, isSms: Boolean) { override fun deleteMessage(messageID: Long, isSms: Boolean) {
val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase()
else DatabaseComponent.get(context).mmsDatabase() else DatabaseComponent.get(context).mmsDatabase()
val (threadId, timestamp) = runCatching { messagingDatabase.getMessageRecord(messageID).run { threadId to timestamp } }.getOrNull() ?: (null to null)
messagingDatabase.deleteMessage(messageID) messagingDatabase.deleteMessage(messageID)
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessage(messageID, isSms) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessage(messageID, isSms)
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID, mms = !isSms)
threadId ?: return
timestamp ?: return
MessagingModuleConfiguration.shared.lastSentTimestampCache.delete(threadId, timestamp)
} }
override fun deleteMessages(messageIDs: List<Long>, threadId: Long, isSms: Boolean) { override fun deleteMessages(messageIDs: List<Long>, threadId: Long, isSms: Boolean) {
val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase()
else DatabaseComponent.get(context).mmsDatabase() else DatabaseComponent.get(context).mmsDatabase()
val messages = messageIDs.mapNotNull { runCatching { messagingDatabase.getMessageRecord(it) }.getOrNull() }
// Perform local delete
messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId) messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId)
// Perform online delete
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs)
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs, mms = !isSms)
val threadId = messages.firstOrNull()?.threadId
threadId?.let{ MessagingModuleConfiguration.shared.lastSentTimestampCache.delete(it, messages.map { it.timestamp }) }
} }
override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? { override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? {
@@ -212,15 +229,12 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
return message.id return message.id
} }
override fun getServerHashForMessage(messageID: Long): String? { override fun getServerHashForMessage(messageID: Long, mms: Boolean): String? =
val messageDB = DatabaseComponent.get(context).lokiMessageDatabase() DatabaseComponent.get(context).lokiMessageDatabase().getMessageServerHash(messageID, mms)
return messageDB.getMessageServerHash(messageID)
}
override fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? { override fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? =
val attachmentDatabase = DatabaseComponent.get(context).attachmentDatabase() DatabaseComponent.get(context).attachmentDatabase()
return attachmentDatabase.getAttachment(AttachmentId(attachmentId, 0)) .getAttachment(AttachmentId(attachmentId, 0))
}
private fun scaleAndStripExif(attachmentDatabase: AttachmentDatabase, constraints: MediaConstraints, attachment: Attachment): Attachment? { private fun scaleAndStripExif(attachmentDatabase: AttachmentDatabase, constraints: MediaConstraints, attachment: Attachment): Attachment? {
return try { return try {

View File

@@ -45,7 +45,8 @@ public class AudioRecorder {
Log.i(TAG, "Running startRecording() + " + Thread.currentThread().getId()); Log.i(TAG, "Running startRecording() + " + Thread.currentThread().getId());
try { try {
if (audioCodec != null) { if (audioCodec != null) {
throw new AssertionError("We can only record once at a time."); Log.e(TAG, "Trying to start recording while another recording is in progress, exiting...");
return;
} }
ParcelFileDescriptor fds[] = ParcelFileDescriptor.createPipe(); ParcelFileDescriptor fds[] = ParcelFileDescriptor.createPipe();

View File

@@ -80,6 +80,11 @@ public class AudioSlidePlayer implements SensorEventListener {
} }
} }
@Nullable
public synchronized static AudioSlidePlayer getInstance() {
return playing.orNull();
}
private AudioSlidePlayer(@NonNull Context context, private AudioSlidePlayer(@NonNull Context context,
@NonNull AudioSlide slide, @NonNull AudioSlide slide,
@NonNull Listener listener) @NonNull Listener listener)

View File

@@ -93,6 +93,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
super.onNewIntent(intent) super.onNewIntent(intent)
if (intent?.action == ACTION_ANSWER) { if (intent?.action == ACTION_ANSWER) {
val answerIntent = WebRtcCallService.acceptCallIntent(this) val answerIntent = WebRtcCallService.acceptCallIntent(this)
answerIntent.flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
ContextCompat.startForegroundService(this, answerIntent) ContextCompat.startForegroundService(this, answerIntent)
} }
} }
@@ -106,6 +107,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
setShowWhenLocked(true) setShowWhenLocked(true)
setTurnScreenOn(true) setTurnScreenOn(true)
} }
window.addFlags( window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
or WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
@@ -334,6 +336,10 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
if (isEnabled) { if (isEnabled) {
viewModel.localRenderer?.let { surfaceView -> viewModel.localRenderer?.let { surfaceView ->
surfaceView.setZOrderOnTop(true) surfaceView.setZOrderOnTop(true)
// Mirror the video preview of the person making the call to prevent disorienting them
surfaceView.setMirror(true)
binding.localRenderer.addView(surfaceView) binding.localRenderer.addView(surfaceView)
} }
} }

View File

@@ -1,149 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.TypedArray;
import android.os.AsyncTask;
import android.util.AttributeSet;
import android.view.View;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.session.libsession.snode.SnodeAPI;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.util.DateUtils;
import java.util.Locale;
import network.loki.messenger.R;
public class ConversationItemFooter extends LinearLayout {
private TextView dateView;
private ExpirationTimerView timerView;
private ImageView insecureIndicatorView;
private DeliveryStatusView deliveryStatusView;
public ConversationItemFooter(Context context) {
super(context);
init(null);
}
public ConversationItemFooter(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(attrs);
}
public ConversationItemFooter(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(attrs);
}
private void init(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.conversation_item_footer, this);
dateView = findViewById(R.id.footer_date);
timerView = findViewById(R.id.footer_expiration_timer);
insecureIndicatorView = findViewById(R.id.footer_insecure_indicator);
deliveryStatusView = findViewById(R.id.footer_delivery_status);
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemFooter, 0, 0);
setTextColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_text_color, getResources().getColor(R.color.core_white)));
setIconColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_icon_color, getResources().getColor(R.color.core_white)));
typedArray.recycle();
}
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
timerView.stopAnimation();
}
public void setMessageRecord(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
presentDate(messageRecord, locale);
presentTimer(messageRecord);
presentInsecureIndicator(messageRecord);
presentDeliveryStatus(messageRecord);
}
public void setTextColor(int color) {
dateView.setTextColor(color);
}
public void setIconColor(int color) {
timerView.setColorFilter(color);
insecureIndicatorView.setColorFilter(color);
deliveryStatusView.setTint(color);
}
private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
dateView.forceLayout();
if (messageRecord.isFailed()) {
dateView.setText(R.string.ConversationItem_error_not_delivered);
} else {
dateView.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
}
}
@SuppressLint("StaticFieldLeak")
private void presentTimer(@NonNull final MessageRecord messageRecord) {
if (messageRecord.getExpiresIn() > 0 && !messageRecord.isPending()) {
this.timerView.setVisibility(View.VISIBLE);
this.timerView.setPercentComplete(0);
if (messageRecord.getExpireStarted() > 0) {
this.timerView.setExpirationTime(messageRecord.getExpireStarted(),
messageRecord.getExpiresIn());
this.timerView.startAnimation();
if (messageRecord.getExpireStarted() + messageRecord.getExpiresIn() <= SnodeAPI.getNowWithOffset()) {
ApplicationContext.getInstance(getContext()).getExpiringMessageManager().checkSchedule();
}
} else if (!messageRecord.isOutgoing() && !messageRecord.isMediaPending()) {
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
ExpiringMessageManager expirationManager = ApplicationContext.getInstance(getContext()).getExpiringMessageManager();
long id = messageRecord.getId();
boolean mms = messageRecord.isMms();
if (mms) DatabaseComponent.get(getContext()).mmsDatabase().markExpireStarted(id);
else DatabaseComponent.get(getContext()).smsDatabase().markExpireStarted(id);
expirationManager.scheduleDeletion(id, mms, messageRecord.getExpiresIn());
return null;
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
} else {
this.timerView.setVisibility(View.GONE);
}
}
private void presentInsecureIndicator(@NonNull MessageRecord messageRecord) {
insecureIndicatorView.setVisibility(View.GONE);
}
private void presentDeliveryStatus(@NonNull MessageRecord messageRecord) {
if (!messageRecord.isFailed()) {
if (!messageRecord.isOutgoing()) deliveryStatusView.setNone();
else if (messageRecord.isPending()) deliveryStatusView.setPending();
else if (messageRecord.isRead()) deliveryStatusView.setRead();
else if (messageRecord.isDelivered()) deliveryStatusView.setDelivered();
else deliveryStatusView.setSent();
} else {
deliveryStatusView.setNone();
}
}
}

View File

@@ -1,50 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import androidx.annotation.ColorInt;
public class Outliner {
private final float[] radii = new float[8];
private final Path corners = new Path();
private final RectF bounds = new RectF();
private final Paint outlinePaint = new Paint();
{
outlinePaint.setStyle(Paint.Style.STROKE);
outlinePaint.setStrokeWidth(1f);
outlinePaint.setAntiAlias(true);
}
public void setColor(@ColorInt int color) {
outlinePaint.setColor(color);
}
public void draw(Canvas canvas) {
final float halfStrokeWidth = outlinePaint.getStrokeWidth() / 2;
bounds.left = halfStrokeWidth;
bounds.top = halfStrokeWidth;
bounds.right = canvas.getWidth() - halfStrokeWidth;
bounds.bottom = canvas.getHeight() - halfStrokeWidth;
corners.reset();
corners.addRoundRect(bounds, radii, Path.Direction.CW);
canvas.drawPath(corners, outlinePaint);
}
public void setRadius(int radius) {
setRadii(radius, radius, radius, radius);
}
public void setRadii(int topLeft, int topRight, int bottomRight, int bottomLeft) {
radii[0] = radii[1] = topLeft;
radii[2] = radii[3] = topRight;
radii[4] = radii[5] = bottomRight;
radii[6] = radii[7] = bottomLeft;
}
}

View File

@@ -6,11 +6,9 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
import android.widget.RelativeLayout import android.widget.RelativeLayout
import androidx.annotation.DimenRes
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewProfilePictureBinding import network.loki.messenger.databinding.ViewProfilePictureBinding
import network.loki.messenger.databinding.ViewUserBinding
import org.session.libsession.avatars.ContactColors import org.session.libsession.avatars.ContactColors
import org.session.libsession.avatars.PlaceholderAvatarPhoto import org.session.libsession.avatars.PlaceholderAvatarPhoto
import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.avatars.ProfileContactPhoto
@@ -34,13 +32,12 @@ class ProfilePictureView @JvmOverloads constructor(
var additionalDisplayName: String? = null var additionalDisplayName: String? = null
var isLarge = false var isLarge = false
private val profilePicturesCache = mutableMapOf<String, String?>() private val profilePicturesCache = mutableMapOf<View, Recipient>()
private val unknownRecipientDrawable by lazy { ResourceContactPhoto(R.drawable.ic_profile_default) private val unknownRecipientDrawable by lazy { ResourceContactPhoto(R.drawable.ic_profile_default)
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) } .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) }
private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification) private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification)
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) } .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) }
// endregion // endregion
constructor(context: Context, sender: Recipient): this(context) { constructor(context: Context, sender: Recipient): this(context) {
@@ -74,7 +71,7 @@ class ProfilePictureView @JvmOverloads constructor(
additionalDisplayName = getUserDisplayName(apk) additionalDisplayName = getUserDisplayName(apk)
} }
} else if(recipient.isOpenGroupInboxRecipient) { } else if(recipient.isOpenGroupInboxRecipient) {
val publicKey = GroupUtil.getDecodedOpenGroupInbox(recipient.address.serialize()) val publicKey = GroupUtil.getDecodedOpenGroupInboxSessionId(recipient.address.serialize())
this.publicKey = publicKey this.publicKey = publicKey
displayName = getUserDisplayName(publicKey) displayName = getUserDisplayName(publicKey)
additionalPublicKey = null additionalPublicKey = null
@@ -91,8 +88,8 @@ class ProfilePictureView @JvmOverloads constructor(
val publicKey = publicKey ?: return val publicKey = publicKey ?: return
val additionalPublicKey = additionalPublicKey val additionalPublicKey = additionalPublicKey
if (additionalPublicKey != null) { if (additionalPublicKey != null) {
setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName, R.dimen.small_profile_picture_size) setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName)
setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName, R.dimen.small_profile_picture_size) setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName)
binding.doubleModeImageViewContainer.visibility = View.VISIBLE binding.doubleModeImageViewContainer.visibility = View.VISIBLE
} else { } else {
glide.clear(binding.doubleModeImageView1) glide.clear(binding.doubleModeImageView1)
@@ -100,14 +97,14 @@ class ProfilePictureView @JvmOverloads constructor(
binding.doubleModeImageViewContainer.visibility = View.INVISIBLE binding.doubleModeImageViewContainer.visibility = View.INVISIBLE
} }
if (additionalPublicKey == null && !isLarge) { if (additionalPublicKey == null && !isLarge) {
setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName, R.dimen.medium_profile_picture_size) setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName)
binding.singleModeImageView.visibility = View.VISIBLE binding.singleModeImageView.visibility = View.VISIBLE
} else { } else {
glide.clear(binding.singleModeImageView) glide.clear(binding.singleModeImageView)
binding.singleModeImageView.visibility = View.INVISIBLE binding.singleModeImageView.visibility = View.INVISIBLE
} }
if (additionalPublicKey == null && isLarge) { if (additionalPublicKey == null && isLarge) {
setProfilePictureIfNeeded(binding.largeSingleModeImageView, publicKey, displayName, R.dimen.large_profile_picture_size) setProfilePictureIfNeeded(binding.largeSingleModeImageView, publicKey, displayName)
binding.largeSingleModeImageView.visibility = View.VISIBLE binding.largeSingleModeImageView.visibility = View.VISIBLE
} else { } else {
glide.clear(binding.largeSingleModeImageView) glide.clear(binding.largeSingleModeImageView)
@@ -115,17 +112,19 @@ class ProfilePictureView @JvmOverloads constructor(
} }
} }
private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?, @DimenRes sizeResId: Int) { private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?) {
if (publicKey.isNotEmpty()) { if (publicKey.isNotEmpty()) {
val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false) val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false)
if (profilePicturesCache.containsKey(publicKey) && profilePicturesCache[publicKey] == recipient.profileAvatar) return if (profilePicturesCache[imageView] == recipient) return
profilePicturesCache[imageView] = recipient
val signalProfilePicture = recipient.contactPhoto val signalProfilePicture = recipient.contactPhoto
val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject
val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}") glide.clear(imageView)
val placeholder = PlaceholderAvatarPhoto(publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
if (signalProfilePicture != null && avatar != "0" && avatar != "") { if (signalProfilePicture != null && avatar != "0" && avatar != "") {
glide.clear(imageView)
glide.load(signalProfilePicture) glide.load(signalProfilePicture)
.placeholder(unknownRecipientDrawable) .placeholder(unknownRecipientDrawable)
.centerCrop() .centerCrop()
@@ -133,21 +132,19 @@ class ProfilePictureView @JvmOverloads constructor(
.diskCacheStrategy(DiskCacheStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE)
.circleCrop() .circleCrop()
.into(imageView) .into(imageView)
} else if (recipient.isOpenGroupRecipient && recipient.groupAvatarId == null) { } else if (recipient.isCommunityRecipient && recipient.groupAvatarId == null) {
glide.clear(imageView) glide.clear(imageView)
glide.load(unknownOpenGroupDrawable) glide.load(unknownOpenGroupDrawable)
.centerCrop() .centerCrop()
.circleCrop() .circleCrop()
.into(imageView) .into(imageView)
} else { } else {
glide.clear(imageView)
glide.load(placeholder) glide.load(placeholder)
.placeholder(unknownRecipientDrawable) .placeholder(unknownRecipientDrawable)
.centerCrop() .centerCrop()
.circleCrop() .circleCrop()
.diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView) .diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView)
} }
profilePicturesCache[publicKey] = recipient.profileAvatar
} else { } else {
glide.load(unknownRecipientDrawable) glide.load(unknownRecipientDrawable)
.centerCrop() .centerCrop()

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.components; package org.thoughtcrime.securesms.components;
import android.animation.Animator; import android.animation.Animator;
import android.content.Context; import android.content.Context;
import android.os.Build; import android.os.Build;
@@ -68,9 +67,7 @@ public class SearchToolbar extends LinearLayout {
} }
@Override @Override
public boolean onQueryTextChange(String newText) { public boolean onQueryTextChange(String newText) { return onQueryTextSubmit(newText); }
return onQueryTextSubmit(newText);
}
}); });
searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() {

View File

@@ -3,133 +3,95 @@ package org.thoughtcrime.securesms.components.emoji;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.net.Uri; import android.net.Uri;
import android.os.AsyncTask;
import android.preference.PreferenceManager; import android.preference.PreferenceManager;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import com.fasterxml.jackson.databind.type.CollectionType;
import com.fasterxml.jackson.databind.type.TypeFactory;
import org.session.libsignal.utilities.JsonUtil; import org.session.libsignal.utilities.JsonUtil;
import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Log;
import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Iterator; import java.util.LinkedList;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import network.loki.messenger.R; import network.loki.messenger.R;
public class RecentEmojiPageModel implements EmojiPageModel { public class RecentEmojiPageModel implements EmojiPageModel {
private static final String TAG = RecentEmojiPageModel.class.getSimpleName(); private static final String TAG = RecentEmojiPageModel.class.getSimpleName();
private static final String EMOJI_LRU_PREFERENCE = "pref_recent_emoji2"; public static final String RECENT_EMOJIS_KEY = "Recents";
private static final int EMOJI_LRU_SIZE = 50;
public static final String KEY = "Recents";
public static final List<String> DEFAULT_REACTIONS_LIST =
Arrays.asList("\ud83d\ude02", "\ud83e\udd70", "\ud83d\ude22", "\ud83d\ude21", "\ud83d\ude2e", "\ud83d\ude08");
private final SharedPreferences prefs; public static final LinkedList<String> DEFAULT_REACTION_EMOJIS_LIST = new LinkedList<>(Arrays.asList(
private final LinkedHashSet<String> recentlyUsed; "\ud83d\ude02",
"\ud83e\udd70",
"\ud83d\ude22",
"\ud83d\ude21",
"\ud83d\ude2e",
"\ud83d\ude08"));
public static final String DEFAULT_REACTION_EMOJIS_JSON_STRING = JsonUtil.toJson(new LinkedList<>(DEFAULT_REACTION_EMOJIS_LIST));
private static SharedPreferences prefs;
private static LinkedList<String> recentlyUsed;
public RecentEmojiPageModel(Context context) { public RecentEmojiPageModel(Context context) {
this.prefs = PreferenceManager.getDefaultSharedPreferences(context); prefs = PreferenceManager.getDefaultSharedPreferences(context);
this.recentlyUsed = getPersistedCache();
}
private LinkedHashSet<String> getPersistedCache() { // Note: Do NOT try to populate or update the persisted recent emojis in the constructor - the
String serialized = prefs.getString(EMOJI_LRU_PREFERENCE, "[]"); // `getEmoji` method ends up getting called half-way through in a race-condition manner.
try {
CollectionType collectionType = TypeFactory.defaultInstance()
.constructCollectionType(LinkedHashSet.class, String.class);
return JsonUtil.getMapper().readValue(serialized, collectionType);
} catch (IOException e) {
Log.w(TAG, e);
return new LinkedHashSet<>();
}
} }
@Override @Override
public String getKey() { public String getKey() { return RECENT_EMOJIS_KEY; }
return KEY;
}
@Override public int getIconAttr() { @Override public int getIconAttr() { return R.attr.emoji_category_recent; }
return R.attr.emoji_category_recent;
}
@Override public List<String> getEmoji() { @Override public List<String> getEmoji() {
List<String> recent = new ArrayList<>(recentlyUsed); // Populate our recently used list if required (i.e., on first run)
List<String> out = new ArrayList<>(DEFAULT_REACTIONS_LIST.size()); if (recentlyUsed == null) {
try {
for (int i = 0; i < DEFAULT_REACTIONS_LIST.size(); i++) { String recentlyUsedEmjoiJsonString = prefs.getString(RECENT_EMOJIS_KEY, DEFAULT_REACTION_EMOJIS_JSON_STRING);
if (recent.size() > i) { recentlyUsed = JsonUtil.fromJson(recentlyUsedEmjoiJsonString, LinkedList.class);
out.add(recent.get(i)); } catch (Exception e) {
} else { Log.w(TAG, e);
out.add(DEFAULT_REACTIONS_LIST.get(i)); Log.d(TAG, "Default reaction emoji data was corrupt (likely via key re-use on app upgrade) - rewriting fresh data.");
boolean writeSuccess = prefs.edit().putString(RECENT_EMOJIS_KEY, DEFAULT_REACTION_EMOJIS_JSON_STRING).commit();
if (!writeSuccess) { Log.w(TAG, "Failed to update recently used emojis in shared prefs."); }
recentlyUsed = DEFAULT_REACTION_EMOJIS_LIST;
} }
} }
return new ArrayList<>(recentlyUsed);
return out;
} }
@Override public List<Emoji> getDisplayEmoji() { @Override public List<Emoji> getDisplayEmoji() {
return Stream.of(getEmoji()).map(Emoji::new).toList(); return Stream.of(getEmoji()).map(Emoji::new).toList();
} }
@Override public boolean hasSpriteMap() { @Override public boolean hasSpriteMap() { return false; }
return false;
}
@Nullable @Nullable
@Override @Override
public Uri getSpriteUri() { public Uri getSpriteUri() { return null; }
return null;
}
@Override public boolean isDynamic() { @Override public boolean isDynamic() { return true; }
return true;
}
public void onCodePointSelected(String emoji) { public static void onCodePointSelected(String emoji) {
recentlyUsed.remove(emoji); // If the emoji is already in the recently used list then remove it..
recentlyUsed.add(emoji); if (recentlyUsed.contains(emoji)) { recentlyUsed.removeFirstOccurrence(emoji); }
if (recentlyUsed.size() > EMOJI_LRU_SIZE) { // ..and then regardless of whether the emoji used was already in the recently used list or not
Iterator<String> iterator = recentlyUsed.iterator(); // it gets placed as the first element in the list..
iterator.next(); recentlyUsed.addFirst(emoji);
iterator.remove();
}
final LinkedHashSet<String> latestRecentlyUsed = new LinkedHashSet<>(recentlyUsed); // Ensure that we only ever store data for a maximum of 6 recently used emojis (this code will
new AsyncTask<Void, Void, Void>() { // execute if if we did NOT remove any occurrence of a previously used emoji but then added the
// new emoji to the front of the list).
while (recentlyUsed.size() > 6) { recentlyUsed.removeLast(); }
@Override // ..which we then save to shared prefs.
protected Void doInBackground(Void... params) { String recentlyUsedAsJsonString = JsonUtil.toJson(recentlyUsed);
try { boolean writeSuccess = prefs.edit().putString(RECENT_EMOJIS_KEY, recentlyUsedAsJsonString).commit();
String serialized = JsonUtil.toJsonThrows(latestRecentlyUsed); if (!writeSuccess) { Log.w(TAG, "Failed to update recently used emojis in shared prefs."); }
prefs.edit()
.putString(EMOJI_LRU_PREFERENCE, serialized)
.apply();
} catch (IOException e) {
Log.w(TAG, e);
}
return null;
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
private String[] toReversePrimitiveArray(@NonNull LinkedHashSet<String> emojiSet) {
String[] emojis = new String[emojiSet.size()];
int i = emojiSet.size() - 1;
for (String emoji : emojiSet) {
emojis[i--] = emoji;
}
return emojis;
} }
} }

View File

@@ -1,107 +0,0 @@
package org.thoughtcrime.securesms.components.emoji.parsing;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.AssetManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.AsyncTask;
import androidx.annotation.NonNull;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.session.libsession.utilities.ListenableFutureTask;
import org.session.libsession.utilities.Util;
import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.SoftReference;
import java.util.concurrent.Callable;
public class EmojiPageBitmap {
private static final String TAG = EmojiPageBitmap.class.getSimpleName();
private final Context context;
private final EmojiPageModel model;
private final float decodeScale;
private SoftReference<Bitmap> bitmapReference;
private ListenableFutureTask<Bitmap> task;
public EmojiPageBitmap(@NonNull Context context, @NonNull EmojiPageModel model, float decodeScale) {
this.context = context.getApplicationContext();
this.model = model;
this.decodeScale = decodeScale;
}
@SuppressLint("StaticFieldLeak")
public ListenableFutureTask<Bitmap> get() {
Util.assertMainThread();
if (bitmapReference != null && bitmapReference.get() != null) {
return new ListenableFutureTask<>(bitmapReference.get());
} else if (task != null) {
return task;
} else {
Callable<Bitmap> callable = () -> {
try {
Log.i(TAG, "loading page " + model.getSpriteUri().toString());
return loadPage();
} catch (IOException ioe) {
Log.w(TAG, ioe);
}
return null;
};
task = new ListenableFutureTask<>(callable);
new AsyncTask<Void, Void, Void>() {
@Override protected Void doInBackground(Void... params) {
task.run();
return null;
}
@Override protected void onPostExecute(Void aVoid) {
task = null;
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
return task;
}
private Bitmap loadPage() throws IOException {
if (bitmapReference != null && bitmapReference.get() != null) return bitmapReference.get();
float scale = decodeScale;
AssetManager assetManager = context.getAssets();
InputStream assetStream = assetManager.open(model.getSpriteUri().toString());
BitmapFactory.Options options = new BitmapFactory.Options();
if (org.thoughtcrime.securesms.util.Util.isLowMemory(context)) {
Log.i(TAG, "Low memory detected. Changing sample size.");
options.inSampleSize = 2;
scale = decodeScale * 2;
}
Stopwatch stopwatch = new Stopwatch(model.getSpriteUri().toString());
Bitmap bitmap = BitmapFactory.decodeStream(assetStream, null, options);
stopwatch.split("decode");
Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, (int)(bitmap.getWidth() * scale), (int)(bitmap.getHeight() * scale), true);
stopwatch.split("scale");
stopwatch.stop(TAG);
bitmapReference = new SoftReference<>(scaledBitmap);
Log.i(TAG, "onPageLoaded(" + model.getSpriteUri().toString() + ") originalByteCount: " + bitmap.getByteCount()
+ " scaledByteCount: " + scaledBitmap.getByteCount()
+ " scaledSize: " + scaledBitmap.getWidth() + "x" + scaledBitmap.getHeight());
return scaledBitmap;
}
@Override
public @NonNull String toString() {
return model.getSpriteUri().toString();
}
}

View File

@@ -1,13 +1,17 @@
package org.thoughtcrime.securesms.components.menu package org.thoughtcrime.securesms.components.menu
import android.content.Context
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.ColorRes
/** /**
* Represents an action to be rendered * Represents an action to be rendered
*/ */
data class ActionItem @JvmOverloads constructor( data class ActionItem(
@AttrRes val iconRes: Int, @AttrRes val iconRes: Int,
val title: CharSequence, val title: Int,
val action: Runnable, val action: Runnable,
val contentDescription: String? = null val contentDescription: Int? = null,
val subtitle: ((Context) -> CharSequence?)? = null,
@ColorRes val color: Int? = null,
) )

View File

@@ -1,13 +1,23 @@
package org.thoughtcrime.securesms.components.menu package org.thoughtcrime.securesms.components.menu
import android.content.Context
import android.content.res.ColorStateList
import android.util.TypedValue import android.util.TypedValue
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isGone
import androidx.core.widget.ImageViewCompat
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.utilities.getColorFromAttr
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
@@ -34,30 +44,23 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
mappingAdapter.submitList(items.toAdapterItems()) mappingAdapter.submitList(items.toAdapterItems())
} }
private fun List<ActionItem>.toAdapterItems(): List<DisplayItem> { private fun List<ActionItem>.toAdapterItems(): List<DisplayItem> =
return this.mapIndexed { index, item -> mapIndexed { index, item ->
val displayType: DisplayType = when { when {
this.size == 1 -> DisplayType.ONLY size == 1 -> DisplayType.ONLY
index == 0 -> DisplayType.TOP index == 0 -> DisplayType.TOP
index == this.size - 1 -> DisplayType.BOTTOM index == size - 1 -> DisplayType.BOTTOM
else -> DisplayType.MIDDLE else -> DisplayType.MIDDLE
} }.let { DisplayItem(item, it) }
DisplayItem(item, displayType)
} }
}
private data class DisplayItem( private data class DisplayItem(
val item: ActionItem, val item: ActionItem,
val displayType: DisplayType val displayType: DisplayType
) : MappingModel<DisplayItem> { ) : MappingModel<DisplayItem> {
override fun areItemsTheSame(newItem: DisplayItem): Boolean { override fun areItemsTheSame(newItem: DisplayItem): Boolean = this == newItem
return this == newItem
}
override fun areContentsTheSame(newItem: DisplayItem): Boolean { override fun areContentsTheSame(newItem: DisplayItem): Boolean = this == newItem
return this == newItem
}
} }
private enum class DisplayType { private enum class DisplayType {
@@ -68,28 +71,61 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
itemView: View, itemView: View,
private val onItemClick: () -> Unit, private val onItemClick: () -> Unit,
) : MappingViewHolder<DisplayItem>(itemView) { ) : MappingViewHolder<DisplayItem>(itemView) {
private var subtitleJob: Job? = null
val icon: ImageView = itemView.findViewById(R.id.context_menu_item_icon) val icon: ImageView = itemView.findViewById(R.id.context_menu_item_icon)
val title: TextView = itemView.findViewById(R.id.context_menu_item_title) val title: TextView = itemView.findViewById(R.id.context_menu_item_title)
val subtitle: TextView = itemView.findViewById(R.id.context_menu_item_subtitle)
override fun bind(model: DisplayItem) { override fun bind(model: DisplayItem) {
if (model.item.iconRes > 0) { val item = model.item
val color = item.color?.let { ContextCompat.getColor(context, it) }
if (item.iconRes > 0) {
val typedValue = TypedValue() val typedValue = TypedValue()
context.theme.resolveAttribute(model.item.iconRes, typedValue, true) context.theme.resolveAttribute(item.iconRes, typedValue, true)
icon.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId)) icon.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId))
icon.imageTintList = ColorStateList.valueOf(color ?: context.getColorFromAttr(android.R.attr.textColor))
} }
itemView.contentDescription = model.item.contentDescription item.contentDescription?.let(context.resources::getString)?.let { itemView.contentDescription = it }
title.text = model.item.title title.setText(item.title)
color?.let(title::setTextColor)
color?.let(subtitle::setTextColor)
subtitle.isGone = true
item.subtitle?.let { startSubtitleJob(subtitle, it) }
itemView.setOnClickListener { itemView.setOnClickListener {
model.item.action.run() item.action.run()
onItemClick() onItemClick()
} }
when (model.displayType) { when (model.displayType) {
DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_top) DisplayType.TOP -> R.drawable.context_menu_item_background_top
DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_bottom) DisplayType.BOTTOM -> R.drawable.context_menu_item_background_bottom
DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_middle) DisplayType.MIDDLE -> R.drawable.context_menu_item_background_middle
DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_only) DisplayType.ONLY -> R.drawable.context_menu_item_background_only
}.let(itemView::setBackgroundResource)
}
private fun startSubtitleJob(textView: TextView, getSubtitle: (Context) -> CharSequence?) {
fun updateText() = getSubtitle(context).let {
textView.isGone = it == null
textView.text = it
} }
updateText()
subtitleJob?.cancel()
subtitleJob = CoroutineScope(Dispatchers.Main).launch {
while (true) {
updateText()
delay(200)
}
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
// naive job cancellation, will break if many items are added to context menu.
subtitleJob?.cancel()
} }
} }
} }

View File

@@ -1,31 +0,0 @@
package org.thoughtcrime.securesms.components.recyclerview;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.LinearSmoothScroller;
import android.util.DisplayMetrics;
public class SmoothScrollingLinearLayoutManager extends LinearLayoutManager {
public SmoothScrollingLinearLayoutManager(Context context, boolean reverseLayout) {
super(context, LinearLayoutManager.VERTICAL, reverseLayout);
}
public void smoothScrollToPosition(@NonNull Context context, int position, float millisecondsPerInch) {
final LinearSmoothScroller scroller = new LinearSmoothScroller(context) {
@Override
protected int getVerticalSnapPreference() {
return LinearSmoothScroller.SNAP_TO_END;
}
@Override
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
return millisecondsPerInch / displayMetrics.densityDpi;
}
};
scroller.setTargetPosition(position);
startSmoothScroll(scroller);
}
}

View File

@@ -58,7 +58,7 @@ class ContactSelectionListLoader(context: Context, val mode: Int, val filter: St
private fun getOpenGroups(contacts: List<Recipient>): List<ContactSelectionListItem> { private fun getOpenGroups(contacts: List<Recipient>): List<ContactSelectionListItem> {
return getItems(contacts, context.getString(R.string.fragment_contact_selection_open_groups_title)) { return getItems(contacts, context.getString(R.string.fragment_contact_selection_open_groups_title)) {
it.address.isOpenGroup it.address.isCommunity
} }
} }

View File

@@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.contactshare; package org.thoughtcrime.securesms.contacts;
import android.content.Context; import android.content.Context;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@@ -24,7 +24,7 @@ public final class ContactUtil {
return SpanUtil.italic(context.getString(R.string.MessageNotifier_unknown_contact_message)); return SpanUtil.italic(context.getString(R.string.MessageNotifier_unknown_contact_message));
} }
public static @NonNull String getDisplayName(@Nullable Contact contact) { private static @NonNull String getDisplayName(@Nullable Contact contact) {
if (contact == null) { if (contact == null) {
return ""; return "";
} }

View File

@@ -1,169 +0,0 @@
package org.thoughtcrime.securesms.contactshare;
import androidx.annotation.NonNull;
import org.session.libsession.messaging.sending_receiving.attachments.Attachment;
import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment;
import org.session.libsignal.utilities.guava.Optional;
import org.session.libsignal.messages.SharedContact;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import org.session.libsession.utilities.Contact;
import static org.session.libsession.utilities.Contact.*;
public class ContactModelMapper {
public static SharedContact.Builder localToRemoteBuilder(@NonNull Contact contact) {
List<SharedContact.Phone> phoneNumbers = new ArrayList<>(contact.getPhoneNumbers().size());
List<SharedContact.Email> emails = new ArrayList<>(contact.getEmails().size());
List<SharedContact.PostalAddress> postalAddresses = new ArrayList<>(contact.getPostalAddresses().size());
for (Phone phone : contact.getPhoneNumbers()) {
phoneNumbers.add(new SharedContact.Phone.Builder().setValue(phone.getNumber())
.setType(localToRemoteType(phone.getType()))
.setLabel(phone.getLabel())
.build());
}
for (Email email : contact.getEmails()) {
emails.add(new SharedContact.Email.Builder().setValue(email.getEmail())
.setType(localToRemoteType(email.getType()))
.setLabel(email.getLabel())
.build());
}
for (PostalAddress postalAddress : contact.getPostalAddresses()) {
postalAddresses.add(new SharedContact.PostalAddress.Builder().setType(localToRemoteType(postalAddress.getType()))
.setLabel(postalAddress.getLabel())
.setStreet(postalAddress.getStreet())
.setPobox(postalAddress.getPoBox())
.setNeighborhood(postalAddress.getNeighborhood())
.setCity(postalAddress.getCity())
.setRegion(postalAddress.getRegion())
.setPostcode(postalAddress.getPostalCode())
.setCountry(postalAddress.getCountry())
.build());
}
SharedContact.Name name = new SharedContact.Name.Builder().setDisplay(contact.getName().getDisplayName())
.setGiven(contact.getName().getGivenName())
.setFamily(contact.getName().getFamilyName())
.setPrefix(contact.getName().getPrefix())
.setSuffix(contact.getName().getSuffix())
.setMiddle(contact.getName().getMiddleName())
.build();
return new SharedContact.Builder().setName(name)
.withOrganization(contact.getOrganization())
.withPhones(phoneNumbers)
.withEmails(emails)
.withAddresses(postalAddresses);
}
public static Contact remoteToLocal(@NonNull SharedContact sharedContact) {
Name name = new Name(sharedContact.getName().getDisplay().orNull(),
sharedContact.getName().getGiven().orNull(),
sharedContact.getName().getFamily().orNull(),
sharedContact.getName().getPrefix().orNull(),
sharedContact.getName().getSuffix().orNull(),
sharedContact.getName().getMiddle().orNull());
List<Phone> phoneNumbers = new LinkedList<>();
if (sharedContact.getPhone().isPresent()) {
for (SharedContact.Phone phone : sharedContact.getPhone().get()) {
phoneNumbers.add(new Phone(phone.getValue(),
remoteToLocalType(phone.getType()),
phone.getLabel().orNull()));
}
}
List<Email> emails = new LinkedList<>();
if (sharedContact.getEmail().isPresent()) {
for (SharedContact.Email email : sharedContact.getEmail().get()) {
emails.add(new Email(email.getValue(),
remoteToLocalType(email.getType()),
email.getLabel().orNull()));
}
}
List<PostalAddress> postalAddresses = new LinkedList<>();
if (sharedContact.getAddress().isPresent()) {
for (SharedContact.PostalAddress postalAddress : sharedContact.getAddress().get()) {
postalAddresses.add(new PostalAddress(remoteToLocalType(postalAddress.getType()),
postalAddress.getLabel().orNull(),
postalAddress.getStreet().orNull(),
postalAddress.getPobox().orNull(),
postalAddress.getNeighborhood().orNull(),
postalAddress.getCity().orNull(),
postalAddress.getRegion().orNull(),
postalAddress.getPostcode().orNull(),
postalAddress.getCountry().orNull()));
}
}
Avatar avatar = null;
if (sharedContact.getAvatar().isPresent()) {
Attachment attachment = PointerAttachment.forPointer(Optional.of(sharedContact.getAvatar().get().getAttachment().asPointer())).get();
boolean isProfile = sharedContact.getAvatar().get().isProfile();
avatar = new Avatar(null, attachment, isProfile);
}
return new Contact(name, sharedContact.getOrganization().orNull(), phoneNumbers, emails, postalAddresses, avatar);
}
private static Phone.Type remoteToLocalType(SharedContact.Phone.Type type) {
switch (type) {
case HOME: return Phone.Type.HOME;
case MOBILE: return Phone.Type.MOBILE;
case WORK: return Phone.Type.WORK;
default: return Phone.Type.CUSTOM;
}
}
private static Email.Type remoteToLocalType(SharedContact.Email.Type type) {
switch (type) {
case HOME: return Email.Type.HOME;
case MOBILE: return Email.Type.MOBILE;
case WORK: return Email.Type.WORK;
default: return Email.Type.CUSTOM;
}
}
private static PostalAddress.Type remoteToLocalType(SharedContact.PostalAddress.Type type) {
switch (type) {
case HOME: return PostalAddress.Type.HOME;
case WORK: return PostalAddress.Type.WORK;
default: return PostalAddress.Type.CUSTOM;
}
}
private static SharedContact.Phone.Type localToRemoteType(Phone.Type type) {
switch (type) {
case HOME: return SharedContact.Phone.Type.HOME;
case MOBILE: return SharedContact.Phone.Type.MOBILE;
case WORK: return SharedContact.Phone.Type.WORK;
default: return SharedContact.Phone.Type.CUSTOM;
}
}
private static SharedContact.Email.Type localToRemoteType(Email.Type type) {
switch (type) {
case HOME: return SharedContact.Email.Type.HOME;
case MOBILE: return SharedContact.Email.Type.MOBILE;
case WORK: return SharedContact.Email.Type.WORK;
default: return SharedContact.Email.Type.CUSTOM;
}
}
private static SharedContact.PostalAddress.Type localToRemoteType(PostalAddress.Type type) {
switch (type) {
case HOME: return SharedContact.PostalAddress.Type.HOME;
case WORK: return SharedContact.PostalAddress.Type.WORK;
default: return SharedContact.PostalAddress.Type.CUSTOM;
}
}
}

View File

@@ -0,0 +1,184 @@
package org.thoughtcrime.securesms.conversation
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewConversationActionBarBinding
import network.loki.messenger.databinding.ViewConversationSettingBinding
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.utilities.ExpirationUtil
import org.session.libsession.utilities.modifyLayoutParams
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.LokiAPIDatabase
import org.thoughtcrime.securesms.util.DateUtils
import java.util.Locale
import javax.inject.Inject
@AndroidEntryPoint
class ConversationActionBarView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
private val binding = ViewConversationActionBarBinding.inflate(LayoutInflater.from(context), this, true)
@Inject lateinit var lokiApiDb: LokiAPIDatabase
@Inject lateinit var groupDb: GroupDatabase
var delegate: ConversationActionBarDelegate? = null
private val settingsAdapter = ConversationSettingsAdapter { setting ->
if (setting.settingType == ConversationSettingType.EXPIRATION) {
delegate?.onDisappearingMessagesClicked()
}
}
init {
var previousState: Int
var currentState = 0
binding.settingsPager.registerOnPageChangeCallback(object : OnPageChangeCallback() {
override fun onPageScrollStateChanged(state: Int) {
val currentPage: Int = binding.settingsPager.currentItem
val lastPage = maxOf( (binding.settingsPager.adapter?.itemCount ?: 0) - 1, 0)
if (currentPage == lastPage || currentPage == 0) {
previousState = currentState
currentState = state
if (previousState == 1 && currentState == 0) {
binding.settingsPager.setCurrentItem(if (currentPage == 0) lastPage else 0, true)
}
}
}
})
binding.settingsPager.adapter = settingsAdapter
TabLayoutMediator(binding.settingsTabLayout, binding.settingsPager) { _, _ -> }.attach()
}
fun bind(
delegate: ConversationActionBarDelegate,
threadId: Long,
recipient: Recipient,
config: ExpirationConfiguration? = null,
openGroup: OpenGroup? = null
) {
this.delegate = delegate
binding.profilePictureView.layoutParams = resources.getDimensionPixelSize(
if (recipient.isClosedGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size
).let { LayoutParams(it, it) }
MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(threadId, context)
update(recipient, openGroup, config)
}
fun update(recipient: Recipient, openGroup: OpenGroup? = null, config: ExpirationConfiguration? = null) {
binding.profilePictureView.update(recipient)
binding.conversationTitleView.text = recipient.takeUnless { it.isLocalNumber }?.toShortString() ?: context.getString(R.string.note_to_self)
updateSubtitle(recipient, openGroup, config)
binding.conversationTitleContainer.modifyLayoutParams<MarginLayoutParams> {
marginEnd = if (recipient.showCallMenu()) 0 else binding.profilePictureView.width
}
}
fun updateSubtitle(recipient: Recipient, openGroup: OpenGroup? = null, config: ExpirationConfiguration? = null) {
val settings = mutableListOf<ConversationSetting>()
if (config?.isEnabled == true) {
val prefix = when (config.expiryMode) {
is ExpiryMode.AfterRead -> R.string.expiration_type_disappear_after_read
else -> R.string.expiration_type_disappear_after_send
}.let(context::getString)
settings += ConversationSetting(
"$prefix - ${ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, config.expiryMode.expirySeconds)}",
ConversationSettingType.EXPIRATION,
R.drawable.ic_timer,
resources.getString(R.string.AccessibilityId_disappearing_messages_type_and_time)
)
}
if (recipient.isMuted) {
settings += ConversationSetting(
recipient.mutedUntil.takeUnless { it == Long.MAX_VALUE }
?.let { context.getString(R.string.ConversationActivity_muted_until_date, DateUtils.getFormattedDateTime(it, "EEE, MMM d, yyyy HH:mm", Locale.getDefault())) }
?: context.getString(R.string.ConversationActivity_muted_forever),
ConversationSettingType.NOTIFICATION,
R.drawable.ic_outline_notifications_off_24
)
}
if (recipient.isGroupRecipient) {
val title = if (recipient.isCommunityRecipient) {
val userCount = openGroup?.let { lokiApiDb.getUserCount(it.room, it.server) } ?: 0
context.getString(R.string.ConversationActivity_active_member_count, userCount)
} else {
val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size
context.getString(R.string.ConversationActivity_member_count, userCount)
}
settings += ConversationSetting(title, ConversationSettingType.MEMBER_COUNT)
}
settingsAdapter.submitList(settings)
binding.settingsTabLayout.isVisible = settings.size > 1
}
class ConversationSettingsAdapter(
private val settingsListener: (ConversationSetting) -> Unit
) : ListAdapter<ConversationSetting, ConversationSettingsAdapter.SettingViewHolder>(SettingsDiffer()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
return SettingViewHolder(ViewConversationSettingBinding.inflate(layoutInflater, parent, false))
}
override fun onBindViewHolder(holder: SettingViewHolder, position: Int) {
holder.bind(getItem(position), itemCount) {
settingsListener.invoke(it)
}
}
class SettingViewHolder(
private val binding: ViewConversationSettingBinding
): RecyclerView.ViewHolder(binding.root) {
fun bind(setting: ConversationSetting, itemCount: Int, listener: (ConversationSetting) -> Unit) {
binding.root.setOnClickListener { listener.invoke(setting) }
binding.root.contentDescription = setting.contentDescription
binding.iconImageView.setImageResource(setting.iconResId)
binding.iconImageView.isVisible = setting.iconResId > 0
binding.titleView.text = setting.title
binding.leftArrowImageView.isVisible = itemCount > 1
binding.rightArrowImageView.isVisible = itemCount > 1
}
}
class SettingsDiffer: DiffUtil.ItemCallback<ConversationSetting>() {
override fun areItemsTheSame(oldItem: ConversationSetting, newItem: ConversationSetting): Boolean = oldItem.settingType === newItem.settingType
override fun areContentsTheSame(oldItem: ConversationSetting, newItem: ConversationSetting): Boolean = oldItem == newItem
}
}
}
fun interface ConversationActionBarDelegate {
fun onDisappearingMessagesClicked()
}
data class ConversationSetting(
val title: String,
val settingType: ConversationSettingType,
val iconResId: Int = 0,
val contentDescription: String = ""
)
enum class ConversationSettingType {
EXPIRATION,
MEMBER_COUNT,
NOTIFICATION
}

View File

@@ -0,0 +1,72 @@
package org.thoughtcrime.securesms.conversation.disappearingmessages
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.ExpirationUtil
import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.getExpirationTypeDisplayValue
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
class DisappearingMessages @Inject constructor(
@ApplicationContext private val context: Context,
private val textSecurePreferences: TextSecurePreferences,
private val messageExpirationManager: MessageExpirationManagerProtocol,
) {
fun set(threadId: Long, address: Address, mode: ExpiryMode, isGroup: Boolean) {
val expiryChangeTimestampMs = SnodeAPI.nowWithOffset
MessagingModuleConfiguration.shared.storage.setExpirationConfiguration(ExpirationConfiguration(threadId, mode, expiryChangeTimestampMs))
val message = ExpirationTimerUpdate(isGroup = isGroup).apply {
expiryMode = mode
sender = textSecurePreferences.getLocalNumber()
isSenderSelf = true
recipient = address.serialize()
sentTimestamp = expiryChangeTimestampMs
}
messageExpirationManager.insertExpirationTimerMessage(message)
MessageSender.send(message, address)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
fun showFollowSettingDialog(context: Context, message: MessageRecord) = context.showSessionDialog {
title(R.string.dialog_disappearing_messages_follow_setting_title)
text(if (message.expiresIn == 0L) {
context.getString(R.string.dialog_disappearing_messages_follow_setting_off_body)
} else {
context.getString(
R.string.dialog_disappearing_messages_follow_setting_on_body,
ExpirationUtil.getExpirationDisplayValue(
context,
message.expiresIn.milliseconds
),
context.getExpirationTypeDisplayValue(message.isNotDisappearAfterRead)
)
})
destructiveButton(
text = if (message.expiresIn == 0L) R.string.dialog_disappearing_messages_follow_setting_confirm else R.string.dialog_disappearing_messages_follow_setting_set,
contentDescription = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_set_button
) {
set(message.threadId, message.recipient.address, message.expiryMode, message.recipient.isClosedGroupRecipient)
}
cancelButton()
}
}
val MessageRecord.expiryMode get() = if (expiresIn <= 0) ExpiryMode.NONE
else if (expireStarted == timestamp) ExpiryMode.AfterSend(expiresIn / 1000)
else ExpiryMode.AfterRead(expiresIn / 1000)

View File

@@ -0,0 +1,94 @@
package org.thoughtcrime.securesms.conversation.disappearingmessages
import android.os.Bundle
import android.widget.Toast
import androidx.activity.viewModels
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityDisappearingMessagesBinding
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.DisappearingMessages
import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.UiState
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.ui.AppTheme
import javax.inject.Inject
@AndroidEntryPoint
class DisappearingMessagesActivity: PassphraseRequiredActionBarActivity() {
private lateinit var binding : ActivityDisappearingMessagesBinding
@Inject lateinit var recipientDb: RecipientDatabase
@Inject lateinit var threadDb: ThreadDatabase
@Inject lateinit var viewModelFactory: DisappearingMessagesViewModel.AssistedFactory
private val threadId: Long by lazy {
intent.getLongExtra(THREAD_ID, -1)
}
private val viewModel: DisappearingMessagesViewModel by viewModels {
viewModelFactory.create(threadId)
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
binding = ActivityDisappearingMessagesBinding.inflate(layoutInflater)
setContentView(binding.root)
setUpToolbar()
binding.container.setContent { DisappearingMessagesScreen() }
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.event.collect {
when (it) {
Event.SUCCESS -> finish()
Event.FAIL -> showToast(getString(R.string.DisappearingMessagesActivity_settings_not_updated))
}
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect {
supportActionBar?.subtitle = it.subtitle(this@DisappearingMessagesActivity)
}
}
}
}
private fun showToast(message: String) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
private fun setUpToolbar() {
setSupportActionBar(binding.toolbar)
supportActionBar?.apply {
title = getString(R.string.activity_disappearing_messages_title)
setDisplayHomeAsUpEnabled(true)
setHomeButtonEnabled(true)
}
}
companion object {
const val THREAD_ID = "thread_id"
}
@Composable
fun DisappearingMessagesScreen() {
val uiState by viewModel.uiState.collectAsState(UiState())
AppTheme {
DisappearingMessages(uiState, callbacks = viewModel)
}
}
}

View File

@@ -0,0 +1,129 @@
package org.thoughtcrime.securesms.conversation.disappearingmessages
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import network.loki.messenger.BuildConfig
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.ExpiryCallbacks
import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.UiState
import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.toUiState
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase
class DisappearingMessagesViewModel(
private val threadId: Long,
private val application: Application,
private val textSecurePreferences: TextSecurePreferences,
private val messageExpirationManager: MessageExpirationManagerProtocol,
private val disappearingMessages: DisappearingMessages,
private val threadDb: ThreadDatabase,
private val groupDb: GroupDatabase,
private val storage: Storage,
isNewConfigEnabled: Boolean,
showDebugOptions: Boolean
) : AndroidViewModel(application), ExpiryCallbacks {
private val _event = Channel<Event>()
val event = _event.receiveAsFlow()
private val _state = MutableStateFlow(
State(
isNewConfigEnabled = isNewConfigEnabled,
showDebugOptions = showDebugOptions
)
)
val state = _state.asStateFlow()
val uiState = _state
.map(State::toUiState)
.stateIn(viewModelScope, SharingStarted.Eagerly, UiState())
init {
viewModelScope.launch {
val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode?.maybeConvertToLegacy(isNewConfigEnabled) ?: ExpiryMode.NONE
val recipient = threadDb.getRecipientForThreadId(threadId)
val groupRecord = recipient?.takeIf { it.isClosedGroupRecipient }
?.run { groupDb.getGroup(address.toGroupString()).orNull() }
_state.update {
it.copy(
address = recipient?.address,
isGroup = groupRecord != null,
isNoteToSelf = recipient?.address?.serialize() == textSecurePreferences.getLocalNumber(),
isSelfAdmin = groupRecord == null || groupRecord.admins.any{ it.serialize() == textSecurePreferences.getLocalNumber() },
expiryMode = expiryMode,
persistedMode = expiryMode
)
}
}
}
override fun setValue(value: ExpiryMode) = _state.update { it.copy(expiryMode = value) }
override fun onSetClick() = viewModelScope.launch {
val state = _state.value
val mode = state.expiryMode?.coerceLegacyToAfterSend()
val address = state.address
if (address == null || mode == null) {
_event.send(Event.FAIL)
return@launch
}
disappearingMessages.set(threadId, address, mode, state.isGroup)
_event.send(Event.SUCCESS)
}
private fun ExpiryMode.coerceLegacyToAfterSend() = takeUnless { it is ExpiryMode.Legacy } ?: ExpiryMode.AfterSend(expirySeconds)
@dagger.assisted.AssistedFactory
interface AssistedFactory {
fun create(threadId: Long): Factory
}
@Suppress("UNCHECKED_CAST")
class Factory @AssistedInject constructor(
@Assisted private val threadId: Long,
private val application: Application,
private val textSecurePreferences: TextSecurePreferences,
private val messageExpirationManager: MessageExpirationManagerProtocol,
private val disappearingMessages: DisappearingMessages,
private val threadDb: ThreadDatabase,
private val groupDb: GroupDatabase,
private val storage: Storage
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T = DisappearingMessagesViewModel(
threadId,
application,
textSecurePreferences,
messageExpirationManager,
disappearingMessages,
threadDb,
groupDb,
storage,
ExpirationConfiguration.isNewConfigEnabled,
BuildConfig.DEBUG
) as T
}
}
private fun ExpiryMode.maybeConvertToLegacy(isNewConfigEnabled: Boolean): ExpiryMode = takeIf { isNewConfigEnabled } ?: ExpiryMode.Legacy(expirySeconds)

View File

@@ -0,0 +1,90 @@
package org.thoughtcrime.securesms.conversation.disappearingmessages
import androidx.annotation.StringRes
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.session.libsession.utilities.Address
import org.thoughtcrime.securesms.ui.GetString
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
enum class Event {
SUCCESS, FAIL
}
data class State(
val isGroup: Boolean = false,
val isSelfAdmin: Boolean = true,
val address: Address? = null,
val isNoteToSelf: Boolean = false,
val expiryMode: ExpiryMode? = null,
val isNewConfigEnabled: Boolean = true,
val persistedMode: ExpiryMode? = null,
val showDebugOptions: Boolean = false
) {
val subtitle get() = when {
isGroup || isNoteToSelf -> GetString(R.string.activity_disappearing_messages_subtitle_sent)
else -> GetString(R.string.activity_disappearing_messages_subtitle)
}
val typeOptionsHidden get() = isNoteToSelf || (isGroup && isNewConfigEnabled)
val nextType get() = when {
expiryType == ExpiryType.AFTER_READ -> ExpiryType.AFTER_READ
isNewConfigEnabled -> ExpiryType.AFTER_SEND
else -> ExpiryType.LEGACY
}
val duration get() = expiryMode?.duration
val expiryType get() = expiryMode?.type
val isTimeOptionsEnabled = isNoteToSelf || isSelfAdmin && (isNewConfigEnabled || expiryType == ExpiryType.LEGACY)
}
enum class ExpiryType(
private val createMode: (Long) -> ExpiryMode,
@StringRes val title: Int,
@StringRes val subtitle: Int? = null,
@StringRes val contentDescription: Int = title,
) {
NONE(
{ ExpiryMode.NONE },
R.string.expiration_off,
contentDescription = R.string.AccessibilityId_disable_disappearing_messages,
),
LEGACY(
ExpiryMode::Legacy,
R.string.expiration_type_disappear_legacy,
contentDescription = R.string.expiration_type_disappear_legacy_description
),
AFTER_READ(
ExpiryMode::AfterRead,
R.string.expiration_type_disappear_after_read,
R.string.expiration_type_disappear_after_read_description,
R.string.AccessibilityId_disappear_after_read_option
),
AFTER_SEND(
ExpiryMode::AfterSend,
R.string.expiration_type_disappear_after_send,
R.string.expiration_type_disappear_after_send_description,
R.string.AccessibilityId_disappear_after_send_option
);
fun mode(seconds: Long) = if (seconds != 0L) createMode(seconds) else ExpiryMode.NONE
fun mode(duration: Duration) = mode(duration.inWholeSeconds)
fun defaultMode(persistedMode: ExpiryMode?) = when(this) {
persistedMode?.type -> persistedMode
AFTER_READ -> mode(12.hours)
else -> mode(1.days)
}
}
val ExpiryMode.type: ExpiryType get() = when(this) {
is ExpiryMode.Legacy -> ExpiryType.LEGACY
is ExpiryMode.AfterSend -> ExpiryType.AFTER_SEND
is ExpiryMode.AfterRead -> ExpiryType.AFTER_READ
else -> ExpiryType.NONE
}

View File

@@ -0,0 +1,98 @@
package org.thoughtcrime.securesms.conversation.disappearingmessages.ui
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType
import org.thoughtcrime.securesms.conversation.disappearingmessages.State
import org.thoughtcrime.securesms.ui.GetString
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
fun State.toUiState() = UiState(
cards = listOfNotNull(
typeOptions()?.let { ExpiryOptionsCard(GetString(R.string.activity_disappearing_messages_delete_type), it) },
timeOptions()?.let { ExpiryOptionsCard(GetString(R.string.activity_disappearing_messages_timer), it) }
),
showGroupFooter = isGroup && isNewConfigEnabled,
showSetButton = isSelfAdmin
)
private fun State.typeOptions(): List<ExpiryRadioOption>? = if (typeOptionsHidden) null else {
buildList {
add(offTypeOption())
if (!isNewConfigEnabled) add(legacyTypeOption())
if (!isGroup) add(afterReadTypeOption())
add(afterSendTypeOption())
}
}
private fun State.timeOptions(): List<ExpiryRadioOption>? {
// Don't show times card if we have a types card, and type is off.
if (!typeOptionsHidden && expiryType == ExpiryType.NONE) return null
return nextType.let { type ->
when (type) {
ExpiryType.AFTER_READ -> afterReadTimes
else -> afterSendTimes
}.map { timeOption(type, it) }
}.let {
buildList {
if (typeOptionsHidden) add(offTypeOption())
addAll(debugOptions())
addAll(it)
}
}
}
private fun State.offTypeOption() = typeOption(ExpiryType.NONE)
private fun State.legacyTypeOption() = typeOption(ExpiryType.LEGACY)
private fun State.afterReadTypeOption() = newTypeOption(ExpiryType.AFTER_READ)
private fun State.afterSendTypeOption() = newTypeOption(ExpiryType.AFTER_SEND)
private fun State.newTypeOption(type: ExpiryType) = typeOption(type, isNewConfigEnabled && isSelfAdmin)
private fun State.typeOption(
type: ExpiryType,
enabled: Boolean = isSelfAdmin,
) = ExpiryRadioOption(
value = type.defaultMode(persistedMode),
title = GetString(type.title),
subtitle = type.subtitle?.let(::GetString),
contentDescription = GetString(type.contentDescription),
selected = expiryType == type,
enabled = enabled
)
private fun debugTimes(isDebug: Boolean) = if (isDebug) listOf(10.seconds, 30.seconds, 1.minutes) else emptyList()
private fun debugModes(isDebug: Boolean, type: ExpiryType) =
debugTimes(isDebug).map { type.mode(it.inWholeSeconds) }
private fun State.debugOptions(): List<ExpiryRadioOption> =
debugModes(showDebugOptions, nextType).map { timeOption(it, subtitle = GetString("for testing purposes")) }
private val afterSendTimes = listOf(12.hours, 1.days, 7.days, 14.days)
private val afterReadTimes = buildList {
add(5.minutes)
add(1.hours)
addAll(afterSendTimes)
}
private fun State.timeOption(
type: ExpiryType,
time: Duration
) = timeOption(type.mode(time))
private fun State.timeOption(
mode: ExpiryMode,
title: GetString = GetString(mode.duration),
subtitle: GetString? = null,
) = ExpiryRadioOption(
value = mode,
title = title,
subtitle = subtitle,
contentDescription = title,
selected = mode.duration == expiryMode?.duration,
enabled = isTimeOptionsEnabled
)

View File

@@ -0,0 +1,75 @@
package org.thoughtcrime.securesms.conversation.disappearingmessages.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.thoughtcrime.securesms.ui.Callbacks
import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.NoOpCallbacks
import org.thoughtcrime.securesms.ui.OptionsCard
import org.thoughtcrime.securesms.ui.OutlineButton
import org.thoughtcrime.securesms.ui.RadioOption
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.fadingEdges
typealias ExpiryCallbacks = Callbacks<ExpiryMode>
typealias ExpiryRadioOption = RadioOption<ExpiryMode>
@Composable
fun DisappearingMessages(
state: UiState,
modifier: Modifier = Modifier,
callbacks: ExpiryCallbacks = NoOpCallbacks
) {
val scrollState = rememberScrollState()
Column(modifier = modifier.padding(horizontal = 32.dp)) {
Box(modifier = Modifier.weight(1f)) {
Column(
modifier = Modifier
.padding(bottom = 20.dp)
.verticalScroll(scrollState)
.fadingEdges(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
state.cards.forEach {
OptionsCard(it, callbacks)
}
if (state.showGroupFooter) Text(text = stringResource(R.string.activity_disappearing_messages_group_footer),
style = TextStyle(
fontSize = 11.sp,
fontWeight = FontWeight(400),
color = Color(0xFFA1A2A1),
textAlign = TextAlign.Center),
modifier = Modifier.fillMaxWidth())
}
}
if (state.showSetButton) OutlineButton(
GetString(R.string.disappearing_messages_set_button_title),
modifier = Modifier
.contentDescription(GetString(R.string.AccessibilityId_set_button))
.align(Alignment.CenterHorizontally)
.padding(bottom = 20.dp),
onClick = callbacks::onSetClick
)
}
}

View File

@@ -0,0 +1,62 @@
package org.thoughtcrime.securesms.conversation.disappearingmessages.ui
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType
import org.thoughtcrime.securesms.conversation.disappearingmessages.State
import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider
@Preview(widthDp = 450, heightDp = 700)
@Composable
fun PreviewStates(
@PreviewParameter(StatePreviewParameterProvider::class) state: State
) {
PreviewTheme(R.style.Classic_Dark) {
DisappearingMessages(
state.toUiState()
)
}
}
class StatePreviewParameterProvider : PreviewParameterProvider<State> {
override val values = newConfigValues.filter { it.expiryType != ExpiryType.LEGACY } + newConfigValues.map { it.copy(isNewConfigEnabled = false) }
private val newConfigValues get() = sequenceOf(
// new 1-1
State(expiryMode = ExpiryMode.NONE),
State(expiryMode = ExpiryMode.Legacy(43200)),
State(expiryMode = ExpiryMode.AfterRead(300)),
State(expiryMode = ExpiryMode.AfterSend(43200)),
// new group non-admin
State(isGroup = true, isSelfAdmin = false),
State(isGroup = true, isSelfAdmin = false, expiryMode = ExpiryMode.Legacy(43200)),
State(isGroup = true, isSelfAdmin = false, expiryMode = ExpiryMode.AfterSend(43200)),
// new group admin
State(isGroup = true),
State(isGroup = true, expiryMode = ExpiryMode.Legacy(43200)),
State(isGroup = true, expiryMode = ExpiryMode.AfterSend(43200)),
// new note-to-self
State(isNoteToSelf = true),
)
}
@Preview
@Composable
fun PreviewThemes(
@PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int
) {
PreviewTheme(themeResId) {
DisappearingMessages(
State(expiryMode = ExpiryMode.AfterSend(43200)).toUiState(),
modifier = Modifier.size(400.dp, 600.dp)
)
}
}

View File

@@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.conversation.disappearingmessages.ui
import androidx.annotation.StringRes
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.RadioOption
typealias ExpiryOptionsCard = OptionsCard<ExpiryMode>
data class UiState(
val cards: List<ExpiryOptionsCard> = emptyList(),
val showGroupFooter: Boolean = false,
val showSetButton: Boolean = true
) {
constructor(
vararg cards: ExpiryOptionsCard,
showGroupFooter: Boolean = false,
showSetButton: Boolean = true,
): this(
cards.asList(),
showGroupFooter,
showSetButton
)
}
data class OptionsCard<T>(
val title: GetString,
val options: List<RadioOption<T>>
) {
constructor(title: GetString, vararg options: RadioOption<T>): this(title, options.asList())
constructor(@StringRes title: Int, vararg options: RadioOption<T>): this(GetString(title), options.asList())
}

View File

@@ -35,11 +35,28 @@ class ContactListAdapter(
binding.profilePictureView.update(contact.recipient) binding.profilePictureView.update(contact.recipient)
binding.nameTextView.text = contact.displayName binding.nameTextView.text = contact.displayName
binding.root.setOnClickListener { listener(contact.recipient) } binding.root.setOnClickListener { listener(contact.recipient) }
// TODO: When we implement deleting contacts (hide might be safest for now) then probably set a long-click listener here w/ something like:
/*
binding.root.setOnLongClickListener {
Log.w("[ACL]", "Long clicked on contact ${contact.recipient.name}")
binding.contentView.context.showSessionDialog {
title("Delete Contact")
text("Are you sure you want to delete this contact?")
button(R.string.delete) {
val contacts = configFactory.contacts ?: return
contacts.upsertContact(contact.recipient.address.serialize()) { priority = PRIORITY_HIDDEN }
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
endActionMode()
}
cancelButton(::endActionMode)
}
true
}
*/
} }
fun unbind() { fun unbind() { binding.profilePictureView.recycle() }
binding.profilePictureView.recycle()
}
} }
class HeaderViewHolder( class HeaderViewHolder(
@@ -52,15 +69,11 @@ class ContactListAdapter(
} }
} }
override fun getItemCount(): Int { override fun getItemCount(): Int { return items.size }
return items.size
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) { override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
super.onViewRecycled(holder) super.onViewRecycled(holder)
if (holder is ContactViewHolder) { if (holder is ContactViewHolder) { holder.unbind() }
holder.unbind()
}
} }
override fun getItemViewType(position: Int): Int { override fun getItemViewType(position: Int): Int {
@@ -72,13 +85,9 @@ class ContactListAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return if (viewType == ViewType.Contact) { return if (viewType == ViewType.Contact) {
ContactViewHolder( ContactViewHolder(ViewContactBinding.inflate(LayoutInflater.from(context), parent, false))
ViewContactBinding.inflate(LayoutInflater.from(context), parent, false)
)
} else { } else {
HeaderViewHolder( HeaderViewHolder(ContactSectionHeaderBinding.inflate(LayoutInflater.from(context), parent, false))
ContactSectionHeaderBinding.inflate(LayoutInflater.from(context), parent, false)
)
} }
} }

View File

@@ -30,23 +30,22 @@ import android.view.MenuItem
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.RelativeLayout import android.widget.RelativeLayout
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.DimenRes
import androidx.core.text.set import androidx.core.text.set
import androidx.core.text.toSpannable import androidx.core.text.toSpannable
import androidx.core.view.drawToBitmap import androidx.core.view.drawToBitmap
import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.loader.app.LoaderManager import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -57,13 +56,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityConversationV2Binding import network.loki.messenger.databinding.ActivityConversationV2Binding
import network.loki.messenger.databinding.ViewVisibleMessageBinding import network.loki.messenger.libsession_util.util.ExpiryMode
import nl.komponents.kovenant.ui.successUi import nl.komponents.kovenant.ui.successUi
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
@@ -71,14 +69,14 @@ import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.mentions.Mention import org.session.libsession.messaging.mentions.Mention
import org.session.libsession.messaging.mentions.MentionsManager import org.session.libsession.messaging.mentions.MentionsManager
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.applyExpiryMode
import org.session.libsession.messaging.messages.control.DataExtractionNotification import org.session.libsession.messaging.messages.control.DataExtractionNotification
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
import org.session.libsession.messaging.messages.visible.Reaction import org.session.libsession.messaging.messages.visible.Reaction
import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability
import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.attachments.Attachment 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.link_preview.LinkPreview
@@ -104,14 +102,16 @@ import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.attachments.ScreenshotObserver import org.thoughtcrime.securesms.attachments.ScreenshotObserver
import org.thoughtcrime.securesms.audio.AudioRecorder import org.thoughtcrime.securesms.audio.AudioRecorder
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_DELETE
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_REPLY import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_REPLY
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_RESEND import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_RESEND
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_DELETE
import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog
@@ -127,19 +127,16 @@ import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDel
import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar
import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel
import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities
import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.crypto.MnemonicUtilities import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.LokiAPIDatabase
import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase
import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase
import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.ReactionDatabase import org.thoughtcrime.securesms.database.ReactionDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
@@ -167,15 +164,17 @@ import org.thoughtcrime.securesms.mms.VideoSlide
import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment
import org.thoughtcrime.securesms.showExpirationDialog
import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.ActivityDispatcher import org.thoughtcrime.securesms.util.ActivityDispatcher
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.SaveAttachmentTask import org.thoughtcrime.securesms.util.SaveAttachmentTask
import org.thoughtcrime.securesms.util.SimpleTextWatcher
import org.thoughtcrime.securesms.util.isScrolledToBottom import org.thoughtcrime.securesms.util.isScrolledToBottom
import org.thoughtcrime.securesms.util.isScrolledToWithin30dpOfBottom
import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show
import org.thoughtcrime.securesms.util.toPx import org.thoughtcrime.securesms.util.toPx
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.Locale import java.util.Locale
@@ -189,6 +188,8 @@ import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.sqrt import kotlin.math.sqrt
private const val TAG = "ConversationActivityV2"
// Some things that seemingly belong to the input bar (e.g. the voice message recording UI) are actually // 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 // 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. // price we pay is a bit of back and forth between the input bar and the conversation activity.
@@ -196,7 +197,7 @@ import kotlin.math.sqrt
class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate, class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate,
InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher, InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher,
ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener, ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener,
SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>, SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>, ConversationActionBarDelegate,
OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback, OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback,
ConversationMenuHelper.ConversationMenuListener { ConversationMenuHelper.ConversationMenuListener {
@@ -208,8 +209,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
@Inject lateinit var lokiThreadDb: LokiThreadDatabase @Inject lateinit var lokiThreadDb: LokiThreadDatabase
@Inject lateinit var sessionContactDb: SessionContactDatabase @Inject lateinit var sessionContactDb: SessionContactDatabase
@Inject lateinit var groupDb: GroupDatabase @Inject lateinit var groupDb: GroupDatabase
@Inject lateinit var recipientDb: RecipientDatabase
@Inject lateinit var lokiApiDb: LokiAPIDatabase
@Inject lateinit var smsDb: SmsDatabase @Inject lateinit var smsDb: SmsDatabase
@Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var mmsDb: MmsDatabase
@Inject lateinit var lokiMessageDb: LokiMessageDatabase @Inject lateinit var lokiMessageDb: LokiMessageDatabase
@@ -240,11 +239,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val address = if (sessionId.prefix == IdPrefix.BLINDED && openGroup != null) { val address = if (sessionId.prefix == IdPrefix.BLINDED && openGroup != null) {
storage.getOrCreateBlindedIdMapping(sessionId.hexString, openGroup.server, openGroup.publicKey).sessionId?.let { storage.getOrCreateBlindedIdMapping(sessionId.hexString, openGroup.server, openGroup.publicKey).sessionId?.let {
fromSerialized(it) fromSerialized(it)
} ?: run { } ?: GroupUtil.getEncodedOpenGroupInboxID(openGroup, sessionId)
val openGroupInboxId =
"${openGroup.server}!${openGroup.publicKey}!${sessionId.hexString}".toByteArray()
fromSerialized(GroupUtil.getEncodedOpenGroupInboxID(openGroupInboxId))
}
} else { } else {
it it
} }
@@ -253,10 +248,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} ?: finish() } ?: finish()
} }
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair(), contentResolver) viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
} }
private var actionMode: ActionMode? = null private var actionMode: ActionMode? = null
private var unreadCount = 0 private var unreadCount = Int.MAX_VALUE
// Attachments // Attachments
private val audioRecorder = AudioRecorder(this) private val audioRecorder = AudioRecorder(this)
private val stopAudioHandler = Handler(Looper.getMainLooper()) private val stopAudioHandler = Handler(Looper.getMainLooper())
@@ -280,6 +275,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private val isScrolledToBottom: Boolean private val isScrolledToBottom: Boolean
get() = binding?.conversationRecyclerView?.isScrolledToBottom ?: true get() = binding?.conversationRecyclerView?.isScrolledToBottom ?: true
private val isScrolledToWithin30dpOfBottom: Boolean
get() = binding?.conversationRecyclerView?.isScrolledToWithin30dpOfBottom ?: true
private val layoutManager: LinearLayoutManager? private val layoutManager: LinearLayoutManager?
get() { return binding?.conversationRecyclerView?.layoutManager as LinearLayoutManager? } get() { return binding?.conversationRecyclerView?.layoutManager as LinearLayoutManager? }
@@ -288,8 +286,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (hexEncodedSeed == null) { if (hexEncodedSeed == null) {
hexEncodedSeed = IdentityKeyUtil.getIdentityKeyPair(this).hexEncodedPrivateKey // Legacy account hexEncodedSeed = IdentityKeyUtil.getIdentityKeyPair(this).hexEncodedPrivateKey // Legacy account
} }
val appContext = applicationContext
val loadFileContents: (String) -> String = { fileName -> val loadFileContents: (String) -> String = { fileName ->
MnemonicUtilities.loadFileContents(this, fileName) MnemonicUtilities.loadFileContents(appContext, fileName)
} }
MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english) MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english)
} }
@@ -312,8 +312,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
handleSwipeToReply(message) handleSwipeToReply(message)
}, },
onItemLongPress = { message, position, view -> onItemLongPress = { message, position, view ->
if (!isMessageRequestThread() && if (!viewModel.isMessageRequestThread &&
(viewModel.openGroup == null || Capability.REACTIONS.name.lowercase() in viewModel.serverCapabilities) viewModel.canReactToMessages
) { ) {
showEmojiPicker(message, view) showEmojiPicker(message, view)
} else { } else {
@@ -326,7 +326,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
}, },
onAttachmentNeedsDownload = { attachmentId, mmsId -> onAttachmentNeedsDownload = { attachmentId, mmsId ->
// Start download (on IO thread)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId)) JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId))
} }
@@ -335,6 +334,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
lifecycleCoroutineScope = lifecycleScope lifecycleCoroutineScope = lifecycleScope
) )
adapter.visibleMessageViewDelegate = this 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 adapter
} }
@@ -351,6 +355,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private lateinit var reactionDelegate: ConversationReactionDelegate private lateinit var reactionDelegate: ConversationReactionDelegate
private val reactWithAnyEmojiStartPage = -1 private val reactWithAnyEmojiStartPage = -1
// Properties for what message indices are visible previously & now, as well as the scroll state
private var previousLastVisibleRecyclerViewIndex: Int = RecyclerView.NO_POSITION
private var currentLastVisibleRecyclerViewIndex: Int = RecyclerView.NO_POSITION
private var recyclerScrollState: Int = RecyclerView.SCROLL_STATE_IDLE
// region Settings // region Settings
companion object { companion object {
// Extras // Extras
@@ -365,7 +374,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
const val PICK_GIF = 10 const val PICK_GIF = 10
const val PICK_FROM_LIBRARY = 12 const val PICK_FROM_LIBRARY = 12
const val INVITE_CONTACTS = 124 const val INVITE_CONTACTS = 124
} }
// endregion // endregion
@@ -374,12 +382,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
super.onCreate(savedInstanceState, isReady) super.onCreate(savedInstanceState, isReady)
binding = ActivityConversationV2Binding.inflate(layoutInflater) binding = ActivityConversationV2Binding.inflate(layoutInflater)
setContentView(binding!!.root) setContentView(binding!!.root)
// messageIdToScroll // messageIdToScroll
messageToScrollTimestamp.set(intent.getLongExtra(SCROLL_MESSAGE_ID, -1)) messageToScrollTimestamp.set(intent.getLongExtra(SCROLL_MESSAGE_ID, -1))
messageToScrollAuthor.set(intent.getParcelableExtra(SCROLL_MESSAGE_AUTHOR)) messageToScrollAuthor.set(intent.getParcelableExtra(SCROLL_MESSAGE_AUTHOR))
val recipient = viewModel.recipient val recipient = viewModel.recipient
val openGroup = recipient.let { viewModel.openGroup } val openGroup = recipient.let { viewModel.openGroup }
if (recipient == null || (recipient.isOpenGroupRecipient && openGroup == null)) { if (recipient == null || (recipient.isCommunityRecipient && openGroup == null)) {
Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show() Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show()
return finish() return finish()
} }
@@ -389,6 +398,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpLinkPreviewObserver() setUpLinkPreviewObserver()
restoreDraftIfNeeded() restoreDraftIfNeeded()
setUpUiStateObserver() setUpUiStateObserver()
binding!!.scrollToBottomButton.setOnClickListener { binding!!.scrollToBottomButton.setOnClickListener {
val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener
val targetPosition = if (reverseMessageList) 0 else adapter.itemCount val targetPosition = if (reverseMessageList) 0 else adapter.itemCount
@@ -414,14 +424,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
updateUnreadCountIndicator() updateUnreadCountIndicator()
updateSubtitle()
updatePlaceholder() updatePlaceholder()
setUpBlockedBanner() setUpBlockedBanner()
binding!!.searchBottomBar.setEventListener(this) binding!!.searchBottomBar.setEventListener(this)
updateSendAfterApprovalText() updateSendAfterApprovalText()
showOrHideInputIfNeeded()
setUpMessageRequestsBar() setUpMessageRequestsBar()
// Note: Do not `showOrHideInputIfNeeded` here - we'll never start this activity w/ the
// keyboard visible and have no need to immediately display it.
val weakActivity = WeakReference(this) val weakActivity = WeakReference(this)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
@@ -442,6 +453,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpRecipientObserver() setUpRecipientObserver()
getLatestOpenGroupInfoIfNeeded() getLatestOpenGroupInfoIfNeeded()
setUpSearchResultObserver() setUpSearchResultObserver()
scrollToFirstUnreadMessageIfNeeded()
setUpOutdatedClientBanner()
if (author != null && messageTimestamp >= 0 && targetPosition >= 0) { if (author != null && messageTimestamp >= 0 && targetPosition >= 0) {
binding?.conversationRecyclerView?.scrollToPosition(targetPosition) binding?.conversationRecyclerView?.scrollToPosition(targetPosition)
@@ -457,18 +470,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
reactionDelegate = ConversationReactionDelegate(reactionOverlayStub) reactionDelegate = ConversationReactionDelegate(reactionOverlayStub)
reactionDelegate.setOnReactionSelectedListener(this) reactionDelegate.setOnReactionSelectedListener(this)
lifecycleScope.launch { lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
// only update the conversation every 3 seconds maximum // only update the conversation every 3 seconds maximum
// channel is rendezvous and shouldn't block on try send calls as often as we want // channel is rendezvous and shouldn't block on try send calls as often as we want
val bufferedFlow = bufferedLastSeenChannel.consumeAsFlow() bufferedLastSeenChannel.receiveAsFlow()
bufferedFlow.filter { .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
it > storage.getLastSeen(viewModel.threadId) .collectLatest {
}.collectLatest { latestMessageRead -> withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { try {
storage.markConversationAsRead(viewModel.threadId, latestMessageRead) if (it > storage.getLastSeen(viewModel.threadId)) {
storage.markConversationAsRead(viewModel.threadId, it)
}
} catch (e: Exception) {
Log.e(TAG, "bufferedLastSeenChannel collectLatest", e)
}
}
} }
}
}
} }
} }
@@ -481,6 +497,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
true, true,
screenshotObserver screenshotObserver
) )
viewModel.run {
binding?.toolbarContent?.update(recipient ?: return, openGroup, expirationConfiguration)
}
} }
override fun onPause() { override fun onPause() {
@@ -497,8 +516,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
override fun dispatchIntent(body: (Context) -> Intent?) { override fun dispatchIntent(body: (Context) -> Intent?) {
val intent = body(this) ?: return body(this)?.let { push(it, false) }
push(intent, false)
} }
override fun showDialog(dialogFragment: DialogFragment, tag: String?) { override fun showDialog(dialogFragment: DialogFragment, tag: String?) {
@@ -530,16 +548,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (author != null && messageTimestamp >= 0) { if (author != null && messageTimestamp >= 0) {
jumpToMessage(author, messageTimestamp, firstLoad.get(), null) jumpToMessage(author, messageTimestamp, firstLoad.get(), null)
} } else {
else if (firstLoad.getAndSet(false)) { if (firstLoad.getAndSet(false)) scrollToFirstUnreadMessageIfNeeded(true)
scrollToFirstUnreadMessageIfNeeded(true)
handleRecyclerViewScrolled()
}
else if (oldCount != newCount) {
handleRecyclerViewScrolled() handleRecyclerViewScrolled()
} }
} }
updatePlaceholder() updatePlaceholder()
viewModel.recipient?.let {
maybeUpdateToolbar(recipient = it)
setUpOutdatedClientBanner()
}
} }
override fun onLoaderReset(cursor: Loader<Cursor>) { override fun onLoaderReset(cursor: Loader<Cursor>) {
@@ -556,17 +574,46 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding!!.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { binding!!.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { 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() handleRecyclerViewScrolled()
} }
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
recyclerScrollState = newState
} }
}) })
}
binding!!.conversationRecyclerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> private fun scrollToMostRecentMessageIfWeShould() {
showScrollToBottomButtonIfApplicable() // Grab an initial 'previous' last visible message..
if (previousLastVisibleRecyclerViewIndex == RecyclerView.NO_POSITION) {
previousLastVisibleRecyclerViewIndex = layoutManager?.findLastVisibleItemPosition()!!
} }
// ..and grab the 'current' last visible message.
currentLastVisibleRecyclerViewIndex = layoutManager?.findLastVisibleItemPosition()!!
// If the current last visible message index is less than the previous one (i.e. we've
// lost visibility of one or more messages due to showing the IME keyboard) AND we're
// at the bottom of the message feed..
val atBottomAndTrueLastNoLongerVisible = currentLastVisibleRecyclerViewIndex!! <= previousLastVisibleRecyclerViewIndex!! && !binding?.scrollToBottomButton?.isVisible!!
// ..OR we're at the last message or have received a new message..
val atLastOrReceivedNewMessage = currentLastVisibleRecyclerViewIndex == (adapter.itemCount - 1)
// ..then scroll the recycler view to the last message on resize. Note: We cannot just call
// scroll/smoothScroll - we have to `post` it or nothing happens!
if (atBottomAndTrueLastNoLongerVisible || atLastOrReceivedNewMessage) {
binding?.conversationRecyclerView?.post {
binding?.conversationRecyclerView?.smoothScrollToPosition(adapter.itemCount)
}
}
// Update our previous last visible view index to the current one
previousLastVisibleRecyclerViewIndex = currentLastVisibleRecyclerViewIndex
} }
// called from onCreate // called from onCreate
@@ -578,44 +625,39 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
actionBar.title = "" actionBar.title = ""
actionBar.setDisplayHomeAsUpEnabled(true) actionBar.setDisplayHomeAsUpEnabled(true)
actionBar.setHomeButtonEnabled(true) actionBar.setHomeButtonEnabled(true)
binding.toolbarContent.conversationTitleView.text = when { binding!!.toolbarContent.bind(
recipient.isLocalNumber -> getString(R.string.note_to_self) this,
else -> recipient.toShortString() viewModel.threadId,
} recipient,
@DimenRes val sizeID: Int = if (viewModel.recipient?.isClosedGroupRecipient == true) { viewModel.expirationConfiguration,
R.dimen.medium_profile_picture_size viewModel.openGroup
} else { )
R.dimen.small_profile_picture_size maybeUpdateToolbar(recipient)
}
val size = resources.getDimension(sizeID).roundToInt()
binding.toolbarContent.profilePictureView.layoutParams = LinearLayout.LayoutParams(size, size)
MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(viewModel.threadId, this)
val profilePictureView = binding.toolbarContent.profilePictureView
viewModel.recipient?.let(profilePictureView::update)
} }
// called from onCreate // called from onCreate
private fun setUpInputBar() { private fun setUpInputBar() {
binding!!.inputBar.isVisible = viewModel.openGroup == null || viewModel.openGroup?.canWrite == true val binding = binding ?: return
binding!!.inputBar.delegate = this binding.inputBar.isGone = viewModel.hidesInputBar()
binding!!.inputBarRecordingView.delegate = this binding.inputBar.delegate = this
binding.inputBarRecordingView.delegate = this
// GIF button // GIF button
binding!!.gifButtonContainer.addView(gifButton) binding.gifButtonContainer.addView(gifButton)
gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
gifButton.onUp = { showGIFPicker() } gifButton.onUp = { showGIFPicker() }
gifButton.snIsEnabled = false gifButton.snIsEnabled = false
// Document button // Document button
binding!!.documentButtonContainer.addView(documentButton) binding.documentButtonContainer.addView(documentButton)
documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
documentButton.onUp = { showDocumentPicker() } documentButton.onUp = { showDocumentPicker() }
documentButton.snIsEnabled = false documentButton.snIsEnabled = false
// Library button // Library button
binding!!.libraryButtonContainer.addView(libraryButton) binding.libraryButtonContainer.addView(libraryButton)
libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
libraryButton.onUp = { pickFromLibrary() } libraryButton.onUp = { pickFromLibrary() }
libraryButton.snIsEnabled = false libraryButton.snIsEnabled = false
// Camera button // Camera button
binding!!.cameraButtonContainer.addView(cameraButton) binding.cameraButtonContainer.addView(cameraButton)
cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
cameraButton.onUp = { showCamera() } cameraButton.onUp = { showCamera() }
cameraButton.snIsEnabled = false cameraButton.snIsEnabled = false
@@ -682,23 +724,36 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
private fun getLatestOpenGroupInfoIfNeeded() { private fun getLatestOpenGroupInfoIfNeeded() {
viewModel.openGroup?.let { val openGroup = viewModel.openGroup ?: return
OpenGroupApi.getMemberCount(it.room, it.server).successUi { updateSubtitle() } OpenGroupApi.getMemberCount(openGroup.room, openGroup.server) successUi {
binding?.toolbarContent?.updateSubtitle(viewModel.recipient!!, openGroup, viewModel.expirationConfiguration)
maybeUpdateToolbar(viewModel.recipient!!)
} }
} }
// called from onCreate // called from onCreate
private fun setUpBlockedBanner() { private fun setUpBlockedBanner() {
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient?.takeUnless { it.isGroupRecipient } ?: return
if (recipient.isGroupRecipient) { return }
val sessionID = recipient.address.toString() val sessionID = recipient.address.toString()
val contact = sessionContactDb.getContactWithSessionID(sessionID) val name = sessionContactDb.getContactWithSessionID(sessionID)?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name)
binding?.blockedBanner?.isVisible = recipient.isBlocked binding?.blockedBanner?.isVisible = recipient.isBlocked
binding?.blockedBanner?.setOnClickListener { viewModel.unblock() } binding?.blockedBanner?.setOnClickListener { viewModel.unblock() }
} }
private fun setUpOutdatedClientBanner() {
val legacyRecipient = viewModel.legacyBannerRecipient(this)
val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled &&
legacyRecipient != null
binding?.outdatedBanner?.isVisible = shouldShowLegacy
if (shouldShowLegacy) {
binding?.outdatedBannerTextView?.text =
resources.getString(R.string.activity_conversation_outdated_client_banner_text, legacyRecipient!!.name)
}
}
private fun setUpLinkPreviewObserver() { private fun setUpLinkPreviewObserver() {
if (!textSecurePreferences.isLinkPreviewsEnabled()) { if (!textSecurePreferences.isLinkPreviewsEnabled()) {
linkPreviewViewModel.onUserCancel(); return linkPreviewViewModel.onUserCancel(); return
@@ -745,13 +800,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// of the first unread message in the middle of the screen // of the first unread message in the middle of the screen
if (isFirstLoad && !reverseMessageList) { if (isFirstLoad && !reverseMessageList) {
layoutManager?.scrollToPositionWithOffset(lastSeenItemPosition, ((layoutManager?.height ?: 0) / 2)) layoutManager?.scrollToPositionWithOffset(lastSeenItemPosition, ((layoutManager?.height ?: 0) / 2))
if (shouldHighlight) { highlightViewAtPosition(lastSeenItemPosition) } if (shouldHighlight) { highlightViewAtPosition(lastSeenItemPosition) }
return lastSeenItemPosition return lastSeenItemPosition
} }
if (lastSeenItemPosition <= 3) { return lastSeenItemPosition } if (lastSeenItemPosition <= 3) { return lastSeenItemPosition }
binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition)
return lastSeenItemPosition return lastSeenItemPosition
} }
@@ -764,24 +818,24 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun onPrepareOptionsMenu(menu: Menu): Boolean { override fun onPrepareOptionsMenu(menu: Menu): Boolean {
val recipient = viewModel.recipient ?: return false val recipient = viewModel.recipient ?: return false
if (!isMessageRequestThread()) { if (!viewModel.isMessageRequestThread) {
ConversationMenuHelper.onPrepareOptionsMenu( ConversationMenuHelper.onPrepareOptionsMenu(
menu, menu,
menuInflater, menuInflater,
recipient, recipient,
viewModel.threadId,
this this
) { onOptionsItemSelected(it) } )
} }
maybeUpdateToolbar(recipient)
return true return true
} }
override fun onDestroy() { override fun onDestroy() {
viewModel.saveDraft(binding?.inputBar?.text?.trim() ?: "") viewModel.saveDraft(binding?.inputBar?.text?.trim() ?: "")
cancelVoiceMessage()
tearDownRecipientObserver() tearDownRecipientObserver()
super.onDestroy() super.onDestroy()
binding = null binding = null
// actionBarBinding = null
} }
// endregion // endregion
@@ -796,31 +850,25 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
setUpMessageRequestsBar() setUpMessageRequestsBar()
invalidateOptionsMenu() invalidateOptionsMenu()
updateSubtitle()
updateSendAfterApprovalText() updateSendAfterApprovalText()
showOrHideInputIfNeeded() showOrHideInputIfNeeded()
binding?.toolbarContent?.profilePictureView?.update(threadRecipient) maybeUpdateToolbar(threadRecipient)
binding?.toolbarContent?.conversationTitleView?.text = when {
threadRecipient.isLocalNumber -> getString(R.string.note_to_self)
else -> threadRecipient.toShortString()
}
} }
} }
private fun maybeUpdateToolbar(recipient: Recipient) {
binding?.toolbarContent?.update(recipient, viewModel.openGroup, viewModel.expirationConfiguration)
}
private fun updateSendAfterApprovalText() { private fun updateSendAfterApprovalText() {
binding?.textSendAfterApproval?.isVisible = viewModel.showSendAfterApprovalText binding?.textSendAfterApproval?.isVisible = viewModel.showSendAfterApprovalText
} }
private fun showOrHideInputIfNeeded() { private fun showOrHideInputIfNeeded() {
val recipient = viewModel.recipient binding?.inputBar?.showInput = viewModel.recipient?.takeIf { it.isClosedGroupRecipient }
if (recipient != null && recipient.isClosedGroupRecipient) { ?.run { address.toGroupString().let(groupDb::getGroup).orNull()?.isActive == true }
val group = groupDb.getGroup(recipient.address.toGroupString()).orNull() ?: true
val isActive = (group?.isActive == true)
binding?.inputBar?.showInput = isActive
} else {
binding?.inputBar?.showInput = true
}
} }
private fun setUpMessageRequestsBar() { private fun setUpMessageRequestsBar() {
@@ -850,26 +898,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} }
private fun isMessageRequestThread(): Boolean { private fun isOutgoingMessageRequestThread(): Boolean = viewModel.recipient?.run {
val recipient = viewModel.recipient ?: return false !isGroupRecipient && !isLocalNumber &&
return !recipient.isGroupRecipient && !recipient.isApproved !(hasApprovedMe() || viewModel.hasReceived())
} } ?: false
private fun isOutgoingMessageRequestThread(): Boolean { private fun isIncomingMessageRequestThread(): Boolean = viewModel.recipient?.run {
val recipient = viewModel.recipient ?: return false !isGroupRecipient && !isApproved && !isLocalNumber &&
return !recipient.isGroupRecipient && !threadDb.getLastSeenAndHasSent(viewModel.threadId).second() && threadDb.getMessageCount(viewModel.threadId) > 0
!recipient.isLocalNumber && } ?: false
!(recipient.hasApprovedMe() || viewModel.hasReceived())
}
private fun isIncomingMessageRequestThread(): Boolean {
val recipient = viewModel.recipient ?: return false
return !recipient.isGroupRecipient &&
!recipient.isApproved &&
!recipient.isLocalNumber &&
!threadDb.getLastSeenAndHasSent(viewModel.threadId).second() &&
threadDb.getMessageCount(viewModel.threadId) > 0
}
override fun inputBarEditTextContentChanged(newContent: CharSequence) { override fun inputBarEditTextContentChanged(newContent: CharSequence) {
val inputBarText = binding?.inputBar?.text ?: return // TODO check if we should be referencing newContent here instead val inputBarText = binding?.inputBar?.text ?: return // TODO check if we should be referencing newContent here instead
@@ -934,11 +971,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
view.glide = glide view.glide = glide
view.onCandidateSelected = { handleMentionSelected(it) } view.onCandidateSelected = { handleMentionSelected(it) }
additionalContentContainer.addView(view) additionalContentContainer.addView(view)
val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isOpenGroupRecipient) val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient)
this.mentionCandidatesView = view this.mentionCandidatesView = view
view.show(candidates, viewModel.threadId) view.show(candidates, viewModel.threadId)
} else { } else {
val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isOpenGroupRecipient) val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient)
this.mentionCandidatesView!!.setMentionCandidates(candidates) this.mentionCandidatesView!!.setMentionCandidates(candidates)
} }
isShowingMentionCandidatesView = true isShowingMentionCandidatesView = true
@@ -984,7 +1021,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
override fun showVoiceMessageUI() { override fun showVoiceMessageUI() {
binding?.inputBarRecordingView?.show() binding?.inputBarRecordingView?.show(lifecycleScope)
binding?.inputBar?.alpha = 0.0f binding?.inputBar?.alpha = 0.0f
val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
animation.duration = 250L animation.duration = 250L
@@ -1043,22 +1080,26 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun handleRecyclerViewScrolled() { private fun handleRecyclerViewScrolled() {
val binding = binding ?: return val binding = binding ?: return
// Note: The typing indicate is whether the other person / other people are typing - it has
// nothing to do with the IME keyboard state.
val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible
binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom
showScrollToBottomButtonIfApplicable() showScrollToBottomButtonIfApplicable()
val maybeTargetVisiblePosition = if (reverseMessageList) layoutManager?.findFirstVisibleItemPosition() else layoutManager?.findLastVisibleItemPosition() val maybeTargetVisiblePosition = if (reverseMessageList) layoutManager?.findFirstVisibleItemPosition() else layoutManager?.findLastVisibleItemPosition()
val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView.NO_POSITION val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView.NO_POSITION
if (!firstLoad.get() && targetVisiblePosition != RecyclerView.NO_POSITION) { if (!firstLoad.get() && targetVisiblePosition != RecyclerView.NO_POSITION) {
val visibleItemTimestamp = adapter.getTimestampForItemAt(targetVisiblePosition) adapter.getTimestampForItemAt(targetVisiblePosition)?.let { visibleItemTimestamp ->
if (visibleItemTimestamp != null) { bufferedLastSeenChannel.trySend(visibleItemTimestamp).apply {
bufferedLastSeenChannel.trySend(visibleItemTimestamp) if (isFailure) Log.e(TAG, "trySend failed", exceptionOrNull())
}
} }
} }
if (reverseMessageList) { if (reverseMessageList) {
unreadCount = min(unreadCount, targetVisiblePosition).coerceAtLeast(0) unreadCount = min(unreadCount, targetVisiblePosition).coerceAtLeast(0)
} } else {
else {
val layoutUnreadCount = layoutManager?.let { (it.itemCount - 1) - it.findLastVisibleItemPosition() } val layoutUnreadCount = layoutManager?.let { (it.itemCount - 1) - it.findLastVisibleItemPosition() }
?: RecyclerView.NO_POSITION ?: RecyclerView.NO_POSITION
unreadCount = min(unreadCount, layoutUnreadCount).coerceAtLeast(0) unreadCount = min(unreadCount, layoutUnreadCount).coerceAtLeast(0)
@@ -1069,11 +1110,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun updatePlaceholder() { private fun updatePlaceholder() {
val recipient = viewModel.recipient val recipient = viewModel.recipient
?: return Log.w("Loki", "recipient was null in placeholder update") ?: return Log.w("Loki", "recipient was null in placeholder update")
val blindedRecipient = viewModel.blindedRecipient
val binding = binding ?: return val binding = binding ?: return
val openGroup = viewModel.openGroup val openGroup = viewModel.openGroup
val (textResource, insertParam) = when { val (textResource, insertParam) = when {
recipient.isLocalNumber -> R.string.activity_conversation_empty_state_note_to_self to null recipient.isLocalNumber -> R.string.activity_conversation_empty_state_note_to_self to null
openGroup != null && !openGroup.canWrite -> R.string.activity_conversation_empty_state_read_only to recipient.toShortString() openGroup != null && !openGroup.canWrite -> R.string.activity_conversation_empty_state_read_only to recipient.toShortString()
blindedRecipient?.blocksCommunityMessageRequests == true -> R.string.activity_conversation_empty_state_blocks_community_requests to recipient.toShortString()
else -> R.string.activity_conversation_empty_state_default to recipient.toShortString() else -> R.string.activity_conversation_empty_state_default to recipient.toShortString()
} }
val showPlaceholder = adapter.itemCount == 0 val showPlaceholder = adapter.itemCount == 0
@@ -1110,33 +1154,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding.unreadCountIndicator.isVisible = (unreadCount != 0) binding.unreadCountIndicator.isVisible = (unreadCount != 0)
} }
private fun updateSubtitle() {
val actionBarBinding = binding?.toolbarContent ?: return
val recipient = viewModel.recipient ?: return
actionBarBinding.muteIconImageView.isVisible = recipient.isMuted
actionBarBinding.conversationSubtitleView.isVisible = true
if (recipient.isMuted) {
if (recipient.mutedUntil != Long.MAX_VALUE) {
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_until_date, DateUtils.getFormattedDateTime(recipient.mutedUntil, "EEE, MMM d, yyyy HH:mm", Locale.getDefault()))
} else {
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_forever)
}
} else if (recipient.isGroupRecipient) {
viewModel.openGroup?.let { openGroup ->
val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_active_member_count, userCount)
} ?: run {
val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount)
}
viewModel
} else {
actionBarBinding.conversationSubtitleView.isVisible = false
}
}
// endregion // endregion
// region Interaction // region Interaction
override fun onDisappearingMessagesClicked() {
viewModel.recipient?.let { showDisappearingMessages(it) }
}
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) { if (item.itemId == android.R.id.home) {
return false return false
@@ -1169,7 +1193,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
override fun copyOpenGroupUrl(thread: Recipient) { override fun copyOpenGroupUrl(thread: Recipient) {
if (!thread.isOpenGroupRecipient) { return } if (!thread.isCommunityRecipient) { return }
val threadId = threadDb.getThreadIdIfExistsFor(thread) ?: return val threadId = threadDb.getThreadIdIfExistsFor(thread) ?: return
val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return
@@ -1180,20 +1204,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
} }
override fun showExpiringMessagesDialog(thread: Recipient) { override fun showDisappearingMessages(thread: Recipient) {
if (thread.isClosedGroupRecipient) { if (thread.isClosedGroupRecipient) {
val group = groupDb.getGroup(thread.address.toGroupString()).orNull() groupDb.getGroup(thread.address.toGroupString()).orNull()?.run { if (!isActive) return }
if (group?.isActive == false) { return }
}
showExpirationDialog(thread.expireMessages) { expirationTime ->
storage.setExpirationTimer(thread.address.serialize(), expirationTime)
val message = ExpirationTimerUpdate(expirationTime)
message.recipient = thread.address.serialize()
message.sentTimestamp = SnodeAPI.nowWithOffset
ApplicationContext.getInstance(this).expiringMessageManager.setExpirationTimer(message)
MessageSender.send(message, thread.address)
invalidateOptionsMenu()
} }
Intent(this, DisappearingMessagesActivity::class.java)
.apply { putExtra(DisappearingMessagesActivity.THREAD_ID, viewModel.threadId) }
.also { show(it, true) }
} }
override fun unblock() { override fun unblock() {
@@ -1235,6 +1252,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// `position` is the adapter position; not the visual position // `position` is the adapter position; not the visual position
private fun handleSwipeToReply(message: MessageRecord) { private fun handleSwipeToReply(message: MessageRecord) {
if (message.isOpenGroupInvitation) return
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient ?: return
binding?.inputBar?.draftQuote(recipient, message, glide) binding?.inputBar?.draftQuote(recipient, message, glide)
} }
@@ -1314,6 +1332,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
sendEmojiRemoval(emoji, messageRecord) sendEmojiRemoval(emoji, messageRecord)
} else { } else {
sendEmojiReaction(emoji, messageRecord) sendEmojiReaction(emoji, messageRecord)
RecentEmojiPageModel.onCodePointSelected(emoji) // Save to recently used reaction emojis
} }
} }
@@ -1340,7 +1359,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} else originalMessage.individualRecipient.address } else originalMessage.individualRecipient.address
// Send it // Send it
reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, true) reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, true)
if (recipient.isOpenGroupRecipient) { if (recipient.isCommunityRecipient) {
val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return
viewModel.openGroup?.let { viewModel.openGroup?.let {
OpenGroupApi.addReaction(it.room, it.server, messageServerId, emoji) OpenGroupApi.addReaction(it.room, it.server, messageServerId, emoji)
@@ -1364,7 +1383,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} else originalMessage.individualRecipient.address } else originalMessage.individualRecipient.address
message.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, false) message.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, false)
if (recipient.isOpenGroupRecipient) { if (recipient.isCommunityRecipient) {
val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return
viewModel.openGroup?.let { viewModel.openGroup?.let {
OpenGroupApi.deleteReaction(it.room, it.server, messageServerId, emoji) OpenGroupApi.deleteReaction(it.room, it.server, messageServerId, emoji)
@@ -1538,8 +1557,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return } if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return }
val viewHolder = binding?.conversationRecyclerView?.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return val viewHolder = binding?.conversationRecyclerView?.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return
val visibleMessageView = ViewVisibleMessageBinding.bind(viewHolder.view).visibleMessageView viewHolder.view.playVoiceMessage()
visibleMessageView.playVoiceMessage()
} }
override fun sendMessage() { override fun sendMessage() {
@@ -1590,10 +1608,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
return null return null
} }
// Create the message // Create the message
val message = VisibleMessage() val message = VisibleMessage().applyExpiryMode(viewModel.threadId)
message.sentTimestamp = sentTimestamp message.sentTimestamp = sentTimestamp
message.text = text message.text = text
val outgoingTextMessage = OutgoingTextMessage.from(message, recipient) val expiresInMillis = viewModel.expirationConfiguration?.expiryMode?.expiryMillis ?: 0
val expireStartedAt = if (viewModel.expirationConfiguration?.expiryMode is ExpiryMode.AfterSend) {
message.sentTimestamp!!
} else 0
val outgoingTextMessage = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt)
// Clear the input bar // Clear the input bar
binding?.inputBar?.text = "" binding?.inputBar?.text = ""
binding?.inputBar?.cancelQuoteDraft() binding?.inputBar?.cancelQuoteDraft()
@@ -1611,12 +1633,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
return Pair(recipient.address, sentTimestamp) return Pair(recipient.address, sentTimestamp)
} }
private fun sendAttachments(attachments: List<Attachment>, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null): Pair<Address, Long>? { private fun sendAttachments(
attachments: List<Attachment>,
body: String?,
quotedMessage: MessageRecord? = binding?.inputBar?.quote,
linkPreview: LinkPreview? = null
): Pair<Address, Long>? {
val recipient = viewModel.recipient ?: return null val recipient = viewModel.recipient ?: return null
val sentTimestamp = SnodeAPI.nowWithOffset val sentTimestamp = SnodeAPI.nowWithOffset
processMessageRequestApproval() processMessageRequestApproval()
// Create the message // Create the message
val message = VisibleMessage() val message = VisibleMessage().applyExpiryMode(viewModel.threadId)
message.sentTimestamp = sentTimestamp message.sentTimestamp = sentTimestamp
message.text = body message.text = body
val quote = quotedMessage?.let { val quote = quotedMessage?.let {
@@ -1632,7 +1659,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
else it.individualRecipient.address else it.individualRecipient.address
quote?.copy(author = sender) quote?.copy(author = sender)
} }
val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, localQuote, linkPreview) val expiresInMs = viewModel.expirationConfiguration?.expiryMode?.expiryMillis ?: 0
val expireStartedAtMs = if (viewModel.expirationConfiguration?.expiryMode is ExpiryMode.AfterSend) {
sentTimestamp
} else 0
val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, localQuote, linkPreview, expiresInMs, expireStartedAtMs)
// Clear the input bar // Clear the input bar
binding?.inputBar?.text = "" binding?.inputBar?.text = ""
binding?.inputBar?.cancelQuoteDraft() binding?.inputBar?.cancelQuoteDraft()
@@ -1697,6 +1728,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
} }
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
super.onActivityResult(requestCode, resultCode, intent) super.onActivityResult(requestCode, resultCode, intent)
val mediaPreppedListener = object : ListenableFuture.Listener<Boolean> { val mediaPreppedListener = object : ListenableFuture.Listener<Boolean> {
@@ -1747,7 +1779,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
sendAttachments(slideDeck.asAttachments(), body) sendAttachments(slideDeck.asAttachments(), body)
} }
INVITE_CONTACTS -> { INVITE_CONTACTS -> {
if (viewModel.recipient?.isOpenGroupRecipient != true) { return } if (viewModel.recipient?.isCommunityRecipient != true) { return }
val extras = intent?.extras ?: return val extras = intent?.extras ?: return
if (!intent.hasExtra(selectedContactsKey)) { return } if (!intent.hasExtra(selectedContactsKey)) { return }
val selectedContacts = extras.getStringArray(selectedContactsKey)!! val selectedContacts = extras.getStringArray(selectedContactsKey)!!
@@ -1813,19 +1845,61 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
handleLongPress(messages.first(), 0) //TODO: begin selection mode handleLongPress(messages.first(), 0) //TODO: begin selection mode
} }
override fun deleteMessages(messages: Set<MessageRecord>) { // The option to "Delete just for me" or "Delete for everyone"
val recipient = viewModel.recipient ?: return private fun showDeleteOrDeleteForEveryoneInCommunityUI(messages: Set<MessageRecord>) {
val allSentByCurrentUser = messages.all { it.isOutgoing } val bottomSheet = DeleteOptionsBottomSheet()
val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id) != null } bottomSheet.recipient = viewModel.recipient!!
if (recipient.isOpenGroupRecipient) { bottomSheet.onDeleteForMeTapped = {
val messageCount = 1 messages.forEach(viewModel::deleteLocally)
bottomSheet.dismiss()
endActionMode()
}
bottomSheet.onDeleteForEveryoneTapped = {
messages.forEach(viewModel::deleteForEveryone)
bottomSheet.dismiss()
endActionMode()
}
bottomSheet.onCancelTapped = {
bottomSheet.dismiss()
endActionMode()
}
bottomSheet.show(supportFragmentManager, bottomSheet.tag)
}
private fun showDeleteLocallyUI(messages: Set<MessageRecord>) {
val messageCount = 1
showSessionDialog {
title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
button(R.string.delete) { messages.forEach(viewModel::deleteLocally); endActionMode() }
cancelButton(::endActionMode)
}
}
// Note: The messages in the provided set may be a single message, or multiple if there are a
// group of selected messages.
override fun deleteMessages(messages: Set<MessageRecord>) {
val recipient = viewModel.recipient
if (recipient == null) {
Log.w("ConversationActivityV2", "Asked to delete messages but could not obtain viewModel recipient - aborting.")
return
}
val allSentByCurrentUser = messages.all { it.isOutgoing }
val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id, it.isMms) != null }
// If the recipient is a community OR a Note-to-Self then we delete the message for everyone
if (recipient.isCommunityRecipient || recipient.isLocalNumber) {
val messageCount = 1 // Only used for plurals string
showSessionDialog { showSessionDialog {
title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
button(R.string.delete) { messages.forEach(viewModel::deleteForEveryone); endActionMode() } button(R.string.delete) {
messages.forEach(viewModel::deleteForEveryone); endActionMode()
}
cancelButton { endActionMode() } cancelButton { endActionMode() }
} }
// Otherwise if this is a 1-on-1 conversation we may decided to delete just for ourselves or delete for everyone
} else if (allSentByCurrentUser && allHasHash) { } else if (allSentByCurrentUser && allHasHash) {
val bottomSheet = DeleteOptionsBottomSheet() val bottomSheet = DeleteOptionsBottomSheet()
bottomSheet.recipient = recipient bottomSheet.recipient = recipient
@@ -1844,13 +1918,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
endActionMode() endActionMode()
} }
bottomSheet.show(supportFragmentManager, bottomSheet.tag) bottomSheet.show(supportFragmentManager, bottomSheet.tag)
} else { }
else // Finally, if this is a closed group and you are deleting someone else's message(s) then we can only delete locally.
{
val messageCount = 1 val messageCount = 1
showSessionDialog { showSessionDialog {
title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
button(R.string.delete) { messages.forEach(viewModel::deleteLocally); endActionMode() } button(R.string.delete) {
messages.forEach(viewModel::deleteLocally); endActionMode()
}
cancelButton(::endActionMode) cancelButton(::endActionMode)
} }
} }
@@ -1869,7 +1946,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
showSessionDialog { showSessionDialog {
title(R.string.ConversationFragment_ban_selected_user) title(R.string.ConversationFragment_ban_selected_user)
text("This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.") text("This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.")
button(R.string.ban) { viewModel.banAndDeleteAll(messages.first().individualRecipient); endActionMode() } button(R.string.ban) { viewModel.banAndDeleteAll(messages.first()); endActionMode() }
cancelButton(::endActionMode) cancelButton(::endActionMode)
} }
} }
@@ -1949,6 +2026,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun saveAttachment(messages: Set<MessageRecord>) { override fun saveAttachment(messages: Set<MessageRecord>) {
val message = messages.first() as MmsMessageRecord val message = messages.first() as MmsMessageRecord
// Do not allow the user to download a file attachment before it has finished downloading
if (message.isMediaPending) {
Toast.makeText(this, resources.getString(R.string.conversation_activity__wait_until_attachment_has_finished_downloading), Toast.LENGTH_LONG).show()
return
}
SaveAttachmentTask.showWarningDialog(this) { SaveAttachmentTask.showWarningDialog(this) {
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
@@ -2113,4 +2197,18 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} }
// AdapterDataObserver implementation to scroll us to the bottom of the ConversationRecyclerView
// when we're already near the bottom and we send or receive a message.
inner class ConversationAdapterDataObserver(val recyclerView: ConversationRecyclerView, val adapter: ConversationAdapter) : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
super.onChanged()
if (recyclerView.isScrolledToWithin30dpOfBottom) {
// Note: The adapter itemCount is zero based - so calling this with the itemCount in
// a non-zero based manner scrolls us to the bottom of the last message (including
// to the bottom of long messages as required by Jira SES-789 / GitHub 1364).
recyclerView.scrollToPosition(adapter.itemCount)
}
}
}
} }

View File

@@ -5,9 +5,7 @@ import android.content.Intent
import android.database.Cursor import android.database.Cursor
import android.util.SparseArray import android.util.SparseArray
import android.util.SparseBooleanArray import android.util.SparseBooleanArray
import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.core.util.getOrDefault import androidx.core.util.getOrDefault
@@ -20,7 +18,6 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageBinding
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
@@ -57,6 +54,7 @@ class ConversationAdapter(
private val contactCache = SparseArray<Contact>(100) private val contactCache = SparseArray<Contact>(100)
private val contactLoadedCache = SparseBooleanArray(100) private val contactLoadedCache = SparseBooleanArray(100)
private val lastSeen = AtomicLong(originalLastSeen) private val lastSeen = AtomicLong(originalLastSeen)
private var lastSentMessageId: Long = -1L
init { init {
lifecycleCoroutineScope.launch(IO) { lifecycleCoroutineScope.launch(IO) {
@@ -87,7 +85,7 @@ class ConversationAdapter(
} }
} }
class VisibleMessageViewHolder(val view: View) : ViewHolder(view) class VisibleMessageViewHolder(val view: VisibleMessageView) : ViewHolder(view)
class ControlMessageViewHolder(val view: ControlMessageView) : ViewHolder(view) class ControlMessageViewHolder(val view: ControlMessageView) : ViewHolder(view)
override fun getItemViewType(cursor: Cursor): Int { override fun getItemViewType(cursor: Cursor): Int {
@@ -100,7 +98,7 @@ class ConversationAdapter(
@Suppress("NAME_SHADOWING") @Suppress("NAME_SHADOWING")
val viewType = ViewType.allValues[viewType] val viewType = ViewType.allValues[viewType]
return when (viewType) { return when (viewType) {
ViewType.Visible -> VisibleMessageViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.view_visible_message, parent, false)) ViewType.Visible -> VisibleMessageViewHolder(VisibleMessageView(context))
ViewType.Control -> ControlMessageViewHolder(ControlMessageView(context)) ViewType.Control -> ControlMessageViewHolder(ControlMessageView(context))
else -> throw IllegalStateException("Unexpected view type: $viewType.") else -> throw IllegalStateException("Unexpected view type: $viewType.")
} }
@@ -112,7 +110,7 @@ class ConversationAdapter(
val messageBefore = getMessageBefore(position, cursor) val messageBefore = getMessageBefore(position, cursor)
when (viewHolder) { when (viewHolder) {
is VisibleMessageViewHolder -> { is VisibleMessageViewHolder -> {
val visibleMessageView = ViewVisibleMessageBinding.bind(viewHolder.view).visibleMessageView val visibleMessageView = viewHolder.view
val isSelected = selectedItems.contains(message) val isSelected = selectedItems.contains(message)
visibleMessageView.snIsSelected = isSelected visibleMessageView.snIsSelected = isSelected
visibleMessageView.indexInAdapter = position visibleMessageView.indexInAdapter = position
@@ -136,7 +134,8 @@ class ConversationAdapter(
senderId, senderId,
lastSeen.get(), lastSeen.get(),
visibleMessageViewDelegate, visibleMessageViewDelegate,
onAttachmentNeedsDownload onAttachmentNeedsDownload,
lastSentMessageId
) )
if (!message.isDeleted) { if (!message.isDeleted) {
@@ -177,7 +176,7 @@ class ConversationAdapter(
override fun onItemViewRecycled(viewHolder: ViewHolder?) { override fun onItemViewRecycled(viewHolder: ViewHolder?) {
when (viewHolder) { when (viewHolder) {
is VisibleMessageViewHolder -> viewHolder.view.findViewById<VisibleMessageView>(R.id.visibleMessageView).recycle() is VisibleMessageViewHolder -> viewHolder.view.recycle()
is ControlMessageViewHolder -> viewHolder.view.recycle() is ControlMessageViewHolder -> viewHolder.view.recycle()
} }
super.onItemViewRecycled(viewHolder) super.onItemViewRecycled(viewHolder)
@@ -207,6 +206,7 @@ class ConversationAdapter(
override fun changeCursor(cursor: Cursor?) { override fun changeCursor(cursor: Cursor?) {
super.changeCursor(cursor) super.changeCursor(cursor)
val toRemove = mutableSetOf<MessageRecord>() val toRemove = mutableSetOf<MessageRecord>()
val toDeselect = mutableSetOf<Pair<Int, MessageRecord>>() val toDeselect = mutableSetOf<Pair<Int, MessageRecord>>()
for (selected in selectedItems) { for (selected in selectedItems) {

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.AbstractCursorLoader import org.thoughtcrime.securesms.util.AbstractCursorLoader
@@ -12,6 +13,7 @@ class ConversationLoader(
) : AbstractCursorLoader(context) { ) : AbstractCursorLoader(context) {
override fun getCursor(): Cursor { override fun getCursor(): Cursor {
MessagingModuleConfiguration.shared.lastSentTimestampCache.refresh(threadID)
return DatabaseComponent.get(context).mmsSmsDatabase().getConversation(threadID, reverse) return DatabaseComponent.get(context).mmsSmsDatabase().getConversation(threadID, reverse)
} }
} }

View File

@@ -1,902 +0,0 @@
package org.thoughtcrime.securesms.conversation.v2;
import android.animation.Animator;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.app.Activity;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.PointF;
import android.graphics.Rect;
import android.graphics.drawable.BitmapDrawable;
import android.util.AttributeSet;
import android.view.HapticFeedbackConstants;
import android.view.MotionEvent;
import android.view.View;
import android.view.Window;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.Interpolator;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat;
import androidx.core.view.ViewKt;
import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat;
import com.annimon.stream.Stream;
import org.session.libsession.messaging.open_groups.OpenGroup;
import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.ThemeUtil;
import org.session.libsession.utilities.recipients.Recipient;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel;
import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.util.AnimationCompleteListener;
import org.thoughtcrime.securesms.util.DateUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import kotlin.Unit;
import network.loki.messenger.R;
public final class ConversationReactionOverlay extends FrameLayout {
public static final float LONG_PRESS_SCALE_FACTOR = 0.95f;
private static final Interpolator INTERPOLATOR = new DecelerateInterpolator();
private final Rect emojiViewGlobalRect = new Rect();
private final Rect emojiStripViewBounds = new Rect();
private float segmentSize;
private final Boundary horizontalEmojiBoundary = new Boundary();
private final Boundary verticalScrubBoundary = new Boundary();
private final PointF deadzoneTouchPoint = new PointF();
private Activity activity;
private MessageRecord messageRecord;
private SelectedConversationModel selectedConversationModel;
private String blindedPublicKey;
private OverlayState overlayState = OverlayState.HIDDEN;
private RecentEmojiPageModel recentEmojiPageModel;
private boolean downIsOurs;
private int selected = -1;
private int customEmojiIndex;
private int originalStatusBarColor;
private int originalNavigationBarColor;
private View dropdownAnchor;
private LinearLayout conversationItem;
private View conversationBubble;
private TextView conversationTimestamp;
private View backgroundView;
private ConstraintLayout foregroundView;
private EmojiImageView[] emojiViews;
private ConversationContextMenu contextMenu;
private float touchDownDeadZoneSize;
private float distanceFromTouchDownPointToBottomOfScrubberDeadZone;
private int scrubberWidth;
private int selectedVerticalTranslation;
private int scrubberHorizontalMargin;
private int animationEmojiStartDelayFactor;
private int statusBarHeight;
private OnReactionSelectedListener onReactionSelectedListener;
private OnActionSelectedListener onActionSelectedListener;
private OnHideListener onHideListener;
private AnimatorSet revealAnimatorSet = new AnimatorSet();
private AnimatorSet hideAnimatorSet = new AnimatorSet();
public ConversationReactionOverlay(@NonNull Context context) {
super(context);
}
public ConversationReactionOverlay(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
dropdownAnchor = findViewById(R.id.dropdown_anchor);
conversationItem = findViewById(R.id.conversation_item);
conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble);
conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp);
backgroundView = findViewById(R.id.conversation_reaction_scrubber_background);
foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground);
emojiViews = new EmojiImageView[] { findViewById(R.id.reaction_1),
findViewById(R.id.reaction_2),
findViewById(R.id.reaction_3),
findViewById(R.id.reaction_4),
findViewById(R.id.reaction_5),
findViewById(R.id.reaction_6),
findViewById(R.id.reaction_7) };
customEmojiIndex = emojiViews.length - 1;
distanceFromTouchDownPointToBottomOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom);
touchDownDeadZoneSize = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size);
scrubberWidth = getResources().getDimensionPixelOffset(R.dimen.reaction_scrubber_width);
selectedVerticalTranslation = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation);
scrubberHorizontalMargin = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin);
animationEmojiStartDelayFactor = getResources().getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor);
initAnimators();
}
public void show(@NonNull Activity activity,
@NonNull MessageRecord messageRecord,
@NonNull PointF lastSeenDownPoint,
@NonNull SelectedConversationModel selectedConversationModel,
@Nullable String blindedPublicKey)
{
if (overlayState != OverlayState.HIDDEN) {
return;
}
this.messageRecord = messageRecord;
this.selectedConversationModel = selectedConversationModel;
this.blindedPublicKey = blindedPublicKey;
overlayState = OverlayState.UNINITAILIZED;
selected = -1;
recentEmojiPageModel = new RecentEmojiPageModel(activity);
setupSelectedEmoji();
View statusBarBackground = activity.findViewById(android.R.id.statusBarBackground);
statusBarHeight = statusBarBackground == null ? 0 : statusBarBackground.getHeight();
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
conversationBubble.setLayoutParams(new LinearLayout.LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight()));
conversationBubble.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot));
conversationTimestamp.setText(DateUtils.getDisplayFormattedTimeSpanString(getContext(), Locale.getDefault(), messageRecord.getTimestamp()));
updateConversationTimestamp(messageRecord);
boolean isMessageOnLeft = selectedConversationModel.isOutgoing() ^ ViewUtil.isLtr(this);
conversationItem.setScaleX(LONG_PRESS_SCALE_FACTOR);
conversationItem.setScaleY(LONG_PRESS_SCALE_FACTOR);
setVisibility(View.INVISIBLE);
this.activity = activity;
updateSystemUiOnShow(activity);
ViewKt.doOnLayout(this, v -> {
showAfterLayout(messageRecord, lastSeenDownPoint, isMessageOnLeft);
return Unit.INSTANCE;
});
}
private void updateConversationTimestamp(MessageRecord message) {
if (message.isOutgoing()) conversationBubble.bringToFront();
else conversationTimestamp.bringToFront();
}
private void showAfterLayout(@NonNull MessageRecord messageRecord,
@NonNull PointF lastSeenDownPoint,
boolean isMessageOnLeft) {
contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord));
float endX = isMessageOnLeft ? scrubberHorizontalMargin :
selectedConversationModel.getBubbleX() - conversationItem.getWidth() + selectedConversationModel.getBubbleWidth();
float endY = selectedConversationModel.getBubbleY() - statusBarHeight;
conversationItem.setX(endX);
conversationItem.setY(endY);
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth();
int overlayHeight = getHeight();
int bubbleWidth = selectedConversationModel.getBubbleWidth();
float endApparentTop = endY;
float endScale = 1f;
float menuPadding = DimensionUnit.DP.toPixels(12f);
float reactionBarTopPadding = DimensionUnit.DP.toPixels(32f);
int reactionBarHeight = backgroundView.getHeight();
float reactionBarBackgroundY;
if (isWideLayout) {
boolean everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + conversationItemSnapshot.getHeight() < overlayHeight;
if (everythingFitsVertically) {
boolean reactionBarFitsAboveItem = conversationItem.getY() > reactionBarHeight + menuPadding + reactionBarTopPadding;
if (reactionBarFitsAboveItem) {
reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
} else {
endY = reactionBarHeight + menuPadding + reactionBarTopPadding;
reactionBarBackgroundY = reactionBarTopPadding;
}
} else {
float spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding;
endScale = spaceAvailableForItem / conversationItem.getHeight();
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
endY = reactionBarHeight + menuPadding + reactionBarTopPadding - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
reactionBarBackgroundY = reactionBarTopPadding;
}
} else {
float reactionBarOffset = DimensionUnit.DP.toPixels(48);
float spaceForReactionBar = Math.max(reactionBarHeight + reactionBarOffset, 0);
boolean everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.getHeight() + menuPadding + spaceForReactionBar < overlayHeight;
if (everythingFitsVertically) {
float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
boolean menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight;
if (menuFitsBelowItem) {
if (conversationItem.getY() < 0) {
endY = 0;
}
float contextMenuTop = endY + conversationItemSnapshot.getHeight();
reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
if (reactionBarBackgroundY <= reactionBarTopPadding) {
endY = backgroundView.getHeight() + menuPadding + reactionBarTopPadding;
}
} else {
endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.getHeight();
reactionBarBackgroundY = endY - reactionBarHeight - menuPadding;
}
endApparentTop = endY;
} else if (reactionBarOffset + reactionBarHeight + contextMenu.getMaxHeight() + menuPadding < overlayHeight) {
float spaceAvailableForItem = (float) overlayHeight - contextMenu.getMaxHeight() - menuPadding - spaceForReactionBar;
endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
float contextMenuTop = endY + (conversationItemSnapshot.getHeight() * endScale);
reactionBarBackgroundY = reactionBarTopPadding;//getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
endApparentTop = endY + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
} else {
contextMenu.setHeight(contextMenu.getMaxHeight() / 2);
int menuHeight = contextMenu.getHeight();
boolean fitsVertically = menuHeight + conversationItem.getHeight() + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight;
if (fitsVertically) {
float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
boolean menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + statusBarHeight;
if (menuFitsBelowItem) {
reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
if (reactionBarBackgroundY < reactionBarTopPadding) {
endY = reactionBarTopPadding + reactionBarHeight + menuPadding;
reactionBarBackgroundY = reactionBarTopPadding;
}
} else {
endY = overlayHeight - menuHeight - menuPadding - conversationItemSnapshot.getHeight();
reactionBarBackgroundY = endY - reactionBarHeight - menuPadding;
}
endApparentTop = endY;
} else {
float spaceAvailableForItem = (float) overlayHeight - menuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding;
endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale) + menuPadding + reactionBarTopPadding;
reactionBarBackgroundY = reactionBarTopPadding;
endApparentTop = reactionBarHeight + menuPadding + reactionBarTopPadding;
}
}
}
reactionBarBackgroundY = Math.max(reactionBarBackgroundY, -statusBarHeight);
hideAnimatorSet.end();
setVisibility(View.VISIBLE);
float scrubberX;
if (isMessageOnLeft) {
scrubberX = scrubberHorizontalMargin;
} else {
scrubberX = getWidth() - scrubberWidth - scrubberHorizontalMargin;
}
foregroundView.setX(scrubberX);
foregroundView.setY(reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.getHeight() / 2f);
backgroundView.setX(scrubberX);
backgroundView.setY(reactionBarBackgroundY);
verticalScrubBoundary.update(reactionBarBackgroundY,
lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone);
updateBoundsOnLayoutChanged();
revealAnimatorSet.start();
if (isWideLayout) {
float scrubberRight = scrubberX + scrubberWidth;
float offsetX = isMessageOnLeft ? scrubberRight + menuPadding : scrubberX - contextMenu.getMaxWidth() - menuPadding;
contextMenu.show((int) offsetX, (int) Math.min(backgroundView.getY(), overlayHeight - contextMenu.getMaxHeight()));
} else {
float contentX = isMessageOnLeft ? scrubberHorizontalMargin : selectedConversationModel.getBubbleX();
float offsetX = isMessageOnLeft ? contentX : -contextMenu.getMaxWidth() + contentX + bubbleWidth;
float menuTop = endApparentTop + (conversationItemSnapshot.getHeight() * endScale);
contextMenu.show((int) offsetX, (int) (menuTop + menuPadding));
}
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
conversationBubble.animate()
.scaleX(endScale)
.scaleY(endScale)
.setDuration(revealDuration);
conversationItem.animate()
.x(endX)
.y(endY)
.setDuration(revealDuration);
}
private float getReactionBarOffsetForTouch(float itemY,
float contextMenuTop,
float contextMenuPadding,
float reactionBarOffset,
int reactionBarHeight,
float spaceNeededBetweenTopOfScreenAndTopOfReactionBar,
float messageTop)
{
float adjustedTouchY = itemY - statusBarHeight;
float reactionStartingPoint = Math.min(adjustedTouchY, contextMenuTop);
float spaceBetweenTopOfMessageAndTopOfContextMenu = Math.abs(messageTop - contextMenuTop);
if (spaceBetweenTopOfMessageAndTopOfContextMenu < DimensionUnit.DP.toPixels(150)) {
float offsetToMakeReactionBarOffsetMatchMenuPadding = reactionBarOffset - contextMenuPadding;
reactionStartingPoint = messageTop + offsetToMakeReactionBarOffsetMatchMenuPadding;
}
return Math.max(reactionStartingPoint - reactionBarOffset - reactionBarHeight, spaceNeededBetweenTopOfScreenAndTopOfReactionBar);
}
private void updateSystemUiOnShow(@NonNull Activity activity) {
Window window = activity.getWindow();
int barColor = ContextCompat.getColor(getContext(), R.color.reactions_screen_dark_shade_color);
originalStatusBarColor = window.getStatusBarColor();
WindowUtil.setStatusBarColor(window, barColor);
originalNavigationBarColor = window.getNavigationBarColor();
WindowUtil.setNavigationBarColor(window, barColor);
if (!ThemeUtil.isDarkTheme(getContext())) {
WindowUtil.clearLightStatusBar(window);
WindowUtil.clearLightNavigationBar(window);
}
}
public void hide() {
hideInternal(onHideListener);
}
public void hideForReactWithAny() {
hideInternal(onHideListener);
}
private void hideInternal(@Nullable OnHideListener onHideListener) {
overlayState = OverlayState.HIDDEN;
AnimatorSet animatorSet = newHideAnimatorSet();
hideAnimatorSet = animatorSet;
revealAnimatorSet.end();
animatorSet.start();
if (onHideListener != null) {
onHideListener.startHide();
}
if (selectedConversationModel.getFocusedView() != null) {
ViewUtil.focusAndShowKeyboard(selectedConversationModel.getFocusedView());
}
animatorSet.addListener(new AnimationCompleteListener() {
@Override public void onAnimationEnd(Animator animation) {
animatorSet.removeListener(this);
if (onHideListener != null) {
onHideListener.onHide();
}
}
});
if (contextMenu != null) {
contextMenu.dismiss();
}
}
public boolean isShowing() {
return overlayState != OverlayState.HIDDEN;
}
public @NonNull MessageRecord getMessageRecord() {
return messageRecord;
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
updateBoundsOnLayoutChanged();
}
private void updateBoundsOnLayoutChanged() {
backgroundView.getGlobalVisibleRect(emojiStripViewBounds);
emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect);
emojiStripViewBounds.left = getStart(emojiViewGlobalRect);
emojiViews[emojiViews.length - 1].getGlobalVisibleRect(emojiViewGlobalRect);
emojiStripViewBounds.right = getEnd(emojiViewGlobalRect);
segmentSize = emojiStripViewBounds.width() / (float) emojiViews.length;
}
private int getStart(@NonNull Rect rect) {
if (ViewUtil.isLtr(this)) {
return rect.left;
} else {
return rect.right;
}
}
private int getEnd(@NonNull Rect rect) {
if (ViewUtil.isLtr(this)) {
return rect.right;
} else {
return rect.left;
}
}
public boolean applyTouchEvent(@NonNull MotionEvent motionEvent) {
if (!isShowing()) {
throw new IllegalStateException("Touch events should only be propagated to this method if we are displaying the scrubber.");
}
if ((motionEvent.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) != 0) {
return true;
}
if (overlayState == OverlayState.UNINITAILIZED) {
downIsOurs = false;
deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY());
overlayState = OverlayState.DEADZONE;
}
if (overlayState == OverlayState.DEADZONE) {
float deltaX = Math.abs(deadzoneTouchPoint.x - motionEvent.getX());
float deltaY = Math.abs(deadzoneTouchPoint.y - motionEvent.getY());
if (deltaX > touchDownDeadZoneSize || deltaY > touchDownDeadZoneSize) {
overlayState = OverlayState.SCRUB;
} else {
if (motionEvent.getAction() == MotionEvent.ACTION_UP) {
overlayState = OverlayState.TAP;
if (downIsOurs) {
handleUpEvent();
return true;
}
}
return MotionEvent.ACTION_MOVE == motionEvent.getAction();
}
}
switch (motionEvent.getAction()) {
case MotionEvent.ACTION_DOWN:
selected = getSelectedIndexViaDownEvent(motionEvent);
deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY());
overlayState = OverlayState.DEADZONE;
downIsOurs = true;
return true;
case MotionEvent.ACTION_MOVE:
selected = getSelectedIndexViaMoveEvent(motionEvent);
return true;
case MotionEvent.ACTION_UP:
handleUpEvent();
return downIsOurs;
case MotionEvent.ACTION_CANCEL:
hide();
return downIsOurs;
default:
return false;
}
}
private void setupSelectedEmoji() {
final List<String> emojis = recentEmojiPageModel.getEmoji();
for (int i = 0; i < emojiViews.length; i++) {
final EmojiImageView view = emojiViews[i];
view.setScaleX(1.0f);
view.setScaleY(1.0f);
view.setTranslationY(0);
boolean isAtCustomIndex = i == customEmojiIndex;
if (isAtCustomIndex) {
view.setImageDrawable(ContextCompat.getDrawable(getContext(), R.drawable.ic_baseline_add_24));
view.setTag(null);
} else {
view.setImageEmoji(emojis.get(i));
}
}
}
private int getSelectedIndexViaDownEvent(@NonNull MotionEvent motionEvent) {
return getSelectedIndexViaMotionEvent(motionEvent, new Boundary(emojiStripViewBounds.top, emojiStripViewBounds.bottom));
}
private int getSelectedIndexViaMoveEvent(@NonNull MotionEvent motionEvent) {
return getSelectedIndexViaMotionEvent(motionEvent, verticalScrubBoundary);
}
private int getSelectedIndexViaMotionEvent(@NonNull MotionEvent motionEvent, @NonNull Boundary boundary) {
int selected = -1;
if (backgroundView.getVisibility() != View.VISIBLE) {
return selected;
}
for (int i = 0; i < emojiViews.length; i++) {
final float emojiLeft = (segmentSize * i) + emojiStripViewBounds.left;
horizontalEmojiBoundary.update(emojiLeft, emojiLeft + segmentSize);
if (horizontalEmojiBoundary.contains(motionEvent.getX()) && boundary.contains(motionEvent.getY())) {
selected = i;
}
}
if (this.selected != -1 && this.selected != selected) {
shrinkView(emojiViews[this.selected]);
}
if (this.selected != selected && selected != -1) {
growView(emojiViews[selected]);
}
return selected;
}
private void growView(@NonNull View view) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
view.animate()
.scaleY(1.5f)
.scaleX(1.5f)
.translationY(-selectedVerticalTranslation)
.setDuration(200)
.setInterpolator(INTERPOLATOR)
.start();
}
private void shrinkView(@NonNull View view) {
view.animate()
.scaleX(1.0f)
.scaleY(1.0f)
.translationY(0)
.setDuration(200)
.setInterpolator(INTERPOLATOR)
.start();
}
private void handleUpEvent() {
if (selected != -1 && onReactionSelectedListener != null && backgroundView.getVisibility() == View.VISIBLE) {
if (selected == customEmojiIndex) {
onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].getTag() != null);
} else {
onReactionSelectedListener.onReactionSelected(messageRecord, recentEmojiPageModel.getEmoji().get(selected));
}
} else {
hide();
}
}
public void setOnReactionSelectedListener(@Nullable OnReactionSelectedListener onReactionSelectedListener) {
this.onReactionSelectedListener = onReactionSelectedListener;
}
public void setOnActionSelectedListener(@Nullable OnActionSelectedListener onActionSelectedListener) {
this.onActionSelectedListener = onActionSelectedListener;
}
public void setOnHideListener(@Nullable OnHideListener onHideListener) {
this.onHideListener = onHideListener;
}
private @Nullable String getOldEmoji(@NonNull MessageRecord messageRecord) {
return Stream.of(messageRecord.getReactions())
.filter(record -> record.getAuthor().equals(TextSecurePreferences.getLocalNumber(getContext())))
.findFirst()
.map(ReactionRecord::getEmoji)
.orElse(null);
}
private @NonNull List<ActionItem> getMenuActionItems(@NonNull MessageRecord message) {
List<ActionItem> items = new ArrayList<>();
// Prepare
boolean containsControlMessage = message.isUpdate();
boolean hasText = !message.getBody().isEmpty();
OpenGroup openGroup = DatabaseComponent.get(getContext()).lokiThreadDatabase().getOpenGroupChat(message.getThreadId());
Recipient recipient = DatabaseComponent.get(getContext()).threadDatabase().getRecipientForThreadId(message.getThreadId());
if (recipient == null) return Collections.emptyList();
String userPublicKey = TextSecurePreferences.getLocalNumber(getContext());
// Select message
items.add(new ActionItem(R.attr.menu_select_icon, getContext().getResources().getString(R.string.conversation_context__menu_select), () -> handleActionItemClicked(Action.SELECT),
getContext().getResources().getString(R.string.AccessibilityId_select)));
// Reply
boolean canWrite = openGroup == null || openGroup.getCanWrite();
if (canWrite && !message.isPending() && !message.isFailed()) {
items.add(
new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_reply), () -> handleActionItemClicked(Action.REPLY),
getContext().getResources().getString(R.string.AccessibilityId_reply_message))
);
}
// Copy message text
if (!containsControlMessage && hasText) {
items.add(new ActionItem(R.attr.menu_copy_icon, getContext().getResources().getString(R.string.copy), () -> handleActionItemClicked(Action.COPY_MESSAGE)));
}
// Copy Session ID
if (recipient.isGroupRecipient() && !recipient.isOpenGroupRecipient() && !message.getRecipient().getAddress().toString().equals(userPublicKey)) {
items.add(new ActionItem(
R.attr.menu_copy_icon, getContext().getResources().getString(R.string.activity_conversation_menu_copy_session_id), () -> handleActionItemClicked(Action.COPY_SESSION_ID))
);
}
// Delete message
if (ConversationMenuItemHelper.userCanDeleteSelectedItems(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) {
items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.delete),
() -> handleActionItemClicked(Action.DELETE),
getContext().getResources().getString(R.string.AccessibilityId_delete_message)
)
);
}
// Ban user
if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) {
items.add(new ActionItem(R.attr.menu_block_icon, getContext().getResources().getString(R.string.conversation_context__menu_ban_user), () -> handleActionItemClicked(Action.BAN_USER)));
}
// Ban and delete all
if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) {
items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.conversation_context__menu_ban_and_delete_all), () -> handleActionItemClicked(Action.BAN_AND_DELETE_ALL)));
}
// Message detail
items.add(new ActionItem(R.attr.menu_info_icon, getContext().getResources().getString(R.string.conversation_context__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO)));
// Resend
if (message.isFailed()) {
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resend_message), () -> handleActionItemClicked(Action.RESEND)));
}
// Resync
if (message.isSyncFailed()) {
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resync_message), () -> handleActionItemClicked(Action.RESYNC)));
}
// Save media
if (message.isMms() && ((MediaMmsMessageRecord)message).containsMediaSlide()) {
items.add(new ActionItem(R.attr.menu_save_icon, getContext().getResources().getString(R.string.conversation_context_image__save_attachment), () -> handleActionItemClicked(Action.DOWNLOAD),
getContext().getResources().getString(R.string.AccessibilityId_save_attachment))
);
}
backgroundView.setVisibility(View.VISIBLE);
foregroundView.setVisibility(View.VISIBLE);
return items;
}
private void handleActionItemClicked(@NonNull Action action) {
hideInternal(new OnHideListener() {
@Override public void startHide() {
if (onHideListener != null) {
onHideListener.startHide();
}
}
@Override public void onHide() {
if (onHideListener != null) {
onHideListener.onHide();
}
if (onActionSelectedListener != null) {
onActionSelectedListener.onActionSelected(action);
}
}
});
}
private void initAnimators() {
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
int revealOffset = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_offset);
List<Animator> reveals = Stream.of(emojiViews)
.mapIndexed((idx, v) -> {
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_reveal);
anim.setTarget(v);
anim.setStartDelay(idx * animationEmojiStartDelayFactor);
return anim;
})
.toList();
Animator backgroundRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
backgroundRevealAnim.setTarget(backgroundView);
backgroundRevealAnim.setDuration(revealDuration);
backgroundRevealAnim.setStartDelay(revealOffset);
reveals.add(backgroundRevealAnim);
revealAnimatorSet.setInterpolator(INTERPOLATOR);
revealAnimatorSet.playTogether(reveals);
}
private @NonNull AnimatorSet newHideAnimatorSet() {
AnimatorSet set = new AnimatorSet();
set.addListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
setVisibility(View.GONE);
}
});
set.setInterpolator(INTERPOLATOR);
set.playTogether(newHideAnimators());
return set;
}
private @NonNull List<Animator> newHideAnimators() {
int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_hide_duration);
List<Animator> animators = new ArrayList<>(Stream.of(emojiViews)
.mapIndexed((idx, v) -> {
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);
anim.setTarget(v);
return anim;
})
.toList());
Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
backgroundHideAnim.setTarget(backgroundView);
backgroundHideAnim.setDuration(duration);
animators.add(backgroundHideAnim);
ObjectAnimator itemScaleXAnim = new ObjectAnimator();
itemScaleXAnim.setProperty(View.SCALE_X);
itemScaleXAnim.setFloatValues(1f);
itemScaleXAnim.setTarget(conversationItem);
itemScaleXAnim.setDuration(duration);
animators.add(itemScaleXAnim);
ObjectAnimator itemScaleYAnim = new ObjectAnimator();
itemScaleYAnim.setProperty(View.SCALE_Y);
itemScaleYAnim.setFloatValues(1f);
itemScaleYAnim.setTarget(conversationItem);
itemScaleYAnim.setDuration(duration);
animators.add(itemScaleYAnim);
ObjectAnimator itemXAnim = new ObjectAnimator();
itemXAnim.setProperty(View.X);
itemXAnim.setFloatValues(selectedConversationModel.getBubbleX());
itemXAnim.setTarget(conversationItem);
itemXAnim.setDuration(duration);
animators.add(itemXAnim);
ObjectAnimator itemYAnim = new ObjectAnimator();
itemYAnim.setProperty(View.Y);
itemYAnim.setFloatValues(selectedConversationModel.getBubbleY() - statusBarHeight);
itemYAnim.setTarget(conversationItem);
itemYAnim.setDuration(duration);
animators.add(itemYAnim);
if (activity != null) {
ValueAnimator statusBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalStatusBarColor);
statusBarAnim.setDuration(duration);
statusBarAnim.addUpdateListener(animation -> {
WindowUtil.setStatusBarColor(activity.getWindow(), (int) animation.getAnimatedValue());
});
animators.add(statusBarAnim);
ValueAnimator navigationBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalNavigationBarColor);
navigationBarAnim.setDuration(duration);
navigationBarAnim.addUpdateListener(animation -> {
WindowUtil.setNavigationBarColor(activity.getWindow(), (int) animation.getAnimatedValue());
});
animators.add(navigationBarAnim);
}
return animators;
}
public interface OnHideListener {
void startHide();
void onHide();
}
public interface OnReactionSelectedListener {
void onReactionSelected(@NonNull MessageRecord messageRecord, String emoji);
void onCustomReactionSelected(@NonNull MessageRecord messageRecord, boolean hasAddedCustomEmoji);
}
public interface OnActionSelectedListener {
void onActionSelected(@NonNull Action action);
}
private static class Boundary {
private float min;
private float max;
Boundary() {}
Boundary(float min, float max) {
update(min, max);
}
private void update(float min, float max) {
this.min = min;
this.max = max;
}
public boolean contains(float value) {
if (min < max) {
return this.min < value && this.max > value;
} else {
return this.min > value && this.max < value;
}
}
}
private enum OverlayState {
HIDDEN,
UNINITAILIZED,
DEADZONE,
SCRUB,
TAP
}
public enum Action {
REPLY,
RESEND,
RESYNC,
DOWNLOAD,
COPY_MESSAGE,
COPY_SESSION_ID,
VIEW_INFO,
SELECT,
DELETE,
BAN_USER,
BAN_AND_DELETE_ALL,
}
}

View File

@@ -0,0 +1,719 @@
package org.thoughtcrime.securesms.conversation.v2
import android.animation.Animator
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.app.Activity
import android.content.Context
import android.graphics.PointF
import android.graphics.Rect
import android.graphics.drawable.BitmapDrawable
import android.util.AttributeSet
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.animation.DecelerateInterpolator
import android.view.animation.Interpolator
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.view.doOnLayout
import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber
import org.session.libsession.utilities.ThemeUtil
import org.thoughtcrime.securesms.components.emoji.EmojiImageView
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper.userCanBanSelectedUsers
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper.userCanDeleteSelectedItems
import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
import org.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.util.AnimationCompleteListener
import org.thoughtcrime.securesms.util.DateUtils
import java.util.Locale
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@AndroidEntryPoint
class ConversationReactionOverlay : FrameLayout {
private val emojiViewGlobalRect = Rect()
private val emojiStripViewBounds = Rect()
private var segmentSize = 0f
private val horizontalEmojiBoundary = Boundary()
private val verticalScrubBoundary = Boundary()
private val deadzoneTouchPoint = PointF()
private lateinit var activity: Activity
lateinit var messageRecord: MessageRecord
private lateinit var selectedConversationModel: SelectedConversationModel
private var blindedPublicKey: String? = null
private var overlayState = OverlayState.HIDDEN
private lateinit var recentEmojiPageModel: RecentEmojiPageModel
private var downIsOurs = false
private var selected = -1
private var customEmojiIndex = 0
private var originalStatusBarColor = 0
private var originalNavigationBarColor = 0
private lateinit var dropdownAnchor: View
private lateinit var conversationItem: LinearLayout
private lateinit var conversationBubble: View
private lateinit var conversationTimestamp: TextView
private lateinit var backgroundView: View
private lateinit var foregroundView: ConstraintLayout
private lateinit var emojiViews: List<EmojiImageView>
private var contextMenu: ConversationContextMenu? = null
private var touchDownDeadZoneSize = 0f
private var distanceFromTouchDownPointToBottomOfScrubberDeadZone = 0f
private var scrubberWidth = 0
private var selectedVerticalTranslation = 0
private var scrubberHorizontalMargin = 0
private var animationEmojiStartDelayFactor = 0
private var statusBarHeight = 0
private var onReactionSelectedListener: OnReactionSelectedListener? = null
private var onActionSelectedListener: OnActionSelectedListener? = null
private var onHideListener: OnHideListener? = null
private val revealAnimatorSet = AnimatorSet()
private var hideAnimatorSet = AnimatorSet()
@Inject lateinit var mmsSmsDatabase: MmsSmsDatabase
@Inject lateinit var repository: ConversationRepository
private val scope = CoroutineScope(Dispatchers.Default)
private var job: Job? = null
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
override fun onFinishInflate() {
super.onFinishInflate()
dropdownAnchor = findViewById(R.id.dropdown_anchor)
conversationItem = findViewById(R.id.conversation_item)
conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble)
conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp)
backgroundView = findViewById(R.id.conversation_reaction_scrubber_background)
foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground)
emojiViews = listOf(R.id.reaction_1, R.id.reaction_2, R.id.reaction_3, R.id.reaction_4, R.id.reaction_5, R.id.reaction_6, R.id.reaction_7).map { findViewById(it) }
customEmojiIndex = emojiViews.size - 1
distanceFromTouchDownPointToBottomOfScrubberDeadZone = resources.getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom).toFloat()
touchDownDeadZoneSize = resources.getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size).toFloat()
scrubberWidth = resources.getDimensionPixelOffset(R.dimen.reaction_scrubber_width)
selectedVerticalTranslation = resources.getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation)
scrubberHorizontalMargin = resources.getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin)
animationEmojiStartDelayFactor = resources.getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor)
initAnimators()
}
fun show(activity: Activity,
messageRecord: MessageRecord,
lastSeenDownPoint: PointF,
selectedConversationModel: SelectedConversationModel,
blindedPublicKey: String?) {
job?.cancel()
if (overlayState != OverlayState.HIDDEN) return
this.messageRecord = messageRecord
this.selectedConversationModel = selectedConversationModel
this.blindedPublicKey = blindedPublicKey
overlayState = OverlayState.UNINITAILIZED
selected = -1
recentEmojiPageModel = RecentEmojiPageModel(activity)
setupSelectedEmoji()
val statusBarBackground = activity.findViewById<View>(android.R.id.statusBarBackground)
statusBarHeight = statusBarBackground?.height ?: 0
val conversationItemSnapshot = selectedConversationModel.bitmap
conversationBubble.layoutParams = LinearLayout.LayoutParams(conversationItemSnapshot.width, conversationItemSnapshot.height)
conversationBubble.background = BitmapDrawable(resources, conversationItemSnapshot)
conversationTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), messageRecord.timestamp)
updateConversationTimestamp(messageRecord)
val isMessageOnLeft = selectedConversationModel.isOutgoing xor ViewUtil.isLtr(this)
conversationItem.scaleX = LONG_PRESS_SCALE_FACTOR
conversationItem.scaleY = LONG_PRESS_SCALE_FACTOR
visibility = INVISIBLE
this.activity = activity
updateSystemUiOnShow(activity)
doOnLayout { showAfterLayout(messageRecord, lastSeenDownPoint, isMessageOnLeft) }
job = scope.launch(Dispatchers.IO) {
repository.changes(messageRecord.threadId)
.filter { mmsSmsDatabase.getMessageForTimestamp(messageRecord.timestamp) == null }
.collect { withContext(Dispatchers.Main) { hide() } }
}
}
private fun updateConversationTimestamp(message: MessageRecord) {
if (message.isOutgoing) conversationBubble.bringToFront() else conversationTimestamp.bringToFront()
}
private fun showAfterLayout(messageRecord: MessageRecord,
lastSeenDownPoint: PointF,
isMessageOnLeft: Boolean) {
val contextMenu = ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord))
this.contextMenu = contextMenu
var endX = if (isMessageOnLeft) scrubberHorizontalMargin.toFloat() else selectedConversationModel.bubbleX - conversationItem.width + selectedConversationModel.bubbleWidth
var endY = selectedConversationModel.bubbleY - statusBarHeight
conversationItem.x = endX
conversationItem.y = endY
val conversationItemSnapshot = selectedConversationModel.bitmap
val isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < width
val overlayHeight = height
val bubbleWidth = selectedConversationModel.bubbleWidth
var endApparentTop = endY
var endScale = 1f
val menuPadding = DimensionUnit.DP.toPixels(12f)
val reactionBarTopPadding = DimensionUnit.DP.toPixels(32f)
val reactionBarHeight = backgroundView.height
var reactionBarBackgroundY: Float
if (isWideLayout) {
val everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + conversationItemSnapshot.height < overlayHeight
if (everythingFitsVertically) {
val reactionBarFitsAboveItem = conversationItem.y > reactionBarHeight + menuPadding + reactionBarTopPadding
if (reactionBarFitsAboveItem) {
reactionBarBackgroundY = conversationItem.y - menuPadding - reactionBarHeight
} else {
endY = reactionBarHeight + menuPadding + reactionBarTopPadding
reactionBarBackgroundY = reactionBarTopPadding
}
} else {
val spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding
endScale = spaceAvailableForItem / conversationItem.height
endX += Util.halfOffsetFromScale(conversationItemSnapshot.width, endScale) * if (isMessageOnLeft) -1 else 1
endY = reactionBarHeight + menuPadding + reactionBarTopPadding - Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale)
reactionBarBackgroundY = reactionBarTopPadding
}
} else {
val reactionBarOffset = DimensionUnit.DP.toPixels(48f)
val spaceForReactionBar = Math.max(reactionBarHeight + reactionBarOffset, 0f)
val everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.height + menuPadding + spaceForReactionBar < overlayHeight
if (everythingFitsVertically) {
val bubbleBottom = selectedConversationModel.bubbleY + conversationItemSnapshot.height
val menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight
if (menuFitsBelowItem) {
if (conversationItem.y < 0) {
endY = 0f
}
val contextMenuTop = endY + conversationItemSnapshot.height
reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.bubbleY, contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY)
if (reactionBarBackgroundY <= reactionBarTopPadding) {
endY = backgroundView.height + menuPadding + reactionBarTopPadding
}
} else {
endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.height
reactionBarBackgroundY = endY - reactionBarHeight - menuPadding
}
endApparentTop = endY
} else if (reactionBarOffset + reactionBarHeight + contextMenu.getMaxHeight() + menuPadding < overlayHeight) {
val spaceAvailableForItem = overlayHeight.toFloat() - contextMenu.getMaxHeight() - menuPadding - spaceForReactionBar
endScale = spaceAvailableForItem / conversationItemSnapshot.height
endX += Util.halfOffsetFromScale(conversationItemSnapshot.width, endScale) * if (isMessageOnLeft) -1 else 1
endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale)
val contextMenuTop = endY + conversationItemSnapshot.height * endScale
reactionBarBackgroundY = reactionBarTopPadding //getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
endApparentTop = endY + Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale)
} else {
contextMenu.height = contextMenu.getMaxHeight() / 2
val menuHeight = contextMenu.height
val fitsVertically = menuHeight + conversationItem.height + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight
if (fitsVertically) {
val bubbleBottom = selectedConversationModel.bubbleY + conversationItemSnapshot.height
val menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + statusBarHeight
if (menuFitsBelowItem) {
reactionBarBackgroundY = conversationItem.y - menuPadding - reactionBarHeight
if (reactionBarBackgroundY < reactionBarTopPadding) {
endY = reactionBarTopPadding + reactionBarHeight + menuPadding
reactionBarBackgroundY = reactionBarTopPadding
}
} else {
endY = overlayHeight - menuHeight - menuPadding - conversationItemSnapshot.height
reactionBarBackgroundY = endY - reactionBarHeight - menuPadding
}
endApparentTop = endY
} else {
val spaceAvailableForItem = overlayHeight.toFloat() - menuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding
endScale = spaceAvailableForItem / conversationItemSnapshot.height
endX += Util.halfOffsetFromScale(conversationItemSnapshot.width, endScale) * if (isMessageOnLeft) -1 else 1
endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) + menuPadding + reactionBarTopPadding
reactionBarBackgroundY = reactionBarTopPadding
endApparentTop = reactionBarHeight + menuPadding + reactionBarTopPadding
}
}
}
reactionBarBackgroundY = Math.max(reactionBarBackgroundY, -statusBarHeight.toFloat())
hideAnimatorSet.end()
visibility = VISIBLE
val scrubberX = if (isMessageOnLeft) {
scrubberHorizontalMargin.toFloat()
} else {
(width - scrubberWidth - scrubberHorizontalMargin).toFloat()
}
foregroundView.x = scrubberX
foregroundView.y = reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.height / 2f
backgroundView.x = scrubberX
backgroundView.y = reactionBarBackgroundY
verticalScrubBoundary.update(reactionBarBackgroundY,
lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone)
updateBoundsOnLayoutChanged()
revealAnimatorSet.start()
if (isWideLayout) {
val scrubberRight = scrubberX + scrubberWidth
val offsetX = if (isMessageOnLeft) scrubberRight + menuPadding else scrubberX - contextMenu.getMaxWidth() - menuPadding
contextMenu.show(offsetX.toInt(), Math.min(backgroundView.y, (overlayHeight - contextMenu.getMaxHeight()).toFloat()).toInt())
} else {
val contentX = if (isMessageOnLeft) scrubberHorizontalMargin.toFloat() else selectedConversationModel.bubbleX
val offsetX = if (isMessageOnLeft) contentX else -contextMenu.getMaxWidth() + contentX + bubbleWidth
val menuTop = endApparentTop + conversationItemSnapshot.height * endScale
contextMenu.show(offsetX.toInt(), (menuTop + menuPadding).toInt())
}
val revealDuration = context.resources.getInteger(R.integer.reaction_scrubber_reveal_duration)
conversationBubble.animate()
.scaleX(endScale)
.scaleY(endScale)
.setDuration(revealDuration.toLong())
conversationItem.animate()
.x(endX)
.y(endY)
.setDuration(revealDuration.toLong())
}
private fun getReactionBarOffsetForTouch(itemY: Float,
contextMenuTop: Float,
contextMenuPadding: Float,
reactionBarOffset: Float,
reactionBarHeight: Int,
spaceNeededBetweenTopOfScreenAndTopOfReactionBar: Float,
messageTop: Float): Float {
val adjustedTouchY = itemY - statusBarHeight
var reactionStartingPoint = Math.min(adjustedTouchY, contextMenuTop)
val spaceBetweenTopOfMessageAndTopOfContextMenu = Math.abs(messageTop - contextMenuTop)
if (spaceBetweenTopOfMessageAndTopOfContextMenu < DimensionUnit.DP.toPixels(150f)) {
val offsetToMakeReactionBarOffsetMatchMenuPadding = reactionBarOffset - contextMenuPadding
reactionStartingPoint = messageTop + offsetToMakeReactionBarOffsetMatchMenuPadding
}
return Math.max(reactionStartingPoint - reactionBarOffset - reactionBarHeight, spaceNeededBetweenTopOfScreenAndTopOfReactionBar)
}
private fun updateSystemUiOnShow(activity: Activity) {
val window = activity.window
val barColor = ContextCompat.getColor(context, R.color.reactions_screen_dark_shade_color)
originalStatusBarColor = window.statusBarColor
WindowUtil.setStatusBarColor(window, barColor)
originalNavigationBarColor = window.navigationBarColor
WindowUtil.setNavigationBarColor(window, barColor)
if (!ThemeUtil.isDarkTheme(context)) {
WindowUtil.clearLightStatusBar(window)
WindowUtil.clearLightNavigationBar(window)
}
}
fun hide() {
hideInternal(onHideListener)
}
fun hideForReactWithAny() {
hideInternal(onHideListener)
}
private fun hideInternal(onHideListener: OnHideListener?) {
job?.cancel()
overlayState = OverlayState.HIDDEN
val animatorSet = newHideAnimatorSet()
hideAnimatorSet = animatorSet
revealAnimatorSet.end()
animatorSet.start()
onHideListener?.startHide()
selectedConversationModel.focusedView?.let(ViewUtil::focusAndShowKeyboard)
animatorSet.addListener(object : AnimationCompleteListener() {
override fun onAnimationEnd(animation: Animator) {
animatorSet.removeListener(this)
onHideListener?.onHide()
}
})
contextMenu?.dismiss()
}
val isShowing: Boolean
get() = overlayState != OverlayState.HIDDEN
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
super.onLayout(changed, l, t, r, b)
updateBoundsOnLayoutChanged()
}
private fun updateBoundsOnLayoutChanged() {
backgroundView.getGlobalVisibleRect(emojiStripViewBounds)
emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect)
emojiStripViewBounds.left = getStart(emojiViewGlobalRect)
emojiViews[emojiViews.size - 1].getGlobalVisibleRect(emojiViewGlobalRect)
emojiStripViewBounds.right = getEnd(emojiViewGlobalRect)
segmentSize = emojiStripViewBounds.width() / emojiViews.size.toFloat()
}
private fun getStart(rect: Rect): Int = if (ViewUtil.isLtr(this)) rect.left else rect.right
private fun getEnd(rect: Rect): Int = if (ViewUtil.isLtr(this)) rect.right else rect.left
fun applyTouchEvent(motionEvent: MotionEvent): Boolean {
check(isShowing) { "Touch events should only be propagated to this method if we are displaying the scrubber." }
if (motionEvent.action and MotionEvent.ACTION_POINTER_INDEX_MASK != 0) {
return true
}
if (overlayState == OverlayState.UNINITAILIZED) {
downIsOurs = false
deadzoneTouchPoint[motionEvent.x] = motionEvent.y
overlayState = OverlayState.DEADZONE
}
if (overlayState == OverlayState.DEADZONE) {
val deltaX = Math.abs(deadzoneTouchPoint.x - motionEvent.x)
val deltaY = Math.abs(deadzoneTouchPoint.y - motionEvent.y)
if (deltaX > touchDownDeadZoneSize || deltaY > touchDownDeadZoneSize) {
overlayState = OverlayState.SCRUB
} else {
if (motionEvent.action == MotionEvent.ACTION_UP) {
overlayState = OverlayState.TAP
if (downIsOurs) {
handleUpEvent()
return true
}
}
return MotionEvent.ACTION_MOVE == motionEvent.action
}
}
return when (motionEvent.action) {
MotionEvent.ACTION_DOWN -> {
selected = getSelectedIndexViaDownEvent(motionEvent)
deadzoneTouchPoint[motionEvent.x] = motionEvent.y
overlayState = OverlayState.DEADZONE
downIsOurs = true
true
}
MotionEvent.ACTION_MOVE -> {
selected = getSelectedIndexViaMoveEvent(motionEvent)
true
}
MotionEvent.ACTION_UP -> {
handleUpEvent()
downIsOurs
}
MotionEvent.ACTION_CANCEL -> {
hide()
downIsOurs
}
else -> false
}
}
private fun setupSelectedEmoji() {
val emojis = recentEmojiPageModel.emoji
emojiViews.forEachIndexed { i, view ->
view.scaleX = 1.0f
view.scaleY = 1.0f
view.translationY = 0f
val isAtCustomIndex = i == customEmojiIndex
if (isAtCustomIndex) {
view.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_baseline_add_24))
view.tag = null
} else {
view.setImageEmoji(emojis[i])
}
}
}
private fun getSelectedIndexViaDownEvent(motionEvent: MotionEvent): Int =
getSelectedIndexViaMotionEvent(motionEvent, Boundary(emojiStripViewBounds.top.toFloat(), emojiStripViewBounds.bottom.toFloat()))
private fun getSelectedIndexViaMoveEvent(motionEvent: MotionEvent): Int =
getSelectedIndexViaMotionEvent(motionEvent, verticalScrubBoundary)
private fun getSelectedIndexViaMotionEvent(motionEvent: MotionEvent, boundary: Boundary): Int {
var selected = -1
if (backgroundView.visibility != VISIBLE) {
return selected
}
for (i in emojiViews.indices) {
val emojiLeft = segmentSize * i + emojiStripViewBounds.left
horizontalEmojiBoundary.update(emojiLeft, emojiLeft + segmentSize)
if (horizontalEmojiBoundary.contains(motionEvent.x) && boundary.contains(motionEvent.y)) {
selected = i
}
}
if (this.selected != -1 && this.selected != selected) {
shrinkView(emojiViews[this.selected])
}
if (this.selected != selected && selected != -1) {
growView(emojiViews[selected])
}
return selected
}
private fun growView(view: View) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
view.animate()
.scaleY(1.5f)
.scaleX(1.5f)
.translationY(-selectedVerticalTranslation.toFloat())
.setDuration(200)
.setInterpolator(INTERPOLATOR)
.start()
}
private fun shrinkView(view: View) {
view.animate()
.scaleX(1.0f)
.scaleY(1.0f)
.translationY(0f)
.setDuration(200)
.setInterpolator(INTERPOLATOR)
.start()
}
private fun handleUpEvent() {
val onReactionSelectedListener = onReactionSelectedListener
if (selected != -1 && onReactionSelectedListener != null && backgroundView.visibility == VISIBLE) {
if (selected == customEmojiIndex) {
onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].tag != null)
} else {
onReactionSelectedListener.onReactionSelected(messageRecord, recentEmojiPageModel.emoji[selected])
}
} else {
hide()
}
}
fun setOnReactionSelectedListener(onReactionSelectedListener: OnReactionSelectedListener?) {
this.onReactionSelectedListener = onReactionSelectedListener
}
fun setOnActionSelectedListener(onActionSelectedListener: OnActionSelectedListener?) {
this.onActionSelectedListener = onActionSelectedListener
}
fun setOnHideListener(onHideListener: OnHideListener?) {
this.onHideListener = onHideListener
}
private fun getOldEmoji(messageRecord: MessageRecord): String? =
messageRecord.reactions
.filter { it.author == getLocalNumber(context) }
.firstOrNull()
?.let(ReactionRecord::emoji)
private fun getMenuActionItems(message: MessageRecord): List<ActionItem> {
val items: MutableList<ActionItem> = ArrayList()
// Prepare
val containsControlMessage = message.isUpdate
val hasText = !message.body.isEmpty()
val openGroup = get(context).lokiThreadDatabase().getOpenGroupChat(message.threadId)
val recipient = get(context).threadDatabase().getRecipientForThreadId(message.threadId)
?: return emptyList()
val userPublicKey = getLocalNumber(context)!!
// Select message
items += ActionItem(R.attr.menu_select_icon, R.string.conversation_context__menu_select, { handleActionItemClicked(Action.SELECT) }, R.string.AccessibilityId_select)
// Reply
val canWrite = openGroup == null || openGroup.canWrite
if (canWrite && !message.isPending && !message.isFailed && !message.isOpenGroupInvitation) {
items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_reply, { handleActionItemClicked(Action.REPLY) }, R.string.AccessibilityId_reply_message)
}
// Copy message text
if (!containsControlMessage && hasText) {
items += ActionItem(R.attr.menu_copy_icon, R.string.copy, { handleActionItemClicked(Action.COPY_MESSAGE) })
}
// Copy Session ID
if (recipient.isGroupRecipient && !recipient.isCommunityRecipient && message.recipient.address.toString() != userPublicKey) {
items += ActionItem(R.attr.menu_copy_icon, R.string.activity_conversation_menu_copy_session_id, { handleActionItemClicked(Action.COPY_SESSION_ID) })
}
// Delete message
if (userCanDeleteSelectedItems(context, message, openGroup, userPublicKey, blindedPublicKey)) {
items += ActionItem(R.attr.menu_trash_icon, R.string.delete, { handleActionItemClicked(Action.DELETE) }, R.string.AccessibilityId_delete_message, message.subtitle, R.color.destructive)
}
// Ban user
if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) {
items += ActionItem(R.attr.menu_block_icon, R.string.conversation_context__menu_ban_user, { handleActionItemClicked(Action.BAN_USER) })
}
// Ban and delete all
if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) {
items += ActionItem(R.attr.menu_trash_icon, R.string.conversation_context__menu_ban_and_delete_all, { handleActionItemClicked(Action.BAN_AND_DELETE_ALL) })
}
// Message detail
items += ActionItem(R.attr.menu_info_icon, R.string.conversation_context__menu_message_details, { handleActionItemClicked(Action.VIEW_INFO) })
// Resend
if (message.isFailed) {
items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_resend_message, { handleActionItemClicked(Action.RESEND) })
}
// Resync
if (message.isSyncFailed) {
items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_resync_message, { handleActionItemClicked(Action.RESYNC) })
}
// Save media
if (message.isMms && (message as MediaMmsMessageRecord).containsMediaSlide()) {
items += ActionItem(R.attr.menu_save_icon, R.string.conversation_context_image__save_attachment, { handleActionItemClicked(Action.DOWNLOAD) }, R.string.AccessibilityId_save_attachment)
}
backgroundView.visibility = VISIBLE
foregroundView.visibility = VISIBLE
return items
}
private fun handleActionItemClicked(action: Action) {
hideInternal(object : OnHideListener {
override fun startHide() {
onHideListener?.startHide()
}
override fun onHide() {
onHideListener?.onHide()
onActionSelectedListener?.onActionSelected(action)
}
})
}
private fun initAnimators() {
val revealDuration = context.resources.getInteger(R.integer.reaction_scrubber_reveal_duration)
val revealOffset = context.resources.getInteger(R.integer.reaction_scrubber_reveal_offset)
val reveals = emojiViews.mapIndexed { idx: Int, v: EmojiImageView? ->
AnimatorInflaterCompat.loadAnimator(context, R.animator.reactions_scrubber_reveal).apply {
setTarget(v)
startDelay = (idx * animationEmojiStartDelayFactor).toLong()
}
} + AnimatorInflaterCompat.loadAnimator(context, android.R.animator.fade_in).apply {
setTarget(backgroundView)
setDuration(revealDuration.toLong())
startDelay = revealOffset.toLong()
}
revealAnimatorSet.interpolator = INTERPOLATOR
revealAnimatorSet.playTogether(reveals)
}
private fun newHideAnimatorSet() = AnimatorSet().apply {
addListener(object : AnimationCompleteListener() {
override fun onAnimationEnd(animation: Animator) {
visibility = GONE
}
})
interpolator = INTERPOLATOR
playTogether(newHideAnimators())
}
private fun newHideAnimators(): List<Animator> {
val duration = context.resources.getInteger(R.integer.reaction_scrubber_hide_duration).toLong()
fun conversationItemAnimator(configure: ObjectAnimator.() -> Unit) = ObjectAnimator().apply {
target = conversationItem
setDuration(duration)
configure()
}
return emojiViews.map {
AnimatorInflaterCompat.loadAnimator(context, R.animator.reactions_scrubber_hide).apply { setTarget(it) }
} + AnimatorInflaterCompat.loadAnimator(context, android.R.animator.fade_out).apply {
setTarget(backgroundView)
setDuration(duration)
} + conversationItemAnimator {
setProperty(SCALE_X)
setFloatValues(1f)
} + conversationItemAnimator {
setProperty(SCALE_Y)
setFloatValues(1f)
} + conversationItemAnimator {
setProperty(X)
setFloatValues(selectedConversationModel.bubbleX)
} + conversationItemAnimator {
setProperty(Y)
setFloatValues(selectedConversationModel.bubbleY - statusBarHeight)
} + ValueAnimator.ofArgb(activity.window.statusBarColor, originalStatusBarColor).apply {
setDuration(duration)
addUpdateListener { animation: ValueAnimator -> WindowUtil.setStatusBarColor(activity.window, animation.animatedValue as Int) }
} + ValueAnimator.ofArgb(activity.window.statusBarColor, originalNavigationBarColor).apply {
setDuration(duration)
addUpdateListener { animation: ValueAnimator -> WindowUtil.setNavigationBarColor(activity.window, animation.animatedValue as Int) }
}
}
interface OnHideListener {
fun startHide()
fun onHide()
}
interface OnReactionSelectedListener {
fun onReactionSelected(messageRecord: MessageRecord, emoji: String)
fun onCustomReactionSelected(messageRecord: MessageRecord, hasAddedCustomEmoji: Boolean)
}
interface OnActionSelectedListener {
fun onActionSelected(action: Action)
}
private class Boundary(private var min: Float = 0f, private var max: Float = 0f) {
fun update(min: Float, max: Float) {
this.min = min
this.max = max
}
operator fun contains(value: Float) = if (min < max) {
min < value && max > value
} else {
min > value && max < value
}
}
private enum class OverlayState {
HIDDEN,
UNINITAILIZED,
DEADZONE,
SCRUB,
TAP
}
enum class Action {
REPLY,
RESEND,
RESYNC,
DOWNLOAD,
COPY_MESSAGE,
COPY_SESSION_ID,
VIEW_INFO,
SELECT,
DELETE,
BAN_USER,
BAN_AND_DELETE_ALL
}
companion object {
const val LONG_PRESS_SCALE_FACTOR = 0.95f
private val INTERPOLATOR: Interpolator = DecelerateInterpolator()
}
}
private fun Duration.to2partString(): String? =
toComponents { days, hours, minutes, seconds, nanoseconds -> listOf(days.days, hours.hours, minutes.minutes, seconds.seconds) }
.filter { it.inWholeSeconds > 0L }.take(2).takeIf { it.isNotEmpty() }?.joinToString(" ")
private val MessageRecord.subtitle: ((Context) -> CharSequence?)?
get() = if (expiresIn <= 0) {
null
} else { context ->
(expiresIn - (SnodeAPI.nowWithOffset - (expireStarted.takeIf { it > 0 } ?: timestamp)))
.coerceAtLeast(0L)
.milliseconds
.to2partString()
?.let { context.getString(R.string.auto_deletes_in, it) }
}

View File

@@ -1,36 +1,44 @@
package org.thoughtcrime.securesms.conversation.v2 package org.thoughtcrime.securesms.conversation.v2
import android.content.ContentResolver import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.cash.copper.flow.observeQuery
import com.goterl.lazysodium.utils.KeyPair import com.goterl.lazysodium.utils.KeyPair
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.audio.AudioSlidePlayer
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
import java.util.UUID import java.util.UUID
class ConversationViewModel( class ConversationViewModel(
val threadId: Long, val threadId: Long,
val edKeyPair: KeyPair?, val edKeyPair: KeyPair?,
private val contentResolver: ContentResolver,
private val repository: ConversationRepository, private val repository: ConversationRepository,
private val storage: Storage private val storage: Storage
) : ViewModel() { ) : ViewModel() {
@@ -44,9 +52,21 @@ class ConversationViewModel(
private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce { private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce {
repository.maybeGetRecipientForThreadId(threadId) repository.maybeGetRecipientForThreadId(threadId)
} }
val expirationConfiguration: ExpirationConfiguration?
get() = storage.getExpirationConfiguration(threadId)
val recipient: Recipient? val recipient: Recipient?
get() = _recipient.value get() = _recipient.value
val blindedRecipient: Recipient?
get() = _recipient.value?.let { recipient ->
when {
recipient.isOpenGroupOutboxRecipient -> recipient
recipient.isOpenGroupInboxRecipient -> repository.maybeGetBlindedRecipient(recipient)
else -> null
}
}
private var _openGroup: RetrieveOnce<OpenGroup> = RetrieveOnce { private var _openGroup: RetrieveOnce<OpenGroup> = RetrieveOnce {
storage.getOpenGroup(threadId) storage.getOpenGroup(threadId)
} }
@@ -62,18 +82,35 @@ class ConversationViewModel(
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString ?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
} }
val isMessageRequestThread : Boolean
get() {
val recipient = recipient ?: return false
return !recipient.isLocalNumber && !recipient.isGroupRecipient && !recipient.isApproved
}
val canReactToMessages: Boolean
// allow reactions if the open group is null (normal conversations) or the open group's capabilities include reactions
get() = (openGroup == null || OpenGroupApi.Capability.REACTIONS.name.lowercase() in serverCapabilities)
init { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
contentResolver.observeQuery(DatabaseContentProviders.Conversation.getUriForThread(threadId)) repository.recipientUpdateFlow(threadId)
.collect { .collect { recipient ->
val recipientExists = storage.getRecipientForThread(threadId) != null if (recipient == null && _uiState.value.conversationExists) {
if (!recipientExists && _uiState.value.conversationExists) {
_uiState.update { it.copy(conversationExists = false) } _uiState.update { it.copy(conversationExists = false) }
} }
} }
} }
} }
override fun onCleared() {
super.onCleared()
// Stop all voice message when exiting this page
AudioSlidePlayer.stopAll()
}
fun saveDraft(text: String) { fun saveDraft(text: String) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
repository.saveDraft(threadId, text) repository.saveDraft(threadId, text)
@@ -113,19 +150,36 @@ class ConversationViewModel(
} }
fun deleteLocally(message: MessageRecord) { fun deleteLocally(message: MessageRecord) {
stopPlayingAudioMessage(message)
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for delete locally action") val recipient = recipient ?: return Log.w("Loki", "Recipient was null for delete locally action")
repository.deleteLocally(recipient, message) repository.deleteLocally(recipient, message)
} }
/**
* Stops audio player if its current playing is the one given in the message.
*/
private fun stopPlayingAudioMessage(message: MessageRecord) {
val mmsMessage = message as? MmsMessageRecord ?: return
val audioSlide = mmsMessage.slideDeck.audioSlide ?: return
AudioSlidePlayer.getInstance()?.takeIf { it.audioSlide == audioSlide }?.stop()
}
fun setRecipientApproved() { fun setRecipientApproved() {
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for set approved action") val recipient = recipient ?: return Log.w("Loki", "Recipient was null for set approved action")
repository.setApproved(recipient, true) repository.setApproved(recipient, true)
} }
fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch { fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch {
val recipient = recipient ?: return@launch val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.")
stopPlayingAudioMessage(message)
repository.deleteForEveryone(threadId, recipient, message) repository.deleteForEveryone(threadId, recipient, message)
.onSuccess {
Log.d("Loki", "Deleted message ${message.id} ")
stopPlayingAudioMessage(message)
}
.onFailure { .onFailure {
Log.w("Loki", "FAILED TO delete message ${message.id} ")
showMessage("Couldn't delete message due to error: $it") showMessage("Couldn't delete message due to error: $it")
} }
} }
@@ -147,10 +201,15 @@ class ConversationViewModel(
} }
} }
fun banAndDeleteAll(recipient: Recipient) = viewModelScope.launch { fun banAndDeleteAll(messageRecord: MessageRecord) = viewModelScope.launch {
repository.banAndDeleteAll(threadId, recipient)
repository.banAndDeleteAll(threadId, messageRecord.individualRecipient)
.onSuccess { .onSuccess {
// At this point the server side messages have been successfully deleted..
showMessage("Successfully banned user and deleted all their messages") showMessage("Successfully banned user and deleted all their messages")
// ..so we can now delete all their messages in this thread from local storage & remove the views.
repository.deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord)
} }
.onFailure { .onFailure {
showMessage("Couldn't execute request due to error: $it") showMessage("Couldn't execute request due to error: $it")
@@ -199,22 +258,28 @@ class ConversationViewModel(
_recipient.updateTo(repository.maybeGetRecipientForThreadId(threadId)) _recipient.updateTo(repository.maybeGetRecipientForThreadId(threadId))
} }
fun hidesInputBar(): Boolean = openGroup?.canWrite != true &&
blindedRecipient?.blocksCommunityMessageRequests == true
fun legacyBannerRecipient(context: Context): Recipient? = recipient?.run {
storage.getLastLegacyRecipient(address.serialize())?.let { Recipient.from(context, Address.fromSerialized(it), false) }
}
@dagger.assisted.AssistedFactory @dagger.assisted.AssistedFactory
interface AssistedFactory { interface AssistedFactory {
fun create(threadId: Long, edKeyPair: KeyPair?, contentResolver: ContentResolver): Factory fun create(threadId: Long, edKeyPair: KeyPair?): Factory
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
class Factory @AssistedInject constructor( class Factory @AssistedInject constructor(
@Assisted private val threadId: Long, @Assisted private val threadId: Long,
@Assisted private val edKeyPair: KeyPair?, @Assisted private val edKeyPair: KeyPair?,
@Assisted private val contentResolver: ContentResolver,
private val repository: ConversationRepository, private val repository: ConversationRepository,
private val storage: Storage private val storage: Storage
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ConversationViewModel(threadId, edKeyPair, contentResolver, repository, storage) as T return ConversationViewModel(threadId, edKeyPair, repository, storage) as T
} }
} }
} }

View File

@@ -123,7 +123,7 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
AppTheme { AppTheme {
MessageDetails( MessageDetails(
state = state, state = state,
onReply = { setResultAndFinish(ON_REPLY) }, onReply = if (state.canReply) { { setResultAndFinish(ON_REPLY) } } else null,
onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } }, onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } },
onDelete = { setResultAndFinish(ON_DELETE) }, onDelete = { setResultAndFinish(ON_DELETE) },
onClickImage = { viewModel.onClickImage(it) }, onClickImage = { viewModel.onClickImage(it) },
@@ -145,7 +145,7 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
@Composable @Composable
fun MessageDetails( fun MessageDetails(
state: MessageDetailsState, state: MessageDetailsState,
onReply: () -> Unit = {}, onReply: (() -> Unit)? = null,
onResend: (() -> Unit)? = null, onResend: (() -> Unit)? = null,
onDelete: () -> Unit = {}, onDelete: () -> Unit = {},
onClickImage: (Int) -> Unit = {}, onClickImage: (Int) -> Unit = {},
@@ -214,18 +214,20 @@ fun CellMetadata(
@Composable @Composable
fun CellButtons( fun CellButtons(
onReply: () -> Unit = {}, onReply: (() -> Unit)? = null,
onResend: (() -> Unit)? = null, onResend: (() -> Unit)? = null,
onDelete: () -> Unit = {}, onDelete: () -> Unit = {},
) { ) {
Cell { Cell {
Column { Column {
ItemButton( onReply?.let {
stringResource(R.string.reply), ItemButton(
R.drawable.ic_message_details__reply, stringResource(R.string.reply),
onClick = onReply R.drawable.ic_message_details__reply,
) onClick = it
Divider() )
Divider()
}
onResend?.let { onResend?.let {
ItemButton( ItemButton(
stringResource(R.string.resend), stringResource(R.string.resend),

View File

@@ -5,9 +5,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
@@ -26,6 +28,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.mms.ImageSlide import org.thoughtcrime.securesms.mms.ImageSlide
import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.TitledText import org.thoughtcrime.securesms.ui.TitledText
import java.util.Date import java.util.Date
@@ -38,8 +41,11 @@ class MessageDetailsViewModel @Inject constructor(
private val lokiMessageDatabase: LokiMessageDatabase, private val lokiMessageDatabase: LokiMessageDatabase,
private val mmsSmsDatabase: MmsSmsDatabase, private val mmsSmsDatabase: MmsSmsDatabase,
private val threadDb: ThreadDatabase, private val threadDb: ThreadDatabase,
private val repository: ConversationRepository,
) : ViewModel() { ) : ViewModel() {
private var job: Job? = null
private val state = MutableStateFlow(MessageDetailsState()) private val state = MutableStateFlow(MessageDetailsState())
val stateFlow = state.asStateFlow() val stateFlow = state.asStateFlow()
@@ -48,6 +54,8 @@ class MessageDetailsViewModel @Inject constructor(
var timestamp: Long = 0L var timestamp: Long = 0L
set(value) { set(value) {
job?.cancel()
field = value field = value
val record = mmsSmsDatabase.getMessageForTimestamp(timestamp) val record = mmsSmsDatabase.getMessageForTimestamp(timestamp)
@@ -58,6 +66,12 @@ class MessageDetailsViewModel @Inject constructor(
val mmsRecord = record as? MmsMessageRecord val mmsRecord = record as? MmsMessageRecord
job = viewModelScope.launch {
repository.changes(record.threadId)
.filter { mmsSmsDatabase.getMessageForTimestamp(value) == null }
.collect { event.send(Event.Finish) }
}
state.value = record.run { state.value = record.run {
val slides = mmsRecord?.slideDeck?.slides ?: emptyList() val slides = mmsRecord?.slideDeck?.slides ?: emptyList()
@@ -103,7 +117,7 @@ class MessageDetailsViewModel @Inject constructor(
Attachment(slide.details, slide.fileName.orNull(), slide.uri, slide is ImageSlide) Attachment(slide.details, slide.fileName.orNull(), slide.uri, slide is ImageSlide)
fun onClickImage(index: Int) { fun onClickImage(index: Int) {
val state = state.value ?: return val state = state.value
val mmsRecord = state.mmsRecord ?: return val mmsRecord = state.mmsRecord ?: return
val slide = mmsRecord.slideDeck.slides[index] ?: return val slide = mmsRecord.slideDeck.slides[index] ?: return
// only open to downloaded images // only open to downloaded images
@@ -144,6 +158,7 @@ data class MessageDetailsState(
val thread: Recipient? = null, val thread: Recipient? = null,
) { ) {
val fromTitle = GetString(R.string.message_details_header__from) val fromTitle = GetString(R.string.message_details_header__from)
val canReply = record?.isOpenGroupInvitation != true
} }
data class Attachment( data class Attachment(

View File

@@ -7,7 +7,6 @@ import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.RelativeLayout import android.widget.RelativeLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.view.children import androidx.core.view.children
@@ -41,7 +40,7 @@ class AlbumThumbnailView : RelativeLayout {
private var slides: List<Slide> = listOf() private var slides: List<Slide> = listOf()
private var slideSize: Int = 0 private var slideSize: Int = 0
override fun dispatchDraw(canvas: Canvas?) { override fun dispatchDraw(canvas: Canvas) {
super.dispatchDraw(canvas) super.dispatchDraw(canvas)
cornerMask.mask(canvas) cornerMask.mask(canvas)
} }

View File

@@ -1,129 +0,0 @@
package org.thoughtcrime.securesms.conversation.v2.components;
import android.content.Context;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.session.libsession.utilities.Util;
import java.lang.ref.WeakReference;
import java.util.concurrent.TimeUnit;
import network.loki.messenger.R;
public class ExpirationTimerView extends androidx.appcompat.widget.AppCompatImageView {
private long startedAt;
private long expiresIn;
private boolean visible = false;
private boolean stopped = true;
private final int[] frames = new int[]{ R.drawable.timer00,
R.drawable.timer05,
R.drawable.timer10,
R.drawable.timer15,
R.drawable.timer20,
R.drawable.timer25,
R.drawable.timer30,
R.drawable.timer35,
R.drawable.timer40,
R.drawable.timer45,
R.drawable.timer50,
R.drawable.timer55,
R.drawable.timer60 };
public ExpirationTimerView(Context context) {
super(context);
}
public ExpirationTimerView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public ExpirationTimerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void setExpirationTime(long startedAt, long expiresIn) {
this.startedAt = startedAt;
this.expiresIn = expiresIn;
setPercentComplete(calculateProgress(this.startedAt, this.expiresIn));
}
public void setPercentComplete(float percentage) {
float percentFull = 1 - percentage;
int frame = (int) Math.ceil(percentFull * (frames.length - 1));
frame = Math.max(0, Math.min(frame, frames.length - 1));
setImageResource(frames[frame]);
}
public void startAnimation() {
synchronized (this) {
visible = true;
if (!stopped) return;
else stopped = false;
}
Util.runOnMainDelayed(new AnimationUpdateRunnable(this), calculateAnimationDelay(this.startedAt, this.expiresIn));
}
public void stopAnimation() {
synchronized (this) {
visible = false;
}
}
private float calculateProgress(long startedAt, long expiresIn) {
long progressed = System.currentTimeMillis() - startedAt;
float percentComplete = (float)progressed / (float)expiresIn;
return Math.max(0, Math.min(percentComplete, 1));
}
private long calculateAnimationDelay(long startedAt, long expiresIn) {
long progressed = System.currentTimeMillis() - startedAt;
long remaining = expiresIn - progressed;
if (remaining <= 0) {
return 0;
} else if (remaining < TimeUnit.SECONDS.toMillis(30)) {
return 1000;
} else {
return 5000;
}
}
private static class AnimationUpdateRunnable implements Runnable {
private final WeakReference<ExpirationTimerView> expirationTimerViewReference;
private AnimationUpdateRunnable(@NonNull ExpirationTimerView expirationTimerView) {
this.expirationTimerViewReference = new WeakReference<>(expirationTimerView);
}
@Override
public void run() {
ExpirationTimerView timerView = expirationTimerViewReference.get();
if (timerView == null) return;
long nextUpdate = timerView.calculateAnimationDelay(timerView.startedAt, timerView.expiresIn);
synchronized (timerView) {
if (timerView.visible) {
timerView.setExpirationTime(timerView.startedAt, timerView.expiresIn);
} else {
timerView.stopped = true;
return;
}
if (nextUpdate <= 0) {
timerView.stopped = true;
return;
}
}
Util.runOnMainDelayed(this, nextUpdate);
}
}
}

View File

@@ -0,0 +1,61 @@
package org.thoughtcrime.securesms.conversation.v2.components
import android.content.Context
import android.graphics.drawable.AnimationDrawable
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.ContextCompat
import network.loki.messenger.R
import org.session.libsession.snode.SnodeAPI.nowWithOffset
import kotlin.math.round
class ExpirationTimerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
private val frames = intArrayOf(
R.drawable.timer00,
R.drawable.timer05,
R.drawable.timer10,
R.drawable.timer15,
R.drawable.timer20,
R.drawable.timer25,
R.drawable.timer30,
R.drawable.timer35,
R.drawable.timer40,
R.drawable.timer45,
R.drawable.timer50,
R.drawable.timer55,
R.drawable.timer60
)
fun setTimerIcon() {
setExpirationTime(0L, 0L)
}
fun setExpirationTime(startedAt: Long, expiresIn: Long) {
if (expiresIn == 0L) {
setImageResource(R.drawable.timer55)
return
}
if (startedAt == 0L) {
// timer has not started
setImageResource(R.drawable.timer60)
return
}
val elapsedTime = nowWithOffset - startedAt
val remainingTime = expiresIn - elapsedTime
val remainingPercent = (remainingTime / expiresIn.toFloat()).coerceIn(0f, 1f)
val frameCount = round(frames.size * remainingPercent).toInt().coerceIn(1, frames.size)
val frameTime = round(remainingTime / frameCount.toFloat()).toInt()
AnimationDrawable().apply {
frames.take(frameCount).reversed().forEach { addFrame(ContextCompat.getDrawable(context, it)!!, frameTime) }
isOneShot = true
}.also(::setImageDrawable).apply(AnimationDrawable::start)
}
}

View File

@@ -38,6 +38,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
private val vMargin by lazy { toDp(4, resources) } private val vMargin by lazy { toDp(4, resources) }
private val minHeight by lazy { toPx(56, resources) } private val minHeight by lazy { toPx(56, resources) }
private var linkPreviewDraftView: LinkPreviewDraftView? = null private var linkPreviewDraftView: LinkPreviewDraftView? = null
private var quoteView: QuoteView? = null
var delegate: InputBarDelegate? = null var delegate: InputBarDelegate? = null
var additionalContentHeight = 0 var additionalContentHeight = 0
var quote: MessageRecord? = null var quote: MessageRecord? = null
@@ -99,7 +100,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_NONE binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_NONE
binding.inputBarEditText.inputType = binding.inputBarEditText.inputType =
binding.inputBarEditText.inputType or binding.inputBarEditText.inputType or
InputType.TYPE_TEXT_FLAG_CAP_SENTENCES InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
} }
val incognitoFlag = if (TextSecurePreferences.isIncognitoKeyboardEnabled(context)) 16777216 else 0 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.imeOptions = binding.inputBarEditText.imeOptions or incognitoFlag // Always use incognito keyboard if setting enabled
@@ -139,53 +140,66 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
delegate?.startRecordingVoiceMessage() delegate?.startRecordingVoiceMessage()
} }
// Drafting quotes and drafting link previews is mutually exclusive, i.e. you can't draft
// a quote and a link preview at the same time.
fun draftQuote(thread: Recipient, message: MessageRecord, glide: GlideRequests) { fun draftQuote(thread: Recipient, message: MessageRecord, glide: GlideRequests) {
quote = message quoteView?.let(binding.inputBarAdditionalContentContainer::removeView)
linkPreview = null
linkPreviewDraftView = null
binding.inputBarAdditionalContentContainer.removeAllViews()
// inflate quoteview with typed array here quote = message
// If we already have a link preview View then clear the 'additional content' layout so that
// our quote View is always the first element (i.e., at the top of the reply).
if (linkPreview != null && linkPreviewDraftView != null) {
binding.inputBarAdditionalContentContainer.removeAllViews()
}
// Inflate quote View with typed array here
val layout = LayoutInflater.from(context).inflate(R.layout.view_quote_draft, binding.inputBarAdditionalContentContainer, false) val layout = LayoutInflater.from(context).inflate(R.layout.view_quote_draft, binding.inputBarAdditionalContentContainer, false)
val quoteView = layout.findViewById<QuoteView>(R.id.mainQuoteViewContainer) quoteView = layout.findViewById<QuoteView>(R.id.mainQuoteViewContainer).also {
quoteView.delegate = this it.delegate = this
binding.inputBarAdditionalContentContainer.addView(layout) binding.inputBarAdditionalContentContainer.addView(layout)
val attachments = (message as? MmsMessageRecord)?.slideDeck val attachments = (message as? MmsMessageRecord)?.slideDeck
val sender = if (message.isOutgoing) TextSecurePreferences.getLocalNumber(context)!! else message.individualRecipient.address.serialize() val sender = if (message.isOutgoing) TextSecurePreferences.getLocalNumber(context)!! else message.individualRecipient.address.serialize()
quoteView.bind(sender, message.body, attachments, it.bind(sender, message.body, attachments, thread, true, message.isOpenGroupInvitation, message.threadId, false, glide)
thread, true, message.isOpenGroupInvitation, message.threadId, false, glide) }
// Before we request a layout update we'll add back any LinkPreviewDraftView that might
// exist - as this goes into the LinearLayout second it will be below the quote View.
if (linkPreview != null && linkPreviewDraftView != null) {
binding.inputBarAdditionalContentContainer.addView(linkPreviewDraftView)
}
requestLayout() requestLayout()
} }
override fun cancelQuoteDraft() { override fun cancelQuoteDraft() {
binding.inputBarAdditionalContentContainer.removeView(quoteView)
quote = null quote = null
binding.inputBarAdditionalContentContainer.removeAllViews() quoteView = null
requestLayout() requestLayout()
} }
fun draftLinkPreview() { fun draftLinkPreview() {
quote = null // As `draftLinkPreview` is called before `updateLinkPreview` when we modify a URI in a
binding.inputBarAdditionalContentContainer.removeAllViews() // message we'll bail early if a link preview View already exists and just let
val linkPreviewDraftView = LinkPreviewDraftView(context) // `updateLinkPreview` get called to update the existing View.
linkPreviewDraftView.delegate = this if (linkPreview != null && linkPreviewDraftView != null) return
this.linkPreviewDraftView = linkPreviewDraftView linkPreviewDraftView?.let(binding.inputBarAdditionalContentContainer::removeView)
linkPreviewDraftView = LinkPreviewDraftView(context).also { it.delegate = this }
// Add the link preview View. Note: If there's already a quote View in the 'additional
// content' container then this preview View will be added after / below it - which is fine.
binding.inputBarAdditionalContentContainer.addView(linkPreviewDraftView) binding.inputBarAdditionalContentContainer.addView(linkPreviewDraftView)
requestLayout() requestLayout()
} }
fun updateLinkPreviewDraft(glide: GlideRequests, linkPreview: LinkPreview) { fun updateLinkPreviewDraft(glide: GlideRequests, updatedLinkPreview: LinkPreview) {
this.linkPreview = linkPreview // Update our `linkPreview` property with the new (provided as an argument to this function)
val linkPreviewDraftView = this.linkPreviewDraftView ?: return // then update the View from that.
linkPreviewDraftView.update(glide, linkPreview) linkPreview = updatedLinkPreview.also { linkPreviewDraftView?.update(glide, it) }
} }
override fun cancelLinkPreviewDraft() { override fun cancelLinkPreviewDraft() {
if (quote != null) { return } binding.inputBarAdditionalContentContainer.removeView(linkPreviewDraftView)
linkPreview = null linkPreview = null
binding.inputBarAdditionalContentContainer.removeAllViews() linkPreviewDraftView = null
requestLayout() requestLayout()
} }

View File

@@ -4,8 +4,6 @@ import android.animation.FloatEvaluator
import android.animation.IntEvaluator import android.animation.IntEvaluator
import android.animation.ValueAnimator import android.animation.ValueAnimator
import android.content.Context import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.widget.ImageView import android.widget.ImageView
@@ -14,6 +12,11 @@ import android.widget.RelativeLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewInputBarRecordingBinding import network.loki.messenger.databinding.ViewInputBarRecordingBinding
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
@@ -25,10 +28,10 @@ import java.util.Date
class InputBarRecordingView : RelativeLayout { class InputBarRecordingView : RelativeLayout {
private lateinit var binding: ViewInputBarRecordingBinding private lateinit var binding: ViewInputBarRecordingBinding
private var startTimestamp = 0L private var startTimestamp = 0L
private val snHandler = Handler(Looper.getMainLooper())
private var dotViewAnimation: ValueAnimator? = null private var dotViewAnimation: ValueAnimator? = null
private var pulseAnimation: ValueAnimator? = null private var pulseAnimation: ValueAnimator? = null
var delegate: InputBarRecordingViewDelegate? = null var delegate: InputBarRecordingViewDelegate? = null
private var timerJob: Job? = null
val lockView: LinearLayout val lockView: LinearLayout
get() = binding.lockView get() = binding.lockView
@@ -50,9 +53,10 @@ class InputBarRecordingView : RelativeLayout {
binding = ViewInputBarRecordingBinding.inflate(LayoutInflater.from(context), this, true) binding = ViewInputBarRecordingBinding.inflate(LayoutInflater.from(context), this, true)
binding.inputBarMiddleContentContainer.disableClipping() binding.inputBarMiddleContentContainer.disableClipping()
binding.inputBarCancelButton.setOnClickListener { hide() } binding.inputBarCancelButton.setOnClickListener { hide() }
} }
fun show() { fun show(scope: CoroutineScope) {
startTimestamp = Date().time startTimestamp = Date().time
binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_microphone, context.theme)) binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_microphone, context.theme))
binding.inputBarCancelButton.alpha = 0.0f binding.inputBarCancelButton.alpha = 0.0f
@@ -69,7 +73,7 @@ class InputBarRecordingView : RelativeLayout {
animateDotView() animateDotView()
pulse() pulse()
animateLockViewUp() animateLockViewUp()
updateTimer() startTimer(scope)
} }
fun hide() { fun hide() {
@@ -86,6 +90,24 @@ class InputBarRecordingView : RelativeLayout {
} }
animation.start() animation.start()
delegate?.handleVoiceMessageUIHidden() delegate?.handleVoiceMessageUIHidden()
stopTimer()
}
private fun startTimer(scope: CoroutineScope) {
timerJob?.cancel()
timerJob = scope.launch {
while (isActive) {
val duration = (Date().time - startTimestamp) / 1000L
binding.recordingViewDurationTextView.text = DateUtils.formatElapsedTime(duration)
delay(500)
}
}
}
private fun stopTimer() {
timerJob?.cancel()
timerJob = null
} }
private fun animateDotView() { private fun animateDotView() {
@@ -129,12 +151,6 @@ class InputBarRecordingView : RelativeLayout {
animation.start() animation.start()
} }
private fun updateTimer() {
val duration = (Date().time - startTimestamp) / 1000L
binding.recordingViewDurationTextView.text = DateUtils.formatElapsedTime(duration)
snHandler.postDelayed({ updateTimer() }, 500)
}
fun lock() { fun lock() {
val fadeOutAnimation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) val fadeOutAnimation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
fadeOutAnimation.duration = 250L fadeOutAnimation.duration = 250L

View File

@@ -65,7 +65,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
menu.findItem(R.id.menu_context_copy).isVisible = !containsControlMessage && hasText menu.findItem(R.id.menu_context_copy).isVisible = !containsControlMessage && hasText
// Copy Session ID // Copy Session ID
menu.findItem(R.id.menu_context_copy_public_key).isVisible = menu.findItem(R.id.menu_context_copy_public_key).isVisible =
(thread.isGroupRecipient && !thread.isOpenGroupRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey) (thread.isGroupRecipient && !thread.isCommunityRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey)
// Message detail // Message detail
menu.findItem(R.id.menu_message_details).isVisible = selectedItems.size == 1 menu.findItem(R.id.menu_message_details).isVisible = selectedItems.size == 1
// Resend // Resend
@@ -77,7 +77,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
&& firstMessage.isMms && (firstMessage as MediaMmsMessageRecord).containsMediaSlide()) && firstMessage.isMms && (firstMessage as MediaMmsMessageRecord).containsMediaSlide())
// Reply // Reply
menu.findItem(R.id.menu_context_reply).isVisible = menu.findItem(R.id.menu_context_reply).isVisible =
(selectedItems.size == 1 && !firstMessage.isPending && !firstMessage.isFailed) (selectedItems.size == 1 && !firstMessage.isPending && !firstMessage.isFailed && !firstMessage.isOpenGroupInvitation)
} }
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu): Boolean { override fun onPrepareActionMode(mode: ActionMode?, menu: Menu): Boolean {

View File

@@ -4,16 +4,11 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.os.AsyncTask import android.os.AsyncTask
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.annotation.ColorInt
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.view.ContextThemeWrapper
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
@@ -24,10 +19,8 @@ import androidx.core.graphics.drawable.IconCompat
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.leave import org.session.libsession.messaging.sending_receiving.leave
import org.session.libsession.utilities.ExpirationUtil
import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.toHexString
@@ -42,8 +35,8 @@ import org.thoughtcrime.securesms.groups.EditClosedGroupActivity
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.service.WebRtcCallService import org.thoughtcrime.securesms.service.WebRtcCallService
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.showMuteDialog import org.thoughtcrime.securesms.showMuteDialog
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.BitmapUtil
import java.io.IOException import java.io.IOException
@@ -53,31 +46,16 @@ object ConversationMenuHelper {
menu: Menu, menu: Menu,
inflater: MenuInflater, inflater: MenuInflater,
thread: Recipient, thread: Recipient,
threadId: Long, context: Context
context: Context,
onOptionsItemSelected: (MenuItem) -> Unit
) { ) {
// Prepare // Prepare
menu.clear() menu.clear()
val isOpenGroup = thread.isOpenGroupRecipient val isOpenGroup = thread.isCommunityRecipient
// Base menu (options that should always be present) // Base menu (options that should always be present)
inflater.inflate(R.menu.menu_conversation, menu) inflater.inflate(R.menu.menu_conversation, menu)
// Expiring messages // Expiring messages
if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient) && !thread.isBlocked) { if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) {
if (thread.expireMessages > 0) { inflater.inflate(R.menu.menu_conversation_expiration, menu)
inflater.inflate(R.menu.menu_conversation_expiration_on, menu)
val item = menu.findItem(R.id.menu_expiring_messages)
item.actionView?.let { actionView ->
val iconView = actionView.findViewById<ImageView>(R.id.menu_badge_icon)
val badgeView = actionView.findViewById<TextView>(R.id.expiration_badge)
@ColorInt val color = context.getColorFromAttr(android.R.attr.textColorPrimary)
iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY)
badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages)
actionView.setOnClickListener { onOptionsItemSelected(item) }
}
} else {
inflater.inflate(R.menu.menu_conversation_expiration_off, menu)
}
} }
// One-on-one chat menu allows copying the session id // One-on-one chat menu allows copying the session id
if (thread.isContactRecipient) { if (thread.isContactRecipient) {
@@ -110,7 +88,7 @@ object ConversationMenuHelper {
inflater.inflate(R.menu.menu_conversation_notification_settings, menu) inflater.inflate(R.menu.menu_conversation_notification_settings, menu)
} }
if (!thread.isGroupRecipient && thread.hasApprovedMe()) { if (thread.showCallMenu()) {
inflater.inflate(R.menu.menu_conversation_call, menu) inflater.inflate(R.menu.menu_conversation_call, menu)
} }
@@ -153,8 +131,7 @@ object ConversationMenuHelper {
R.id.menu_view_all_media -> { showAllMedia(context, thread) } R.id.menu_view_all_media -> { showAllMedia(context, thread) }
R.id.menu_search -> { search(context) } R.id.menu_search -> { search(context) }
R.id.menu_add_shortcut -> { addShortcut(context, thread) } R.id.menu_add_shortcut -> { addShortcut(context, thread) }
R.id.menu_expiring_messages -> { showExpiringMessagesDialog(context, thread) } R.id.menu_expiring_messages -> { showDisappearingMessages(context, thread) }
R.id.menu_expiring_messages_off -> { showExpiringMessagesDialog(context, thread) }
R.id.menu_unblock -> { unblock(context, thread) } R.id.menu_unblock -> { unblock(context, thread) }
R.id.menu_block -> { block(context, thread, deleteThread = false) } R.id.menu_block -> { block(context, thread, deleteThread = false) }
R.id.menu_block_delete -> { blockAndDelete(context, thread) } R.id.menu_block_delete -> { blockAndDelete(context, thread) }
@@ -210,6 +187,7 @@ object ConversationMenuHelper {
private fun addShortcut(context: Context, thread: Recipient) { private fun addShortcut(context: Context, thread: Recipient) {
object : AsyncTask<Void?, Void?, IconCompat?>() { object : AsyncTask<Void?, Void?, IconCompat?>() {
@Deprecated("Deprecated in Java")
override fun doInBackground(vararg params: Void?): IconCompat? { override fun doInBackground(vararg params: Void?): IconCompat? {
var icon: IconCompat? = null var icon: IconCompat? = null
val contactPhoto = thread.contactPhoto val contactPhoto = thread.contactPhoto
@@ -228,6 +206,7 @@ object ConversationMenuHelper {
return icon return icon
} }
@Deprecated("Deprecated in Java")
override fun onPostExecute(icon: IconCompat?) { override fun onPostExecute(icon: IconCompat?) {
val name = Optional.fromNullable<String>(thread.name) val name = Optional.fromNullable<String>(thread.name)
.or(Optional.fromNullable<String>(thread.profileName)) .or(Optional.fromNullable<String>(thread.profileName))
@@ -244,9 +223,9 @@ object ConversationMenuHelper {
}.execute() }.execute()
} }
private fun showExpiringMessagesDialog(context: Context, thread: Recipient) { private fun showDisappearingMessages(context: Context, thread: Recipient) {
val listener = context as? ConversationMenuListener ?: return val listener = context as? ConversationMenuListener ?: return
listener.showExpiringMessagesDialog(thread) listener.showDisappearingMessages(thread)
} }
private fun unblock(context: Context, thread: Recipient) { private fun unblock(context: Context, thread: Recipient) {
@@ -274,7 +253,7 @@ object ConversationMenuHelper {
} }
private fun copyOpenGroupUrl(context: Context, thread: Recipient) { private fun copyOpenGroupUrl(context: Context, thread: Recipient) {
if (!thread.isOpenGroupRecipient) { return } if (!thread.isCommunityRecipient) { return }
val listener = context as? ConversationMenuListener ?: return val listener = context as? ConversationMenuListener ?: return
listener.copyOpenGroupUrl(thread) listener.copyOpenGroupUrl(thread)
} }
@@ -321,7 +300,7 @@ object ConversationMenuHelper {
} }
private fun inviteContacts(context: Context, thread: Recipient) { private fun inviteContacts(context: Context, thread: Recipient) {
if (!thread.isOpenGroupRecipient) { return } if (!thread.isCommunityRecipient) { return }
val intent = Intent(context, SelectContactsActivity::class.java) val intent = Intent(context, SelectContactsActivity::class.java)
val activity = context as AppCompatActivity val activity = context as AppCompatActivity
activity.startActivityForResult(intent, ConversationActivityV2.INVITE_CONTACTS) activity.startActivityForResult(intent, ConversationActivityV2.INVITE_CONTACTS)
@@ -348,7 +327,7 @@ object ConversationMenuHelper {
fun unblock() fun unblock()
fun copySessionID(sessionId: String) fun copySessionID(sessionId: String)
fun copyOpenGroupUrl(thread: Recipient) fun copyOpenGroupUrl(thread: Recipient)
fun showExpiringMessagesDialog(thread: Recipient) fun showDisappearingMessages(thread: Recipient)
} }
} }

View File

@@ -3,50 +3,79 @@ package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewControlMessageBinding import network.loki.messenger.databinding.ViewControlMessageBinding
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages
import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import javax.inject.Inject
@AndroidEntryPoint
class ControlMessageView : LinearLayout { class ControlMessageView : LinearLayout {
private lateinit var binding: ViewControlMessageBinding private val TAG = "ControlMessageView"
// region Lifecycle private val binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true)
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
private fun initialize() { constructor(context: Context) : super(context)
binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true) constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
@Inject lateinit var disappearingMessages: DisappearingMessages
init {
layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
} }
// endregion
// region Updating
fun bind(message: MessageRecord, previous: MessageRecord?) { fun bind(message: MessageRecord, previous: MessageRecord?) {
binding.dateBreakTextView.showDateBreak(message, previous) binding.dateBreakTextView.showDateBreak(message, previous)
binding.iconImageView.visibility = View.GONE binding.iconImageView.isGone = true
binding.expirationTimerView.isGone = true
binding.followSetting.isGone = true
var messageBody: CharSequence = message.getDisplayBody(context) var messageBody: CharSequence = message.getDisplayBody(context)
binding.root.contentDescription= null binding.root.contentDescription = null
binding.textView.text = messageBody
when { when {
message.isExpirationTimerUpdate -> { message.isExpirationTimerUpdate -> {
binding.iconImageView.setImageDrawable( binding.apply {
ResourcesCompat.getDrawable(resources, R.drawable.ic_timer, context.theme) expirationTimerView.isVisible = true
)
binding.iconImageView.visibility = View.VISIBLE val threadRecipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(message.threadId)
if (threadRecipient?.isClosedGroupRecipient == true) {
expirationTimerView.setTimerIcon()
} else {
expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
}
followSetting.isVisible = ExpirationConfiguration.isNewConfigEnabled
&& !message.isOutgoing
&& message.expiryMode != (MessagingModuleConfiguration.shared.storage.getExpirationConfiguration(message.threadId)?.expiryMode ?: ExpiryMode.NONE)
&& threadRecipient?.isGroupRecipient != true
followSetting.setOnClickListener { disappearingMessages.showFollowSettingDialog(context, message) }
}
} }
message.isMediaSavedNotification -> { message.isMediaSavedNotification -> {
binding.iconImageView.setImageDrawable( binding.iconImageView.apply {
ResourcesCompat.getDrawable(resources, R.drawable.ic_file_download_white_36dp, context.theme) setImageDrawable(
) ResourcesCompat.getDrawable(resources, R.drawable.ic_file_download_white_36dp, context.theme)
binding.iconImageView.visibility = View.VISIBLE )
isVisible = true
}
} }
message.isMessageRequestResponse -> { message.isMessageRequestResponse -> {
messageBody = context.getString(R.string.message_requests_accepted) binding.textView.text = context.getString(R.string.message_requests_accepted)
binding.root.contentDescription=context.getString(R.string.AccessibilityId_message_request_config_message) binding.root.contentDescription=context.getString(R.string.AccessibilityId_message_request_config_message)
} }
message.isCallLog -> { message.isCallLog -> {
@@ -56,16 +85,22 @@ class ControlMessageView : LinearLayout {
message.isFirstMissedCall -> R.drawable.ic_info_outline_light message.isFirstMissedCall -> R.drawable.ic_info_outline_light
else -> R.drawable.ic_missed_call else -> R.drawable.ic_missed_call
} }
binding.iconImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, drawable, context.theme)) binding.textView.isVisible = false
binding.iconImageView.visibility = View.VISIBLE binding.callTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(ResourcesCompat.getDrawable(resources, drawable, context.theme), null, null, null)
binding.callTextView.text = messageBody
if (message.expireStarted > 0 && message.expiresIn > 0) {
binding.expirationTimerView.isVisible = true
binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
}
} }
} }
binding.textView.text = messageBody binding.textView.isGone = message.isCallLog
binding.callView.isVisible = message.isCallLog
} }
fun recycle() { fun recycle() {
} }
// endregion
} }

View File

@@ -5,11 +5,13 @@ import android.content.res.ColorStateList
import android.util.AttributeSet import android.util.AttributeSet
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.core.view.isVisible
import network.loki.messenger.databinding.ViewDocumentBinding import network.loki.messenger.databinding.ViewDocumentBinding
import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord
class DocumentView : LinearLayout { class DocumentView : LinearLayout {
private val binding: ViewDocumentBinding by lazy { ViewDocumentBinding.bind(this) } private val binding: ViewDocumentBinding by lazy { ViewDocumentBinding.bind(this) }
// region Lifecycle // region Lifecycle
constructor(context: Context) : super(context) constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
@@ -22,6 +24,12 @@ class DocumentView : LinearLayout {
binding.documentTitleTextView.text = document.fileName.or("Untitled File") binding.documentTitleTextView.text = document.fileName.or("Untitled File")
binding.documentTitleTextView.setTextColor(textColor) binding.documentTitleTextView.setTextColor(textColor)
binding.documentViewIconImageView.imageTintList = ColorStateList.valueOf(textColor) binding.documentViewIconImageView.imageTintList = ColorStateList.valueOf(textColor)
// Show the progress spinner if the attachment is downloading, otherwise show
// the document icon (and always remove the other, whichever one that is)
binding.documentViewProgress.isVisible = message.isMediaPending
binding.documentViewIconImageView.isVisible = !message.isMediaPending
} }
// endregion // endregion
} }

View File

@@ -72,7 +72,7 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
// Author // Author
val author = contactDb.getContactWithSessionID(authorPublicKey) val author = contactDb.getContactWithSessionID(authorPublicKey)
val localNumber = TextSecurePreferences.getLocalNumber(context) val localNumber = TextSecurePreferences.getLocalNumber(context)
val quoteIsLocalUser = localNumber != null && localNumber == author?.sessionID val quoteIsLocalUser = localNumber != null && authorPublicKey == localNumber
val authorDisplayName = val authorDisplayName =
if (quoteIsLocalUser) context.getString(R.string.QuoteView_you) if (quoteIsLocalUser) context.getString(R.string.QuoteView_you)

View File

@@ -27,6 +27,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.modifyLayoutParams
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet
@@ -198,9 +199,9 @@ class VisibleMessageContentView : ConstraintLayout {
isStart = isStartOfMessageCluster, isStart = isStartOfMessageCluster,
isEnd = isEndOfMessageCluster isEnd = isEndOfMessageCluster
) )
val layoutParams = binding.albumThumbnailView.root.layoutParams as ConstraintLayout.LayoutParams binding.albumThumbnailView.root.modifyLayoutParams<ConstraintLayout.LayoutParams> {
layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f horizontalBias = if (message.isOutgoing) 1f else 0f
binding.albumThumbnailView.root.layoutParams = layoutParams }
onContentClick.add { event -> onContentClick.add { event ->
binding.albumThumbnailView.root.calculateHitObject(event, message, thread, onAttachmentNeedsDownload) binding.albumThumbnailView.root.calculateHitObject(event, message, thread, onAttachmentNeedsDownload)
} }
@@ -233,9 +234,9 @@ class VisibleMessageContentView : ConstraintLayout {
} }
} }
} }
val layoutParams = binding.contentParent.layoutParams as ConstraintLayout.LayoutParams binding.contentParent.modifyLayoutParams<ConstraintLayout.LayoutParams> {
layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f horizontalBias = if (message.isOutgoing) 1f else 0f
binding.contentParent.layoutParams = layoutParams }
} }
private val onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf() private val onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf()
@@ -306,16 +307,9 @@ class VisibleMessageContentView : ConstraintLayout {
} }
@ColorInt @ColorInt
fun getTextColor(context: Context, message: MessageRecord): Int { fun getTextColor(context: Context, message: MessageRecord): Int = context.getColorFromAttr(
val colorAttribute = if (message.isOutgoing) { if (message.isOutgoing) R.attr.message_sent_text_color else R.attr.message_received_text_color
// sent )
R.attr.message_sent_text_color
} else {
// received
R.attr.message_received_text_color
}
return context.getColorFromAttr(colorAttribute)
}
} }
// endregion // endregion
} }

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.conversation.v2.messages package org.thoughtcrime.securesms.conversation.v2.messages
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Canvas import android.graphics.Canvas
@@ -10,8 +11,10 @@ import android.os.Looper
import android.util.AttributeSet import android.util.AttributeSet
import android.view.Gravity import android.view.Gravity
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
@@ -21,23 +24,23 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.marginBottom import androidx.core.view.marginBottom
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewEmojiReactionsBinding
import network.loki.messenger.databinding.ViewVisibleMessageBinding import network.loki.messenger.databinding.ViewVisibleMessageBinding
import network.loki.messenger.databinding.ViewstubVisibleMessageMarkerContainerBinding
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.contacts.Contact.ContactContext import org.session.libsession.messaging.contacts.Contact.ContactContext
import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.ViewUtil
import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.modifyLayoutParams
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.LastSentTimestampCache
import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiAPIDatabase
import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase
import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsDatabase
@@ -61,17 +64,29 @@ import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.sqrt import kotlin.math.sqrt
@AndroidEntryPoint private const val TAG = "VisibleMessageView"
class VisibleMessageView : LinearLayout {
@AndroidEntryPoint
class VisibleMessageView : FrameLayout {
private var replyDisabled: Boolean = false
@Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var threadDb: ThreadDatabase
@Inject lateinit var lokiThreadDb: LokiThreadDatabase @Inject lateinit var lokiThreadDb: LokiThreadDatabase
@Inject lateinit var lokiApiDb: LokiAPIDatabase @Inject lateinit var lokiApiDb: LokiAPIDatabase
@Inject lateinit var mmsSmsDb: MmsSmsDatabase @Inject lateinit var mmsSmsDb: MmsSmsDatabase
@Inject lateinit var smsDb: SmsDatabase @Inject lateinit var smsDb: SmsDatabase
@Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var mmsDb: MmsDatabase
@Inject lateinit var lastSentTimestampCache: LastSentTimestampCache
private val binding = ViewVisibleMessageBinding.inflate(LayoutInflater.from(context), this, true)
private val markerContainerBinding = lazy(LazyThreadSafetyMode.NONE) {
ViewstubVisibleMessageMarkerContainerBinding.bind(binding.unreadMarkerContainerStub.inflate())
}
private val emojiReactionsBinding = lazy(LazyThreadSafetyMode.NONE) {
ViewEmojiReactionsBinding.bind(binding.emojiReactionsView.inflate())
}
private val binding by lazy { ViewVisibleMessageBinding.bind(this) }
private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate() private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate()
private val swipeToReplyIconRect = Rect() private val swipeToReplyIconRect = Rect()
private var dx = 0.0f private var dx = 0.0f
@@ -90,7 +105,7 @@ class VisibleMessageView : LinearLayout {
var onPress: ((event: MotionEvent) -> Unit)? = null var onPress: ((event: MotionEvent) -> Unit)? = null
var onSwipeToReply: (() -> Unit)? = null var onSwipeToReply: (() -> Unit)? = null
var onLongPress: (() -> Unit)? = null var onLongPress: (() -> Unit)? = null
val messageContentView: VisibleMessageContentView by lazy { binding.messageContentView.root } val messageContentView: VisibleMessageContentView get() = binding.messageContentView.root
companion object { companion object {
const val swipeToReplyThreshold = 64.0f // dp const val swipeToReplyThreshold = 64.0f // dp
@@ -104,12 +119,7 @@ class VisibleMessageView : LinearLayout {
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
override fun onFinishInflate() { init {
super.onFinishInflate()
initialize()
}
private fun initialize() {
isHapticFeedbackEnabled = true isHapticFeedbackEnabled = true
setWillNotDraw(false) setWillNotDraw(false)
binding.root.disableClipping() binding.root.disableClipping()
@@ -117,7 +127,11 @@ class VisibleMessageView : LinearLayout {
binding.messageInnerContainer.disableClipping() binding.messageInnerContainer.disableClipping()
binding.messageInnerLayout.disableClipping() binding.messageInnerLayout.disableClipping()
binding.messageContentView.root.disableClipping() binding.messageContentView.root.disableClipping()
// Default layout params
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
} }
// endregion // endregion
// region Updating // region Updating
@@ -131,15 +145,16 @@ class VisibleMessageView : LinearLayout {
senderSessionID: String, senderSessionID: String,
lastSeen: Long, lastSeen: Long,
delegate: VisibleMessageViewDelegate? = null, delegate: VisibleMessageViewDelegate? = null,
onAttachmentNeedsDownload: (Long, Long) -> Unit onAttachmentNeedsDownload: (Long, Long) -> Unit,
lastSentMessageId: Long
) { ) {
replyDisabled = message.isOpenGroupInvitation
val threadID = message.threadId val threadID = message.threadId
val thread = threadDb.getRecipientForThreadId(threadID) ?: return val thread = threadDb.getRecipientForThreadId(threadID) ?: return
val isGroupThread = thread.isGroupRecipient val isGroupThread = thread.isGroupRecipient
val isStartOfMessageCluster = isStartOfMessageCluster(message, previous, isGroupThread) val isStartOfMessageCluster = isStartOfMessageCluster(message, previous, isGroupThread)
val isEndOfMessageCluster = isEndOfMessageCluster(message, next, isGroupThread) val isEndOfMessageCluster = isEndOfMessageCluster(message, next, isGroupThread)
// Show profile picture and sender name if this is a group thread AND // Show profile picture and sender name if this is a group thread AND the message is incoming
// the message is incoming
binding.moderatorIconImageView.isVisible = false binding.moderatorIconImageView.isVisible = false
binding.profilePictureView.visibility = when { binding.profilePictureView.visibility = when {
thread.isGroupRecipient && !message.isOutgoing && isEndOfMessageCluster -> View.VISIBLE thread.isGroupRecipient && !message.isOutgoing && isEndOfMessageCluster -> View.VISIBLE
@@ -165,7 +180,7 @@ class VisibleMessageView : LinearLayout {
binding.profilePictureView.publicKey = senderSessionID binding.profilePictureView.publicKey = senderSessionID
binding.profilePictureView.update(message.individualRecipient) binding.profilePictureView.update(message.individualRecipient)
binding.profilePictureView.setOnClickListener { binding.profilePictureView.setOnClickListener {
if (thread.isOpenGroupRecipient) { if (thread.isCommunityRecipient) {
val openGroup = lokiThreadDb.getOpenGroupChat(threadID) val openGroup = lokiThreadDb.getOpenGroupChat(threadID)
if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) { if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) {
// TODO: support v2 soon // TODO: support v2 soon
@@ -178,7 +193,7 @@ class VisibleMessageView : LinearLayout {
maybeShowUserDetails(senderSessionID, threadID) maybeShowUserDetails(senderSessionID, threadID)
} }
} }
if (thread.isOpenGroupRecipient) { if (thread.isCommunityRecipient) {
val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return
var standardPublicKey = "" var standardPublicKey = ""
var blindedPublicKey: String? = null var blindedPublicKey: String? = null
@@ -194,68 +209,43 @@ class VisibleMessageView : LinearLayout {
} }
binding.senderNameTextView.isVisible = !message.isOutgoing && (isStartOfMessageCluster && (isGroupThread || snIsSelected)) binding.senderNameTextView.isVisible = !message.isOutgoing && (isStartOfMessageCluster && (isGroupThread || snIsSelected))
val contactContext = val contactContext =
if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR if (thread.isCommunityRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR
binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID
// Unread marker // Unread marker
binding.unreadMarkerContainer.isVisible = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing val shouldShowUnreadMarker = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing
if (shouldShowUnreadMarker) {
markerContainerBinding.value.root.isVisible = true
} else if (markerContainerBinding.isInitialized()) {
// Only need to hide the binding when the binding is inflated. (default is gone)
markerContainerBinding.value.root.isVisible = false
}
// Date break // Date break
val showDateBreak = isStartOfMessageCluster || snIsSelected val showDateBreak = isStartOfMessageCluster || snIsSelected
binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null
binding.dateBreakTextView.isVisible = showDateBreak binding.dateBreakTextView.isVisible = showDateBreak
// Message status indicator
if (message.isOutgoing) {
val (iconID, iconColor, textId, contentDescription) = getMessageStatusImage(message)
if (textId != null) {
binding.messageStatusTextView.setText(textId)
if (iconColor != null) { // Update message status indicator
binding.messageStatusTextView.setTextColor(iconColor) showStatusMessage(message)
}
}
if (iconID != null) {
val drawable = ContextCompat.getDrawable(context, iconID)?.mutate()
if (iconColor != null) {
drawable?.setTint(iconColor)
}
binding.messageStatusImageView.setImageDrawable(drawable)
}
binding.messageStatusImageView.contentDescription = contentDescription
val lastMessageID = mmsSmsDb.getLastMessageID(message.threadId)
binding.messageStatusTextView.isVisible = (
textId != null && (
!message.isSent ||
message.id == lastMessageID
)
)
binding.messageStatusImageView.isVisible = (
iconID != null && (
!message.isSent ||
message.id == lastMessageID
)
)
} else {
binding.messageStatusTextView.isVisible = false
binding.messageStatusImageView.isVisible = false
}
// Expiration timer
updateExpirationTimer(message)
// Emoji Reactions // Emoji Reactions
val emojiLayoutParams = binding.emojiReactionsView.root.layoutParams as ConstraintLayout.LayoutParams
emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
binding.emojiReactionsView.root.layoutParams = emojiLayoutParams
if (message.reactions.isNotEmpty()) { if (message.reactions.isNotEmpty()) {
val capabilities = lokiThreadDb.getOpenGroupChat(threadID)?.server?.let { lokiApiDb.getServerCapabilities(it) } val capabilities = lokiThreadDb.getOpenGroupChat(threadID)?.server?.let { lokiApiDb.getServerCapabilities(it) }
if (capabilities.isNullOrEmpty() || capabilities.contains(OpenGroupApi.Capability.REACTIONS.name.lowercase())) { if (capabilities.isNullOrEmpty() || capabilities.contains(OpenGroupApi.Capability.REACTIONS.name.lowercase())) {
binding.emojiReactionsView.root.setReactions(message.id, message.reactions, message.isOutgoing, delegate) emojiReactionsBinding.value.root.let { root ->
binding.emojiReactionsView.root.isVisible = true root.setReactions(message.id, message.reactions, message.isOutgoing, delegate)
} else { root.isVisible = true
binding.emojiReactionsView.root.isVisible = false (root.layoutParams as ConstraintLayout.LayoutParams).apply {
horizontalBias = if (message.isOutgoing) 1f else 0f
}
}
} else if (emojiReactionsBinding.isInitialized()) {
emojiReactionsBinding.value.root.isVisible = false
} }
} }
else { else if (emojiReactionsBinding.isInitialized()) {
binding.emojiReactionsView.root.isVisible = false emojiReactionsBinding.value.root.isVisible = false
} }
// Populate content view // Populate content view
@@ -274,122 +264,170 @@ class VisibleMessageView : LinearLayout {
onDoubleTap = { binding.messageContentView.root.onContentDoubleTap?.invoke() } onDoubleTap = { binding.messageContentView.root.onContentDoubleTap?.invoke() }
} }
private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean { // Method to display or hide the status of a message.
return if (isGroupThread) { // Note: Although most commonly used to display the delivery status of a message, we also use the
previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp) // message status area to display the disappearing messages state - so in this latter case we'll
|| current.recipient.address != previous.recipient.address // be displaying the "Sent" and the animating clock icon for outgoing messages or "Read" and the
} else { // animated clock icon for incoming messages.
previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp) private fun showStatusMessage(message: MessageRecord) {
|| current.isOutgoing != previous.isOutgoing // We'll start by hiding everything and then only make visible what we need
binding.messageStatusTextView.isVisible = false
binding.messageStatusImageView.isVisible = false
binding.expirationTimerView.isVisible = false
// Get details regarding how we should display the message (it's delivery icon, icon tint colour, and
// the resource string for what text to display (R.string.delivery_status_sent etc.).
val (iconID, iconColor, textId) = getMessageStatusInfo(message)
// If we get any nulls then a message isn't one with a state that we care about (i.e., control messages
// etc.) - so bail. See: `DisplayRecord.is<WHATEVER>` for the full suite of message state methods.
// Also: We set all delivery status elements visibility to false just to make sure we don't display any
// stale data.
if (textId == null) return
binding.messageInnerLayout.modifyLayoutParams<FrameLayout.LayoutParams> {
gravity = if (message.isOutgoing) Gravity.END else Gravity.START
}
binding.statusContainer.modifyLayoutParams<ConstraintLayout.LayoutParams> {
horizontalBias = if (message.isOutgoing) 1f else 0f
}
// If the message is incoming AND it is not scheduled to disappear then don't show any status or timer details
val scheduledToDisappear = message.expiresIn > 0
if (message.isIncoming && !scheduledToDisappear) return
// Set text & icons as appropriate for the message state. Note: Possible message states we care
// about are: isFailed, isSyncFailed, isPending, isSyncing, isResyncing, isRead, and isSent.
textId.let(binding.messageStatusTextView::setText)
iconColor?.let(binding.messageStatusTextView::setTextColor)
iconID?.let { ContextCompat.getDrawable(context, it) }
?.run { iconColor?.let { mutate().apply { setTint(it) } } ?: this }
?.let(binding.messageStatusImageView::setImageDrawable)
// Potential options at this point are that the message is:
// i.) incoming AND scheduled to disappear.
// ii.) outgoing but NOT scheduled to disappear, or
// iii.) outgoing AND scheduled to disappear.
// ----- Case i..) Message is incoming and scheduled to disappear -----
if (message.isIncoming && scheduledToDisappear) {
// Display the status ('Read') and the show the timer only (no delivery icon)
binding.messageStatusTextView.isVisible = true
binding.expirationTimerView.isVisible = true
binding.expirationTimerView.bringToFront()
updateExpirationTimer(message)
return
}
// --- If we got here then we know the message is outgoing ---
// ----- Case ii.) Message is outgoing but NOT scheduled to disappear -----
if (!scheduledToDisappear) {
// If this isn't a disappearing message then we never show the timer
// If the message has NOT been successfully sent then always show the delivery status text and icon..
val neitherSentNorRead = !(message.isSent || message.isRead)
if (neitherSentNorRead) {
binding.messageStatusTextView.isVisible = true
binding.messageStatusImageView.isVisible = true
} else {
// ..but if the message HAS been successfully sent or read then only display the delivery status
// text and image if this is the last sent message.
val lastSentTimestamp = lastSentTimestampCache.getTimestamp(message.threadId)
val isLastSent = lastSentTimestamp == message.timestamp
binding.messageStatusTextView.isVisible = isLastSent
binding.messageStatusImageView.isVisible = isLastSent
if (isLastSent) { binding.messageStatusImageView.bringToFront() }
}
}
else // ----- Case iii.) Message is outgoing AND scheduled to disappear -----
{
// Always display the delivery status text on all outgoing disappearing messages
binding.messageStatusTextView.isVisible = true
// If the message is sent or has been read..
val sentOrRead = message.isSent || message.isRead
if (sentOrRead) {
// ..then display the timer icon for this disappearing message (but keep the message status icon hidden)
binding.expirationTimerView.isVisible = true
binding.expirationTimerView.bringToFront()
updateExpirationTimer(message)
} else {
// If the message has NOT been sent or read (or it has failed) then show the delivery status icon rather than the timer icon
binding.messageStatusImageView.isVisible = true
binding.messageStatusImageView.bringToFront()
}
} }
} }
private fun isEndOfMessageCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean { private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean =
return if (isGroupThread) { previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp) || if (isGroupThread) {
next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp) current.recipient.address != previous.recipient.address
|| current.recipient.address != next.recipient.address
} else { } else {
next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp) current.isOutgoing != previous.isOutgoing
|| current.isOutgoing != next.isOutgoing }
private fun isEndOfMessageCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean =
next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp) || if (isGroupThread) {
current.recipient.address != next.recipient.address
} else {
current.isOutgoing != next.isOutgoing
} }
}
data class MessageStatusInfo(@DrawableRes val iconId: Int?, data class MessageStatusInfo(@DrawableRes val iconId: Int?,
@ColorInt val iconTint: Int?, @ColorInt val iconTint: Int?,
@StringRes val messageText: Int?, @StringRes val messageText: Int?)
val contentDescription: String?)
private fun getMessageStatusImage(message: MessageRecord): MessageStatusInfo = when { private fun getMessageStatusInfo(message: MessageRecord): MessageStatusInfo = when {
message.isFailed -> message.isFailed ->
MessageStatusInfo( MessageStatusInfo(R.drawable.ic_delivery_status_failed,
R.drawable.ic_delivery_status_failed,
resources.getColor(R.color.destructive, context.theme), resources.getColor(R.color.destructive, context.theme),
R.string.delivery_status_failed, R.string.delivery_status_failed
null
) )
message.isSyncFailed -> message.isSyncFailed ->
MessageStatusInfo( MessageStatusInfo(
R.drawable.ic_delivery_status_failed, R.drawable.ic_delivery_status_failed,
context.getColor(R.color.accent_orange), context.getColor(R.color.accent_orange),
R.string.delivery_status_sync_failed, R.string.delivery_status_sync_failed
null
) )
message.isPending -> message.isPending ->
MessageStatusInfo( MessageStatusInfo(
R.drawable.ic_delivery_status_sending, R.drawable.ic_delivery_status_sending,
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending, context.getColorFromAttr(R.attr.message_status_color),
context.getString(R.string.AccessibilityId_message_sent_status_pending) R.string.delivery_status_sending
) )
message.isResyncing -> message.isSyncing || message.isResyncing ->
MessageStatusInfo( MessageStatusInfo(
R.drawable.ic_delivery_status_sending, R.drawable.ic_delivery_status_sending,
context.getColor(R.color.accent_orange), R.string.delivery_status_syncing, context.getColorFromAttr(R.attr.message_status_color),
context.getString(R.string.AccessibilityId_message_sent_status_syncing) R.string.delivery_status_sending // We COULD tell the user that we're `syncing` (R.string.delivery_status_syncing) but it will likely make more sense to them if we say "Sending"
) )
message.isRead -> message.isRead || message.isIncoming ->
MessageStatusInfo( MessageStatusInfo(
R.drawable.ic_delivery_status_read, R.drawable.ic_delivery_status_read,
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read, context.getColorFromAttr(R.attr.message_status_color),
null R.string.delivery_status_read
) )
else -> message.isSent ->
MessageStatusInfo( MessageStatusInfo(
R.drawable.ic_delivery_status_sent, R.drawable.ic_delivery_status_sent,
context.getColorFromAttr(R.attr.message_status_color), context.getColorFromAttr(R.attr.message_status_color),
R.string.delivery_status_sent, R.string.delivery_status_sent
context.getString(R.string.AccessibilityId_message_sent_status_tick)
) )
else -> {
// The message isn't one we care about for message statuses we display to the user (i.e.,
// control messages etc. - see the `DisplayRecord.is<WHATEVER>` suite of methods for options).
MessageStatusInfo(null, null, null)
}
} }
private fun updateExpirationTimer(message: MessageRecord) { private fun updateExpirationTimer(message: MessageRecord) {
val container = binding.messageInnerContainer if (!message.isOutgoing) binding.messageStatusTextView.bringToFront()
val layout = binding.messageInnerLayout binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
if (message.isOutgoing) binding.messageContentView.root.bringToFront()
else binding.expirationTimerView.bringToFront()
layout.layoutParams = layout.layoutParams.let { it as FrameLayout.LayoutParams }
.apply { gravity = if (message.isOutgoing) Gravity.END else Gravity.START }
val containerParams = container.layoutParams as ConstraintLayout.LayoutParams
containerParams.horizontalBias = if (message.isOutgoing) 1f else 0f
container.layoutParams = containerParams
if (message.expiresIn > 0 && !message.isPending) {
binding.expirationTimerView.setColorFilter(context.getColorFromAttr(android.R.attr.textColorPrimary))
binding.expirationTimerView.isInvisible = false
binding.expirationTimerView.setPercentComplete(0.0f)
if (message.expireStarted > 0) {
binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
binding.expirationTimerView.startAnimation()
if (message.expireStarted + message.expiresIn <= SnodeAPI.nowWithOffset) {
ApplicationContext.getInstance(context).expiringMessageManager.checkSchedule()
}
} else if (!message.isMediaPending) {
binding.expirationTimerView.setPercentComplete(0.0f)
binding.expirationTimerView.stopAnimation()
ThreadUtils.queue {
val expirationManager = ApplicationContext.getInstance(context).expiringMessageManager
val id = message.getId()
val mms = message.isMms
if (mms) mmsDb.markExpireStarted(id) else smsDb.markExpireStarted(id)
expirationManager.scheduleDeletion(id, mms, message.expiresIn)
}
} else {
binding.expirationTimerView.stopAnimation()
binding.expirationTimerView.setPercentComplete(0.0f)
}
} else {
binding.expirationTimerView.isInvisible = true
}
container.requestLayout()
} }
private fun handleIsSelectedChanged() { private fun handleIsSelectedChanged() {
background = if (snIsSelected) { background = if (snIsSelected) ColorDrawable(context.getColorFromAttr(R.attr.message_selected)) else null
ColorDrawable(context.getColorFromAttr(R.attr.message_selected))
} else {
null
}
} }
override fun onDraw(canvas: Canvas) { override fun onDraw(canvas: Canvas) {
@@ -426,6 +464,7 @@ class VisibleMessageView : LinearLayout {
// endregion // endregion
// region Interaction // region Interaction
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean { override fun onTouchEvent(event: MotionEvent): Boolean {
if (onPress == null || onSwipeToReply == null || onLongPress == null) { return false } if (onPress == null || onSwipeToReply == null || onLongPress == null) { return false }
when (event.action) { when (event.action) {
@@ -453,6 +492,7 @@ class VisibleMessageView : LinearLayout {
} else { } else {
longPressCallback?.let { gestureHandler.removeCallbacks(it) } longPressCallback?.let { gestureHandler.removeCallbacks(it) }
} }
if (replyDisabled) return
if (translationX > 0) { return } // Only allow swipes to the left if (translationX > 0) { return } // Only allow swipes to the left
// The idea here is to asymptotically approach a maximum drag distance // The idea here is to asymptotically approach a maximum drag distance
val damping = 50.0f val damping = 50.0f
@@ -526,14 +566,13 @@ class VisibleMessageView : LinearLayout {
} }
private fun maybeShowUserDetails(publicKey: String, threadID: Long) { private fun maybeShowUserDetails(publicKey: String, threadID: Long) {
val userDetailsBottomSheet = UserDetailsBottomSheet() UserDetailsBottomSheet().apply {
val bundle = bundleOf( arguments = bundleOf(
UserDetailsBottomSheet.ARGUMENT_PUBLIC_KEY to publicKey, UserDetailsBottomSheet.ARGUMENT_PUBLIC_KEY to publicKey,
UserDetailsBottomSheet.ARGUMENT_THREAD_ID to threadID UserDetailsBottomSheet.ARGUMENT_THREAD_ID to threadID
) )
userDetailsBottomSheet.arguments = bundle show((this@VisibleMessageView.context as AppCompatActivity).supportFragmentManager, tag)
val activity = context as AppCompatActivity }
userDetailsBottomSheet.show(activity.supportFragmentManager, userDetailsBottomSheet.tag)
} }
fun playVoiceMessage() { fun playVoiceMessage() {

View File

@@ -68,7 +68,7 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener {
return return
} }
val player = AudioSlidePlayer.createFor(context, audio, this) val player = AudioSlidePlayer.createFor(context.applicationContext, audio, this)
this.player = player this.player = player
(audio.asAttachment() as? DatabaseAttachment)?.let { attachment -> (audio.asAttachment() as? DatabaseAttachment)?.let { attachment ->

View File

@@ -241,7 +241,21 @@ public class AttachmentManager {
} }
public static void selectDocument(Activity activity, int requestCode) { public static void selectDocument(Activity activity, int requestCode) {
selectMediaType(activity, "*/*", null, requestCode); Permissions.PermissionsBuilder builder = Permissions.with(activity);
// The READ_EXTERNAL_STORAGE permission is deprecated (and will AUTO-FAIL if requested!) on
// Android 13 and above (API 33 - 'Tiramisu') we must ask for READ_MEDIA_VIDEO/IMAGES/AUDIO instead.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
builder = builder.request(Manifest.permission.READ_MEDIA_VIDEO)
.request(Manifest.permission.READ_MEDIA_IMAGES)
.request(Manifest.permission.READ_MEDIA_AUDIO);
} else {
builder = builder.request(Manifest.permission.READ_EXTERNAL_STORAGE);
}
builder.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio))
.withRationaleDialog(activity.getString(R.string.ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage), R.drawable.ic_baseline_photo_library_24)
.onAllGranted(() -> selectMediaType(activity, "*/*", null, requestCode)) // Note: We can use startActivityForResult w/ the ACTION_OPEN_DOCUMENT or ACTION_OPEN_DOCUMENT_TREE intent if we need to modernise this.
.execute();
} }
public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body) { public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body) {

View File

@@ -1,21 +1,28 @@
package org.thoughtcrime.securesms.conversation.v2.utilities package org.thoughtcrime.securesms.conversation.v2.utilities
import android.app.Application
import android.content.Context import android.content.Context
import android.graphics.Typeface import android.graphics.Typeface
import android.text.Spannable import android.text.Spannable
import android.text.SpannableString import android.text.SpannableString
import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.util.Range import android.util.Range
import androidx.appcompat.widget.ThemeUtils
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import network.loki.messenger.R import network.loki.messenger.R
import nl.komponents.kovenant.combine.Tuple2 import nl.komponents.kovenant.combine.Tuple2
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.ThemeUtil
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.UiModeUtilities import org.thoughtcrime.securesms.util.UiModeUtilities
import org.thoughtcrime.securesms.util.getAccentColor import org.thoughtcrime.securesms.util.getAccentColor
import org.thoughtcrime.securesms.util.getColorResourceIdFromAttr
import org.thoughtcrime.securesms.util.getMessageTextColourAttr
import java.util.regex.Pattern import java.util.regex.Pattern
object MentionUtilities { object MentionUtilities {
@@ -58,15 +65,37 @@ object MentionUtilities {
} }
} }
val result = SpannableString(text) val result = SpannableString(text)
val isLightMode = UiModeUtilities.isDayUiMode(context)
val color = if (isOutgoingMessage) { var mentionTextColour: Int? = null
ResourcesCompat.getColor(context.resources, if (isLightMode) R.color.white else R.color.black, context.theme) // In dark themes..
} else { if (ThemeUtil.isDarkTheme(context)) {
context.getAccentColor() // ..we use the standard outgoing message colour for outgoing messages..
if (isOutgoingMessage) {
val mentionTextColourAttributeId = getMessageTextColourAttr(true)
val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId)
mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme)
}
else // ..but we use the accent colour for incoming messages (i.e., someone mentioning us)..
{
mentionTextColour = context.getAccentColor()
}
} }
else // ..while in light themes we always just use the incoming or outgoing message text colour for mentions.
{
val mentionTextColourAttributeId = getMessageTextColourAttr(isOutgoingMessage)
val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId)
mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme)
}
for (mention in mentions) { for (mention in mentions) {
result.setSpan(ForegroundColorSpan(color), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) result.setSpan(ForegroundColorSpan(mentionTextColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
result.setSpan(StyleSpan(Typeface.BOLD), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) result.setSpan(StyleSpan(Typeface.BOLD), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
// If we're using a light theme then we change the background colour of the mention to be the accent colour
if (ThemeUtil.isLightTheme(context)) {
val backgroundColour = context.getAccentColor();
result.setSpan(BackgroundColorSpan(backgroundColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
} }
return result return result
} }

View File

@@ -40,18 +40,16 @@ object ResendMessageUtilities {
message.recipient = messageRecord.recipient.address.serialize() message.recipient = messageRecord.recipient.address.serialize()
} }
message.threadID = messageRecord.threadId message.threadID = messageRecord.threadId
if (messageRecord.isMms) { if (messageRecord.isMms && messageRecord is MmsMessageRecord) {
val mmsMessageRecord = messageRecord as MmsMessageRecord messageRecord.linkPreviews.firstOrNull()?.let { message.linkPreview = LinkPreview.from(it) }
if (mmsMessageRecord.linkPreviews.isNotEmpty()) { messageRecord.quote?.quoteModel?.let {
message.linkPreview = LinkPreview.from(mmsMessageRecord.linkPreviews[0]) message.quote = Quote.from(it)?.apply {
} if (userBlindedKey != null && publicKey == TextSecurePreferences.getLocalNumber(context)) {
if (mmsMessageRecord.quote != null) { publicKey = userBlindedKey
message.quote = Quote.from(mmsMessageRecord.quote!!.quoteModel) }
if (userBlindedKey != null && messageRecord.quote!!.author.serialize() == TextSecurePreferences.getLocalNumber(context)) {
message.quote!!.publicKey = userBlindedKey
} }
} }
message.addSignalAttachments(mmsMessageRecord.slideDeck.asAttachments()) message.addSignalAttachments(messageRecord.slideDeck.asAttachments())
} }
val sentTimestamp = message.sentTimestamp val sentTimestamp = message.sentTimestamp
val sender = MessagingModuleConfiguration.shared.storage.getUserPublicKey() val sender = MessagingModuleConfiguration.shared.storage.getUserPublicKey()

View File

@@ -30,9 +30,7 @@ class ThumbnailProgressBar: View {
private val objectRect = Rect() private val objectRect = Rect()
private val drawingRect = Rect() private val drawingRect = Rect()
override fun dispatchDraw(canvas: Canvas?) { override fun dispatchDraw(canvas: Canvas) {
if (canvas == null) return
getDrawingRect(objectRect) getDrawingRect(objectRect)
drawingRect.set(objectRect) drawingRect.set(objectRect)

View File

@@ -52,6 +52,7 @@ public class IdentityKeyUtil {
public static final String IDENTITY_PRIVATE_KEY_PREF = "pref_identity_private_v3"; public static final String IDENTITY_PRIVATE_KEY_PREF = "pref_identity_private_v3";
public static final String ED25519_PUBLIC_KEY = "pref_ed25519_public_key"; public static final String ED25519_PUBLIC_KEY = "pref_ed25519_public_key";
public static final String ED25519_SECRET_KEY = "pref_ed25519_secret_key"; public static final String ED25519_SECRET_KEY = "pref_ed25519_secret_key";
public static final String NOTIFICATION_KEY = "pref_notification_key";
public static final String LOKI_SEED = "loki_seed"; public static final String LOKI_SEED = "loki_seed";
public static final String HAS_MIGRATED_KEY = "has_migrated_keys"; public static final String HAS_MIGRATED_KEY = "has_migrated_keys";

View File

@@ -1,10 +1,9 @@
package org.thoughtcrime.securesms.crypto package org.thoughtcrime.securesms.crypto
import android.content.Context import android.content.Context
import com.goterl.lazysodium.LazySodiumAndroid
import com.goterl.lazysodium.SodiumAndroid
import com.goterl.lazysodium.utils.Key import com.goterl.lazysodium.utils.Key
import com.goterl.lazysodium.utils.KeyPair import com.goterl.lazysodium.utils.KeyPair
import org.session.libsession.messaging.utilities.SodiumUtilities.sodium
import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPrivateKey
import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.DjbECPublicKey
import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.crypto.ecc.ECKeyPair
@@ -13,8 +12,6 @@ import org.session.libsignal.utilities.Hex
object KeyPairUtilities { object KeyPairUtilities {
private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) }
fun generate(): KeyPairGenerationResult { fun generate(): KeyPairGenerationResult {
val seed = sodium.randomBytesBuf(16) val seed = sodium.randomBytesBuf(16)
try { try {

View File

@@ -5,9 +5,9 @@ import android.content.Context
import org.session.libsession.utilities.Debouncer import org.session.libsession.utilities.Debouncer
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
class ConversationNotificationDebouncer(private val context: Context) { class ConversationNotificationDebouncer(private val context: ApplicationContext) {
private val threadIDs = mutableSetOf<Long>() private val threadIDs = mutableSetOf<Long>()
private val handler = (context.applicationContext as ApplicationContext).conversationListNotificationHandler private val handler = context.conversationListNotificationHandler
private val debouncer = Debouncer(handler, 100) private val debouncer = Debouncer(handler, 100)
companion object { companion object {
@@ -17,20 +17,28 @@ class ConversationNotificationDebouncer(private val context: Context) {
@Synchronized @Synchronized
fun get(context: Context): ConversationNotificationDebouncer { fun get(context: Context): ConversationNotificationDebouncer {
if (::shared.isInitialized) { return shared } if (::shared.isInitialized) { return shared }
shared = ConversationNotificationDebouncer(context) shared = ConversationNotificationDebouncer(context.applicationContext as ApplicationContext)
return shared return shared
} }
} }
fun notify(threadID: Long) { fun notify(threadID: Long) {
threadIDs.add(threadID) synchronized(threadIDs) {
threadIDs.add(threadID)
}
debouncer.publish { publish() } debouncer.publish { publish() }
} }
private fun publish() { private fun publish() {
for (threadID in threadIDs.toList()) { val toNotify = synchronized(threadIDs) {
val copy = threadIDs.toList()
threadIDs.clear()
copy
}
for (threadID in toNotify) {
context.contentResolver.notifyChange(DatabaseContentProviders.Conversation.getUriForThread(threadID), null) context.contentResolver.notifyChange(DatabaseContentProviders.Conversation.getUriForThread(threadID), null)
} }
threadIDs.clear()
} }
} }

View File

@@ -26,6 +26,7 @@ import androidx.annotation.NonNull;
import net.zetetic.database.sqlcipher.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.session.libsession.utilities.WindowDebouncer; import org.session.libsession.utilities.WindowDebouncer;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
@@ -77,11 +78,11 @@ public abstract class Database {
notifyConversationListListeners(); notifyConversationListListeners();
} }
protected void setNotifyConverationListeners(Cursor cursor, long threadId) { protected void setNotifyConversationListeners(Cursor cursor, long threadId) {
cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.Conversation.getUriForThread(threadId)); cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.Conversation.getUriForThread(threadId));
} }
protected void setNotifyConverationListListeners(Cursor cursor) { protected void setNotifyConversationListListeners(Cursor cursor) {
cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.ConversationList.CONTENT_URI); cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.ConversationList.CONTENT_URI);
} }

View File

@@ -0,0 +1,85 @@
package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.ExpirationDatabaseMetadata
import org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX
import org.session.libsession.utilities.GroupUtil.COMMUNITY_INBOX_PREFIX
import org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
companion object {
const val TABLE_NAME = "expiration_configuration"
const val THREAD_ID = "thread_id"
const val UPDATED_TIMESTAMP_MS = "updated_timestamp_ms"
@JvmField
val CREATE_EXPIRATION_CONFIGURATION_TABLE_COMMAND = """
CREATE TABLE $TABLE_NAME (
$THREAD_ID INTEGER NOT NULL PRIMARY KEY ON CONFLICT REPLACE,
$UPDATED_TIMESTAMP_MS INTEGER DEFAULT NULL
)
""".trimIndent()
@JvmField
val MIGRATE_GROUP_CONVERSATION_EXPIRY_TYPE_COMMAND = """
INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID}
FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME}
WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} LIKE '$CLOSED_GROUP_PREFIX%'
AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0)
""".trimIndent()
@JvmField
val MIGRATE_ONE_TO_ONE_CONVERSATION_EXPIRY_TYPE_COMMAND = """
INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID}
FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME}
WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$CLOSED_GROUP_PREFIX%'
AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_PREFIX%'
AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_INBOX_PREFIX%'
AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0)
""".trimIndent()
private fun readExpirationConfiguration(cursor: Cursor): ExpirationDatabaseMetadata {
return ExpirationDatabaseMetadata(
threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)),
updatedTimestampMs = cursor.getLong(cursor.getColumnIndexOrThrow(UPDATED_TIMESTAMP_MS))
)
}
}
fun getExpirationConfiguration(threadId: Long): ExpirationDatabaseMetadata? {
val query = "$THREAD_ID = ?"
val args = arrayOf("$threadId")
val configurations: MutableList<ExpirationDatabaseMetadata> = mutableListOf()
readableDatabase.query(TABLE_NAME, null, query, args, null, null, null).use { cursor ->
while (cursor.moveToNext()) {
configurations += readExpirationConfiguration(cursor)
}
}
return configurations.firstOrNull()
}
fun setExpirationConfiguration(configuration: ExpirationConfiguration) {
writableDatabase.beginTransaction()
try {
val values = ContentValues().apply {
put(THREAD_ID, configuration.threadId)
put(UPDATED_TIMESTAMP_MS, configuration.updatedTimestampMs)
}
writableDatabase.insert(TABLE_NAME, null, values)
writableDatabase.setTransactionSuccessful()
notifyConversationListeners(configuration.threadId)
} finally {
writableDatabase.endTransaction()
}
}
}

View File

@@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.database
data class ExpirationInfo(
val id: Long,
val timestamp: Long,
val expiresIn: Long,
val expireStarted: Long,
val isMms: Boolean
) {
private fun isDisappearAfterSend() = timestamp == expireStarted
fun isDisappearAfterRead() = expiresIn > 0 && !isDisappearAfterSend()
}

View File

@@ -1,110 +0,0 @@
package org.thoughtcrime.securesms.database;
import android.content.Context;
import android.database.Cursor;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
public abstract class FastCursorRecyclerViewAdapter<VH extends RecyclerView.ViewHolder, T>
extends CursorRecyclerViewAdapter<VH>
{
private static final String TAG = FastCursorRecyclerViewAdapter.class.getSimpleName();
private final LinkedList<T> fastRecords = new LinkedList<>();
private final List<Long> releasedRecordIds = new LinkedList<>();
protected FastCursorRecyclerViewAdapter(Context context, Cursor cursor) {
super(context, cursor);
}
public void addFastRecord(@NonNull T record) {
fastRecords.addFirst(record);
notifyDataSetChanged();
}
public void releaseFastRecord(long id) {
synchronized (releasedRecordIds) {
releasedRecordIds.add(id);
}
}
protected void cleanFastRecords() {
synchronized (releasedRecordIds) {
Iterator<Long> releaseIdIterator = releasedRecordIds.iterator();
while (releaseIdIterator.hasNext()) {
long releasedId = releaseIdIterator.next();
Iterator<T> fastRecordIterator = fastRecords.iterator();
while (fastRecordIterator.hasNext()) {
if (isRecordForId(fastRecordIterator.next(), releasedId)) {
fastRecordIterator.remove();
releaseIdIterator.remove();
break;
}
}
}
}
}
protected abstract T getRecordFromCursor(@NonNull Cursor cursor);
protected abstract void onBindItemViewHolder(VH viewHolder, @NonNull T record);
protected abstract long getItemId(@NonNull T record);
protected abstract int getItemViewType(@NonNull T record);
protected abstract boolean isRecordForId(@NonNull T record, long id);
@Override
public int getItemViewType(@NonNull Cursor cursor) {
T record = getRecordFromCursor(cursor);
return getItemViewType(record);
}
@Override
public void onBindItemViewHolder(VH viewHolder, @NonNull Cursor cursor) {
T record = getRecordFromCursor(cursor);
onBindItemViewHolder(viewHolder, record);
}
@Override
public void onBindFastAccessItemViewHolder(VH viewHolder, int position) {
int calculatedPosition = getCalculatedPosition(position);
onBindItemViewHolder(viewHolder, fastRecords.get(calculatedPosition));
}
@Override
protected int getFastAccessSize() {
return fastRecords.size();
}
protected T getRecordForPositionOrThrow(int position) {
if (isFastAccessPosition(position)) {
return fastRecords.get(getCalculatedPosition(position));
} else {
Cursor cursor = getCursorAtPositionOrThrow(position);
return getRecordFromCursor(cursor);
}
}
protected int getFastAccessItemViewType(int position) {
return getItemViewType(fastRecords.get(getCalculatedPosition(position)));
}
protected boolean isFastAccessPosition(int position) {
position = getCalculatedPosition(position);
return position >= 0 && position < fastRecords.size();
}
protected long getFastAccessItemId(int position) {
return getItemId(fastRecords.get(getCalculatedPosition(position)));
}
private int getCalculatedPosition(int position) {
return hasHeaderView() ? position - 1 : position;
}
}

View File

@@ -0,0 +1,38 @@
package org.thoughtcrime.securesms.database
import org.session.libsession.messaging.LastSentTimestampCache
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LastSentTimestampCache @Inject constructor(
val mmsSmsDatabase: MmsSmsDatabase
): LastSentTimestampCache {
private val map = mutableMapOf<Long, Long>()
@Synchronized
override fun getTimestamp(threadId: Long): Long? = map[threadId]
@Synchronized
override fun submitTimestamp(threadId: Long, timestamp: Long) {
if (map[threadId]?.let { timestamp <= it } == true) return
map[threadId] = timestamp
}
@Synchronized
override fun delete(threadId: Long, timestamps: List<Long>) {
if (map[threadId]?.let { it !in timestamps } == true) return
map.remove(threadId)
refresh(threadId)
}
@Synchronized
override fun refresh(threadId: Long) {
if (map[threadId]?.let { it > 0 } == true) return
val lastOutgoingTimestamp = mmsSmsDatabase.getLastOutgoingTimestamp(threadId)
if (lastOutgoingTimestamp <= 0) return
map[threadId] = lastOutgoingTimestamp
}
}

View File

@@ -97,6 +97,16 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
public val groupPublicKey = "group_public_key" public val groupPublicKey = "group_public_key"
@JvmStatic @JvmStatic
val createClosedGroupPublicKeysTable = "CREATE TABLE $closedGroupPublicKeysTable ($groupPublicKey STRING PRIMARY KEY)" val createClosedGroupPublicKeysTable = "CREATE TABLE $closedGroupPublicKeysTable ($groupPublicKey STRING PRIMARY KEY)"
private const val LAST_LEGACY_MESSAGE_TABLE = "last_legacy_messages"
// The overall "thread recipient
private const val LAST_LEGACY_THREAD_RECIPIENT = "last_legacy_thread_recipient"
// The individual 'last' person who sent the message with legacy expiration attached
private const val LAST_LEGACY_SENDER_RECIPIENT = "last_legacy_sender_recipient"
private const val LEGACY_THREAD_RECIPIENT_QUERY = "$LAST_LEGACY_THREAD_RECIPIENT = ?"
const val CREATE_LAST_LEGACY_MESSAGE_TABLE = "CREATE TABLE $LAST_LEGACY_MESSAGE_TABLE ($LAST_LEGACY_THREAD_RECIPIENT STRING PRIMARY KEY, $LAST_LEGACY_SENDER_RECIPIENT STRING NOT NULL);"
// Hard fork service node info // Hard fork service node info
const val FORK_INFO_TABLE = "fork_info" const val FORK_INFO_TABLE = "fork_info"
const val DUMMY_KEY = "dummy_key" const val DUMMY_KEY = "dummy_key"
@@ -415,6 +425,31 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
database.endTransaction() database.endTransaction()
} }
override fun getLastLegacySenderAddress(threadRecipientAddress: String): String? =
databaseHelper.readableDatabase.get(LAST_LEGACY_MESSAGE_TABLE, LEGACY_THREAD_RECIPIENT_QUERY, wrap(threadRecipientAddress)) { cursor ->
cursor.getString(LAST_LEGACY_SENDER_RECIPIENT)
}
override fun setLastLegacySenderAddress(
threadRecipientAddress: String,
senderRecipientAddress: String?
) {
val database = databaseHelper.writableDatabase
if (senderRecipientAddress == null) {
// delete
database.delete(LAST_LEGACY_MESSAGE_TABLE, LEGACY_THREAD_RECIPIENT_QUERY, wrap(threadRecipientAddress))
} else {
// just update the value to a new one
val values = wrap(
mapOf(
LAST_LEGACY_THREAD_RECIPIENT to threadRecipientAddress,
LAST_LEGACY_SENDER_RECIPIENT to senderRecipientAddress
)
)
database.insertOrUpdate(LAST_LEGACY_MESSAGE_TABLE, values, LEGACY_THREAD_RECIPIENT_QUERY, wrap(threadRecipientAddress))
}
}
fun getUserCount(room: String, server: String): Int? { fun getUserCount(room: String, server: String): Int? {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
val index = "$server.$room" val index = "$server.$room"

View File

@@ -4,6 +4,7 @@ import android.content.ContentValues
import android.content.Context import android.content.Context
import net.zetetic.database.sqlcipher.SQLiteDatabase.CONFLICT_REPLACE import net.zetetic.database.sqlcipher.SQLiteDatabase.CONFLICT_REPLACE
import org.session.libsignal.database.LokiMessageDatabaseProtocol import org.session.libsignal.database.LokiMessageDatabaseProtocol
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), LokiMessageDatabaseProtocol { class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), LokiMessageDatabaseProtocol {
@@ -13,6 +14,8 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
private val messageThreadMappingTable = "loki_message_thread_mapping_database" private val messageThreadMappingTable = "loki_message_thread_mapping_database"
private val errorMessageTable = "loki_error_message_database" private val errorMessageTable = "loki_error_message_database"
private val messageHashTable = "loki_message_hash_database" private val messageHashTable = "loki_message_hash_database"
private val smsHashTable = "loki_sms_hash_database"
private val mmsHashTable = "loki_mms_hash_database"
private val messageID = "message_id" private val messageID = "message_id"
private val serverID = "server_id" private val serverID = "server_id"
private val friendRequestStatus = "friend_request_status" private val friendRequestStatus = "friend_request_status"
@@ -32,6 +35,10 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
val updateMessageMappingTable = "ALTER TABLE $messageThreadMappingTable ADD COLUMN $serverID INTEGER DEFAULT 0; ALTER TABLE $messageThreadMappingTable ADD CONSTRAINT PK_$messageThreadMappingTable PRIMARY KEY ($messageID, $serverID);" val updateMessageMappingTable = "ALTER TABLE $messageThreadMappingTable ADD COLUMN $serverID INTEGER DEFAULT 0; ALTER TABLE $messageThreadMappingTable ADD CONSTRAINT PK_$messageThreadMappingTable PRIMARY KEY ($messageID, $serverID);"
@JvmStatic @JvmStatic
val createMessageHashTableCommand = "CREATE TABLE IF NOT EXISTS $messageHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);" val createMessageHashTableCommand = "CREATE TABLE IF NOT EXISTS $messageHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);"
@JvmStatic
val createMmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $mmsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);"
@JvmStatic
val createSmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $smsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);"
const val SMS_TYPE = 0 const val SMS_TYPE = 0
const val MMS_TYPE = 1 const val MMS_TYPE = 1
@@ -66,7 +73,12 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
"${Companion.messageID} = ? AND $messageType = ?", "${Companion.messageID} = ? AND $messageType = ?",
arrayOf(messageID.toString(), (if (isSms) SMS_TYPE else MMS_TYPE).toString())) { cursor -> arrayOf(messageID.toString(), (if (isSms) SMS_TYPE else MMS_TYPE).toString())) { cursor ->
cursor.getInt(serverID).toLong() cursor.getInt(serverID).toLong()
} ?: return }
if (serverID == null) {
Log.w(this::class.simpleName, "Could not get server ID to delete message with ID: $messageID")
return
}
database.beginTransaction() database.beginTransaction()
@@ -201,52 +213,52 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
messages.add(cursor.getLong(messageID) to cursor.getLong(serverID)) messages.add(cursor.getLong(messageID) to cursor.getLong(serverID))
} }
} }
var deletedCount = 0L
database.beginTransaction() database.beginTransaction()
messages.forEach { (messageId, serverId) -> messages.forEach { (messageId, serverId) ->
deletedCount += database.delete(messageIDTable, "$messageID = ? AND $serverID = ?", arrayOf(messageId.toString(), serverId.toString())) database.delete(messageIDTable, "$messageID = ? AND $serverID = ?", arrayOf(messageId.toString(), serverId.toString()))
} }
val mappingDeleted = database.delete(messageThreadMappingTable, "$threadID = ?", arrayOf(threadId.toString())) database.delete(messageThreadMappingTable, "$threadID = ?", arrayOf(threadId.toString()))
database.setTransactionSuccessful() database.setTransactionSuccessful()
} finally { } finally {
database.endTransaction() database.endTransaction()
} }
} }
fun getMessageServerHash(messageID: Long): String? { fun getMessageServerHash(messageID: Long, mms: Boolean): String? = getMessageTables(mms).firstNotNullOfOrNull {
val database = databaseHelper.readableDatabase databaseHelper.readableDatabase.get(it, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor ->
return database.get(messageHashTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor ->
cursor.getString(serverHash) cursor.getString(serverHash)
} }
} }
fun setMessageServerHash(messageID: Long, serverHash: String) { fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) {
val database = databaseHelper.writableDatabase val contentValues = ContentValues(2).apply {
val contentValues = ContentValues(2) put(Companion.messageID, messageID)
contentValues.put(Companion.messageID, messageID) put(Companion.serverHash, serverHash)
contentValues.put(Companion.serverHash, serverHash) }
database.insertOrUpdate(messageHashTable, contentValues, "${Companion.messageID} = ?", arrayOf(messageID.toString()))
databaseHelper.writableDatabase.apply {
insertOrUpdate(getMessageTable(mms), contentValues, "${Companion.messageID} = ?", arrayOf(messageID.toString()))
}
} }
fun deleteMessageServerHash(messageID: Long) { fun deleteMessageServerHash(messageID: Long, mms: Boolean) {
val database = databaseHelper.writableDatabase getMessageTables(mms).firstOrNull {
database.delete(messageHashTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) databaseHelper.writableDatabase.delete(it, "${Companion.messageID} = ?", arrayOf(messageID.toString())) > 0
}
} }
fun deleteMessageServerHashes(messageIDs: List<Long>) { fun deleteMessageServerHashes(messageIDs: List<Long>, mms: Boolean) {
val database = databaseHelper.writableDatabase databaseHelper.writableDatabase.delete(
database.delete( getMessageTable(mms),
messageHashTable, "${Companion.messageID} IN (${messageIDs.joinToString(",") { "?" }})",
"${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})",
messageIDs.map { "$it" }.toTypedArray() messageIDs.map { "$it" }.toTypedArray()
) )
} }
fun migrateThreadId(legacyThreadId: Long, newThreadId: Long) { private fun getMessageTables(mms: Boolean) = sequenceOf(
val database = databaseHelper.writableDatabase getMessageTable(mms),
val contentValues = ContentValues(1) messageHashTable
contentValues.put(threadID, newThreadId) )
database.update(messageThreadMappingTable, contentValues, "$threadID = ?", arrayOf(legacyThreadId.toString()))
}
private fun getMessageTable(mms: Boolean) = if (mms) mmsHashTable else smsHashTable
} }

View File

@@ -0,0 +1,14 @@
package org.thoughtcrime.securesms.database
import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType
import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId
data class MarkedMessageInfo(val syncMessageId: SyncMessageId, val expirationInfo: ExpirationInfo) {
val expiryType get() = when {
syncMessageId.timetamp == expirationInfo.expireStarted -> ExpiryType.AFTER_SEND
expirationInfo.expiresIn > 0 -> ExpiryType.AFTER_READ
else -> ExpiryType.NONE
}
val expiryMode get() = expiryType.mode(expirationInfo.expiresIn)
}

View File

@@ -68,7 +68,7 @@ public class MediaDatabase extends Database {
public Cursor getGalleryMediaForThread(long threadId) { public Cursor getGalleryMediaForThread(long threadId) {
SQLiteDatabase database = databaseHelper.getReadableDatabase(); SQLiteDatabase database = databaseHelper.getReadableDatabase();
Cursor cursor = database.rawQuery(GALLERY_MEDIA_QUERY, new String[]{threadId+""}); Cursor cursor = database.rawQuery(GALLERY_MEDIA_QUERY, new String[]{threadId+""});
setNotifyConverationListeners(cursor, threadId); setNotifyConversationListeners(cursor, threadId);
return cursor; return cursor;
} }
@@ -83,7 +83,7 @@ public class MediaDatabase extends Database {
public Cursor getDocumentMediaForThread(long threadId) { public Cursor getDocumentMediaForThread(long threadId) {
SQLiteDatabase database = databaseHelper.getReadableDatabase(); SQLiteDatabase database = databaseHelper.getReadableDatabase();
Cursor cursor = database.rawQuery(DOCUMENT_MEDIA_QUERY, new String[]{threadId+""}); Cursor cursor = database.rawQuery(DOCUMENT_MEDIA_QUERY, new String[]{threadId+""});
setNotifyConverationListeners(cursor, threadId); setNotifyConversationListeners(cursor, threadId);
return cursor; return cursor;
} }

View File

@@ -14,6 +14,7 @@ import org.session.libsession.utilities.IdentityKeyMismatchList;
import org.session.libsignal.crypto.IdentityKey; import org.session.libsignal.crypto.IdentityKey;
import org.session.libsignal.utilities.JsonUtil; import org.session.libsignal.utilities.JsonUtil;
import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.util.SqlUtil; import org.thoughtcrime.securesms.util.SqlUtil;
@@ -33,7 +34,6 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
protected abstract String getTableName(); protected abstract String getTableName();
public abstract void markExpireStarted(long messageId);
public abstract void markExpireStarted(long messageId, long startTime); public abstract void markExpireStarted(long messageId, long startTime);
public abstract void markAsSent(long messageId, boolean secure); public abstract void markAsSent(long messageId, boolean secure);
@@ -225,56 +225,6 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
} }
} }
public static class ExpirationInfo {
private final long id;
private final long expiresIn;
private final long expireStarted;
private final boolean mms;
public ExpirationInfo(long id, long expiresIn, long expireStarted, boolean mms) {
this.id = id;
this.expiresIn = expiresIn;
this.expireStarted = expireStarted;
this.mms = mms;
}
public long getId() {
return id;
}
public long getExpiresIn() {
return expiresIn;
}
public long getExpireStarted() {
return expireStarted;
}
public boolean isMms() {
return mms;
}
}
public static class MarkedMessageInfo {
private final SyncMessageId syncMessageId;
private final ExpirationInfo expirationInfo;
public MarkedMessageInfo(SyncMessageId syncMessageId, ExpirationInfo expirationInfo) {
this.syncMessageId = syncMessageId;
this.expirationInfo = expirationInfo;
}
public SyncMessageId getSyncMessageId() {
return syncMessageId;
}
public ExpirationInfo getExpirationInfo() {
return expirationInfo;
}
}
public static class InsertResult { public static class InsertResult {
private final long messageId; private final long messageId;
private final long threadId; private final long threadId;

View File

@@ -21,9 +21,11 @@ import android.content.Context
import android.database.Cursor import android.database.Cursor
import com.annimon.stream.Stream import com.annimon.stream.Stream
import com.google.android.mms.pdu_alt.PduHeaders import com.google.android.mms.pdu_alt.PduHeaders
import org.apache.commons.lang3.StringUtils
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.signal.IncomingMediaMessage import org.session.libsession.messaging.messages.signal.IncomingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
@@ -212,7 +214,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
fun getMessage(messageId: Long): Cursor { fun getMessage(messageId: Long): Cursor {
val cursor = rawQuery(RAW_ID_WHERE, arrayOf(messageId.toString())) val cursor = rawQuery(RAW_ID_WHERE, arrayOf(messageId.toString()))
setNotifyConverationListeners(cursor, getThreadIdForMessage(messageId)) setNotifyConversationListeners(cursor, getThreadIdForMessage(messageId))
return cursor return cursor
} }
@@ -222,6 +224,12 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
return readerFor(rawQuery(where, null))!! return readerFor(rawQuery(where, null))!!
} }
val expireNotStartedMessages: Reader
get() {
val where = "$EXPIRES_IN > 0 AND $EXPIRE_STARTED = 0"
return readerFor(rawQuery(where, null))!!
}
private fun updateMailboxBitmask( private fun updateMailboxBitmask(
id: Long, id: Long,
maskOff: Long, maskOff: Long,
@@ -296,10 +304,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId) markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId)
} }
override fun markExpireStarted(messageId: Long) {
markExpireStarted(messageId, SnodeAPI.nowWithOffset)
}
override fun markExpireStarted(messageId: Long, startedTimestamp: Long) { override fun markExpireStarted(messageId: Long, startedTimestamp: Long) {
val contentValues = ContentValues() val contentValues = ContentValues()
contentValues.put(EXPIRE_STARTED, startedTimestamp) contentValues.put(EXPIRE_STARTED, startedTimestamp)
@@ -347,13 +351,14 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
) )
while (cursor != null && cursor.moveToNext()) { while (cursor != null && cursor.moveToNext()) {
if (MmsSmsColumns.Types.isSecureType(cursor.getLong(3))) { if (MmsSmsColumns.Types.isSecureType(cursor.getLong(3))) {
val syncMessageId = val timestamp = cursor.getLong(2)
SyncMessageId(fromSerialized(cursor.getString(1)), cursor.getLong(2)) val syncMessageId = SyncMessageId(fromSerialized(cursor.getString(1)), timestamp)
val expirationInfo = ExpirationInfo( val expirationInfo = ExpirationInfo(
cursor.getLong(0), id = cursor.getLong(0),
cursor.getLong(4), timestamp = timestamp,
cursor.getLong(5), expiresIn = cursor.getLong(4),
true expireStarted = cursor.getLong(5),
isMms = true
) )
result.add(MarkedMessageInfo(syncMessageId, expirationInfo)) result.add(MarkedMessageInfo(syncMessageId, expirationInfo))
} }
@@ -383,6 +388,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)) val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT))
val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID)) val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID))
val expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN)) val expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN))
val expireStartedAt = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRE_STARTED))
val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)) val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS))
val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)) val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID))
val distributionType = get(context).threadDatabase().getDistributionType(threadId) val distributionType = get(context).threadDatabase().getDistributionType(threadId)
@@ -451,6 +457,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
timestamp, timestamp,
subscriptionId, subscriptionId,
expiresIn, expiresIn,
expireStartedAt,
distributionType, distributionType,
quote, quote,
contacts, contacts,
@@ -550,6 +557,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
runThreadUpdate: Boolean runThreadUpdate: Boolean
): Optional<InsertResult> { ): Optional<InsertResult> {
if (threadId < 0 ) throw MmsException("No thread ID supplied!") if (threadId < 0 ) throw MmsException("No thread ID supplied!")
if (retrieved.isExpirationUpdate) deleteExpirationTimerMessages(threadId, false.takeUnless { retrieved.groupId != null })
val contentValues = ContentValues() val contentValues = ContentValues()
contentValues.put(DATE_SENT, retrieved.sentTimeMillis) contentValues.put(DATE_SENT, retrieved.sentTimeMillis)
contentValues.put(ADDRESS, retrieved.from.serialize()) contentValues.put(ADDRESS, retrieved.from.serialize())
@@ -570,7 +578,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
contentValues.put(PART_COUNT, retrieved.attachments.size) contentValues.put(PART_COUNT, retrieved.attachments.size)
contentValues.put(SUBSCRIPTION_ID, retrieved.subscriptionId) contentValues.put(SUBSCRIPTION_ID, retrieved.subscriptionId)
contentValues.put(EXPIRES_IN, retrieved.expiresIn) contentValues.put(EXPIRES_IN, retrieved.expiresIn)
contentValues.put(READ, if (retrieved.isExpirationUpdate) 1 else 0) contentValues.put(EXPIRE_STARTED, retrieved.expireStartedAt)
contentValues.put(UNIDENTIFIED, retrieved.isUnidentified) contentValues.put(UNIDENTIFIED, retrieved.isUnidentified)
contentValues.put(HAS_MENTION, retrieved.hasMention()) contentValues.put(HAS_MENTION, retrieved.hasMention())
contentValues.put(MESSAGE_REQUEST_RESPONSE, retrieved.isMessageRequestResponse) contentValues.put(MESSAGE_REQUEST_RESPONSE, retrieved.isMessageRequestResponse)
@@ -619,8 +627,10 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
runThreadUpdate: Boolean runThreadUpdate: Boolean
): Optional<InsertResult> { ): Optional<InsertResult> {
if (threadId < 0 ) throw MmsException("No thread ID supplied!") 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) val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate)
if (messageId == -1L) { if (messageId == -1L) {
Log.w(TAG, "insertSecureDecryptedMessageOutbox believes the MmsDatabase insertion failed.")
return Optional.absent() return Optional.absent()
} }
markAsSent(messageId, true) markAsSent(messageId, true)
@@ -689,6 +699,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
contentValues.put(DATE_RECEIVED, receivedTimestamp) contentValues.put(DATE_RECEIVED, receivedTimestamp)
contentValues.put(SUBSCRIPTION_ID, message.subscriptionId) contentValues.put(SUBSCRIPTION_ID, message.subscriptionId)
contentValues.put(EXPIRES_IN, message.expiresIn) 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.serialize())
contentValues.put( contentValues.put(
DELIVERY_RECEIPT_COUNT, DELIVERY_RECEIPT_COUNT,
@@ -849,8 +860,10 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
*/ */
private fun deleteMessages(messageIds: Array<String?>) { private fun deleteMessages(messageIds: Array<String?>) {
if (messageIds.isEmpty()) { if (messageIds.isEmpty()) {
Log.w(TAG, "No message Ids provided to MmsDatabase.deleteMessages - aborting delete operation!")
return return
} }
// don't need thread IDs // don't need thread IDs
val queryBuilder = StringBuilder() val queryBuilder = StringBuilder()
for (i in messageIds.indices) { for (i in messageIds.indices) {
@@ -873,6 +886,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
notifyStickerPackListeners() notifyStickerPackListeners()
} }
// Caution: The bool returned from `deleteMessage` is NOT "Was the message successfully deleted?"
// - it is "Was the thread deleted because removing that message resulted in an empty thread"!
override fun deleteMessage(messageId: Long): Boolean { override fun deleteMessage(messageId: Long): Boolean {
val threadId = getThreadIdForMessage(messageId) val threadId = getThreadIdForMessage(messageId)
val attachmentDatabase = get(context).attachmentDatabase() val attachmentDatabase = get(context).attachmentDatabase()
@@ -889,14 +904,15 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
} }
override fun deleteMessages(messageIds: LongArray, threadId: Long): Boolean { override fun deleteMessages(messageIds: LongArray, threadId: Long): Boolean {
val attachmentDatabase = get(context).attachmentDatabase() val argsArray = messageIds.map { "?" }
val groupReceiptDatabase = get(context).groupReceiptDatabase() val argValues = messageIds.map { it.toString() }.toTypedArray()
queue(Runnable { attachmentDatabase.deleteAttachmentsForMessages(messageIds) }) val db = databaseHelper.writableDatabase
groupReceiptDatabase.deleteRowsForMessages(messageIds) db.delete(
TABLE_NAME,
val database = databaseHelper.writableDatabase ID + " IN (" + StringUtils.join(argsArray, ',') + ")",
database!!.delete(TABLE_NAME, ID_IN, arrayOf(messageIds.joinToString(","))) argValues
)
val threadDeleted = get(context).threadDatabase().update(threadId, false, true) val threadDeleted = get(context).threadDatabase().update(threadId, false, true)
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
@@ -1079,8 +1095,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
} }
val whereString = where.substring(0, where.length - 4) val whereString = where.substring(0, where.length - 4)
try { try {
cursor = cursor = db!!.query(TABLE_NAME, arrayOf<String?>(ID), whereString, null, null, null, null)
db!!.query(TABLE_NAME, arrayOf<String?>(ID), whereString, null, null, null, null)
val toDeleteStringMessageIds = mutableListOf<String>() val toDeleteStringMessageIds = mutableListOf<String>()
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
toDeleteStringMessageIds += cursor.getLong(0).toString() toDeleteStringMessageIds += cursor.getLong(0).toString()
@@ -1132,13 +1147,9 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
} }
} }
fun readerFor(cursor: Cursor?): Reader { fun readerFor(cursor: Cursor?, getQuote: Boolean = true) = Reader(cursor, getQuote)
return Reader(cursor)
}
fun readerFor(message: OutgoingMediaMessage?, threadId: Long): OutgoingMessageReader { fun readerFor(message: OutgoingMediaMessage?, threadId: Long) = OutgoingMessageReader(message, threadId)
return OutgoingMessageReader(message, threadId)
}
fun setQuoteMissing(messageId: Long): Int { fun setQuoteMissing(messageId: Long): Int {
val contentValues = ContentValues() val contentValues = ContentValues()
@@ -1152,6 +1163,20 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
) )
} }
/**
* @param outgoing if true only delete outgoing messages, if false only delete incoming messages, if null delete both.
*/
private fun deleteExpirationTimerMessages(threadId: Long, outgoing: Boolean? = null) {
val outgoingClause = outgoing?.takeIf { ExpirationConfiguration.isNewConfigEnabled }?.let {
val comparison = if (it) "IN" else "NOT IN"
" AND $MESSAGE_BOX & ${MmsSmsColumns.Types.BASE_TYPE_MASK} $comparison (${MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES.joinToString()})"
} ?: ""
val where = "$THREAD_ID = ? AND ($MESSAGE_BOX & ${MmsSmsColumns.Types.EXPIRATION_TIMER_UPDATE_BIT}) <> 0" + outgoingClause
writableDatabase.delete(TABLE_NAME, where, arrayOf("$threadId"))
notifyConversationListeners(threadId)
}
object Status { object Status {
const val DOWNLOAD_INITIALIZED = 1 const val DOWNLOAD_INITIALIZED = 1
const val DOWNLOAD_NO_CONNECTIVITY = 2 const val DOWNLOAD_NO_CONNECTIVITY = 2
@@ -1188,7 +1213,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
} }
inner class Reader(private val cursor: Cursor?) : Closeable { inner class Reader(private val cursor: Cursor?, private val getQuote: Boolean = true) : Closeable {
val next: MessageRecord? val next: MessageRecord?
get() = if (cursor == null || !cursor.moveToNext()) null else current get() = if (cursor == null || !cursor.moveToNext()) null else current
val current: MessageRecord val current: MessageRecord
@@ -1197,7 +1222,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
return if (mmsType == PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND.toLong()) { return if (mmsType == PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND.toLong()) {
getNotificationMmsMessageRecord(cursor) getNotificationMmsMessageRecord(cursor)
} else { } else {
getMediaMmsMessageRecord(cursor) getMediaMmsMessageRecord(cursor, getQuote)
} }
} }
@@ -1224,20 +1249,10 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
DELIVERY_RECEIPT_COUNT DELIVERY_RECEIPT_COUNT
) )
) )
var readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT)) val readReceiptCount = if (isReadReceiptsEnabled(context)) cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT)) else 0
val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID))
val hasMention = (cursor.getInt(cursor.getColumnIndexOrThrow(HAS_MENTION)) == 1) val hasMention = (cursor.getInt(cursor.getColumnIndexOrThrow(HAS_MENTION)) == 1)
if (!isReadReceiptsEnabled(context)) { val contentLocationBytes: ByteArray? = contentLocation?.takeUnless { it.isEmpty() }?.let(::toIsoBytes)
readReceiptCount = 0 val transactionIdBytes: ByteArray? = transactionId?.takeUnless { it.isEmpty() }?.let(::toIsoBytes)
}
var contentLocationBytes: ByteArray? = null
var transactionIdBytes: ByteArray? = null
if (!contentLocation.isNullOrEmpty()) contentLocationBytes = toIsoBytes(
contentLocation
)
if (!transactionId.isNullOrEmpty()) transactionIdBytes = toIsoBytes(
transactionId
)
val slideDeck = SlideDeck(context, MmsNotificationAttachment(status, messageSize)) val slideDeck = SlideDeck(context, MmsNotificationAttachment(status, messageSize))
return NotificationMmsMessageRecord( return NotificationMmsMessageRecord(
id, recipient, recipient, id, recipient, recipient,
@@ -1248,7 +1263,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
) )
} }
private fun getMediaMmsMessageRecord(cursor: Cursor): MediaMmsMessageRecord { private fun getMediaMmsMessageRecord(cursor: Cursor, getQuote: Boolean): MediaMmsMessageRecord {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)) val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID))
val dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)) val dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT))
val dateReceived = cursor.getLong( val dateReceived = cursor.getLong(
@@ -1299,7 +1314,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
.filterNot { o: DatabaseAttachment? -> o in contactAttachments } .filterNot { o: DatabaseAttachment? -> o in contactAttachments }
.filterNot { o: DatabaseAttachment? -> o in previewAttachments } .filterNot { o: DatabaseAttachment? -> o in previewAttachments }
) )
val quote = getQuote(cursor) val quote = if (getQuote) getQuote(cursor) else null
val reactions = get(context).reactionDatabase().getReactions(cursor) val reactions = get(context).reactionDatabase().getReactions(cursor)
return MediaMmsMessageRecord( return MediaMmsMessageRecord(
id, recipient, recipient, id, recipient, recipient,
@@ -1352,7 +1367,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID)) val quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID))
val quoteAuthor = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR)) val quoteAuthor = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR))
if (quoteId == 0L || quoteAuthor.isNullOrBlank()) return null if (quoteId == 0L || quoteAuthor.isNullOrBlank()) return null
val retrievedQuote = get(context).mmsSmsDatabase().getMessageFor(quoteId, quoteAuthor) val retrievedQuote = get(context).mmsSmsDatabase().getMessageFor(quoteId, quoteAuthor, false)
val quoteText = retrievedQuote?.body val quoteText = retrievedQuote?.body
val quoteMissing = retrievedQuote == null val quoteMissing = retrievedQuote == null
val quoteDeck = ( val quoteDeck = (
@@ -1398,7 +1413,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
const val SHARED_CONTACTS: String = "shared_contacts" const val SHARED_CONTACTS: String = "shared_contacts"
const val LINK_PREVIEWS: String = "previews" const val LINK_PREVIEWS: String = "previews"
const val CREATE_TABLE: String = const val CREATE_TABLE: String =
"CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
THREAD_ID + " INTEGER, " + DATE_SENT + " INTEGER, " + DATE_RECEIVED + " INTEGER, " + MESSAGE_BOX + " INTEGER, " + THREAD_ID + " INTEGER, " + DATE_SENT + " INTEGER, " + DATE_RECEIVED + " INTEGER, " + MESSAGE_BOX + " INTEGER, " +
READ + " INTEGER DEFAULT 0, " + "m_id" + " TEXT, " + "sub" + " TEXT, " + READ + " INTEGER DEFAULT 0, " + "m_id" + " TEXT, " + "sub" + " TEXT, " +
"sub_cs" + " INTEGER, " + BODY + " TEXT, " + PART_COUNT + " INTEGER, " + "sub_cs" + " INTEGER, " + BODY + " TEXT, " + PART_COUNT + " INTEGER, " +
@@ -1503,5 +1518,21 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
const val CREATE_REACTIONS_UNREAD_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_UNREAD INTEGER DEFAULT 0;" const val CREATE_REACTIONS_UNREAD_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_UNREAD INTEGER DEFAULT 0;"
const val CREATE_REACTIONS_LAST_SEEN_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_LAST_SEEN INTEGER DEFAULT 0;" const val CREATE_REACTIONS_LAST_SEEN_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_LAST_SEEN INTEGER DEFAULT 0;"
const val CREATE_HAS_MENTION_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $HAS_MENTION INTEGER DEFAULT 0;" const val CREATE_HAS_MENTION_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $HAS_MENTION INTEGER DEFAULT 0;"
private const val TEMP_TABLE_NAME = "TEMP_TABLE_NAME"
const val COMMA_SEPARATED_COLUMNS = "$ID, $THREAD_ID, $DATE_SENT, $DATE_RECEIVED, $MESSAGE_BOX, $READ, m_id, sub, sub_cs, $BODY, $PART_COUNT, ct_t, $CONTENT_LOCATION, $ADDRESS, $ADDRESS_DEVICE_ID, $EXPIRY, m_cls, $MESSAGE_TYPE, v, $MESSAGE_SIZE, pri, rr,rpt_a, resp_st, $STATUS, $TRANSACTION_ID, retr_st, retr_txt, retr_txt_cs, read_status, ct_cls, resp_txt, d_tm, $DELIVERY_RECEIPT_COUNT, $MISMATCHED_IDENTITIES, $NETWORK_FAILURE, d_rpt, $SUBSCRIPTION_ID, $EXPIRES_IN, $EXPIRE_STARTED, $NOTIFIED, $READ_RECEIPT_COUNT, $QUOTE_ID, $QUOTE_AUTHOR, $QUOTE_BODY, $QUOTE_ATTACHMENT, $QUOTE_MISSING, $SHARED_CONTACTS, $UNIDENTIFIED, $LINK_PREVIEWS, $MESSAGE_REQUEST_RESPONSE, $REACTIONS_UNREAD, $REACTIONS_LAST_SEEN, $HAS_MENTION"
@JvmField
val ADD_AUTOINCREMENT = arrayOf(
"ALTER TABLE $TABLE_NAME RENAME TO $TEMP_TABLE_NAME",
CREATE_TABLE,
CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND,
CREATE_REACTIONS_UNREAD_COMMAND,
CREATE_REACTIONS_LAST_SEEN_COMMAND,
CREATE_HAS_MENTION_COMMAND,
"INSERT INTO $TABLE_NAME ($COMMA_SEPARATED_COLUMNS) SELECT $COMMA_SEPARATED_COLUMNS FROM $TEMP_TABLE_NAME",
"DROP TABLE $TEMP_TABLE_NAME"
)
} }
} }

View File

@@ -9,7 +9,11 @@ public interface MmsSmsColumns {
public static final String THREAD_ID = "thread_id"; public static final String THREAD_ID = "thread_id";
public static final String READ = "read"; public static final String READ = "read";
public static final String BODY = "body"; public static final String BODY = "body";
// This is the address of the message recipient, which may be a single user, a group, or a community!
// It is NOT the address of the sender of any given message!
public static final String ADDRESS = "address"; public static final String ADDRESS = "address";
public static final String ADDRESS_DEVICE_ID = "address_device_id"; public static final String ADDRESS_DEVICE_ID = "address_device_id";
public static final String DELIVERY_RECEIPT_COUNT = "delivery_receipt_count"; public static final String DELIVERY_RECEIPT_COUNT = "delivery_receipt_count";
public static final String READ_RECEIPT_COUNT = "read_receipt_count"; public static final String READ_RECEIPT_COUNT = "read_receipt_count";

View File

@@ -30,6 +30,7 @@ import net.zetetic.database.sqlcipher.SQLiteQueryBuilder;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.Util; import org.session.libsession.utilities.Util;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId; import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
@@ -96,9 +97,13 @@ public class MmsSmsDatabase extends Database {
} }
public @Nullable MessageRecord getMessageFor(long timestamp, String serializedAuthor) { public @Nullable MessageRecord getMessageFor(long timestamp, String serializedAuthor) {
return getMessageFor(timestamp, serializedAuthor, true);
}
public @Nullable MessageRecord getMessageFor(long timestamp, String serializedAuthor, boolean getQuote) {
try (Cursor cursor = queryTables(PROJECTION, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, null, null)) { try (Cursor cursor = queryTables(PROJECTION, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, null, null)) {
MmsSmsDatabase.Reader reader = readerFor(cursor); MmsSmsDatabase.Reader reader = readerFor(cursor, getQuote);
MessageRecord messageRecord; MessageRecord messageRecord;
boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor); boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor);
@@ -115,6 +120,53 @@ public class MmsSmsDatabase extends Database {
return null; return null;
} }
public @Nullable MessageRecord getSentMessageFor(long timestamp, String serializedAuthor) {
// Early exit if the author is not us
boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor);
if (!isOwnNumber) {
Log.i(TAG, "Asked to find sent messages but provided author is not us - returning null.");
return null;
}
try (Cursor cursor = queryTables(PROJECTION, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, null, null)) {
MmsSmsDatabase.Reader reader = readerFor(cursor);
MessageRecord messageRecord;
while ((messageRecord = reader.getNext()) != null) {
if (messageRecord.isOutgoing())
{
return messageRecord;
}
}
}
Log.i(TAG, "Could not find any message sent from us at provided timestamp - returning null.");
return null;
}
public MessageRecord getLastSentMessageRecordFromSender(long threadId, String serializedAuthor) {
// Early exit if the author is not us
boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor);
if (!isOwnNumber) {
Log.i(TAG, "Asked to find last sent message but provided author is not us - returning null.");
return null;
}
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC";
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
// Try everything with resources so that they auto-close on end of scope
try (Cursor cursor = queryTables(PROJECTION, selection, order, null)) {
try (MmsSmsDatabase.Reader reader = readerFor(cursor)) {
MessageRecord messageRecord;
while ((messageRecord = reader.getNext()) != null) {
if (messageRecord.isOutgoing()) { return messageRecord; }
}
}
}
Log.i(TAG, "Could not find last sent message from us in given thread - returning null.");
return null;
}
public @Nullable MessageRecord getMessageFor(long timestamp, Address author) { public @Nullable MessageRecord getMessageFor(long timestamp, Address author) {
return getMessageFor(timestamp, author.serialize()); return getMessageFor(timestamp, author.serialize());
} }
@@ -183,7 +235,7 @@ public class MmsSmsDatabase extends Database {
String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null; String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null;
Cursor cursor = queryTables(PROJECTION, selection, order, limitStr); Cursor cursor = queryTables(PROJECTION, selection, order, limitStr);
setNotifyConverationListeners(cursor, threadId); setNotifyConversationListeners(cursor, threadId);
return cursor; return cursor;
} }
@@ -209,6 +261,79 @@ public class MmsSmsDatabase extends Database {
} }
} }
// Builds up and returns a list of all all the messages sent by this user in the given thread.
// Used to do a pass through our local database to remove records when a user has "Ban & Delete"
// called on them in a Community.
public Set<MessageRecord> getAllMessageRecordsFromSenderInThread(long threadId, String serializedAuthor) {
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.ADDRESS + " = \"" + serializedAuthor + "\"";
Set<MessageRecord> identifiedMessages = new HashSet<MessageRecord>();
// Try everything with resources so that they auto-close on end of scope
try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) {
try (MmsSmsDatabase.Reader reader = readerFor(cursor)) {
MessageRecord messageRecord;
while ((messageRecord = reader.getNext()) != null) {
identifiedMessages.add(messageRecord);
}
}
}
return identifiedMessages;
}
// Version of the above `getAllMessageRecordsFromSenderInThread` method that returns the message
// Ids rather than the set of MessageRecords - currently unused by potentially useful in the future.
public Set<Long> getAllMessageIdsFromSenderInThread(long threadId, String serializedAuthor) {
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.ADDRESS + " = \"" + serializedAuthor + "\"";
Set<Long> identifiedMessages = new HashSet<Long>();
// Try everything with resources so that they auto-close on end of scope
try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) {
try (MmsSmsDatabase.Reader reader = readerFor(cursor)) {
MessageRecord messageRecord;
while ((messageRecord = reader.getNext()) != null) {
identifiedMessages.add(messageRecord.id);
}
}
}
return identifiedMessages;
}
public long getLastOutgoingTimestamp(long threadId) {
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC";
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
// Try everything with resources so that they auto-close on end of scope
try (Cursor cursor = queryTables(PROJECTION, selection, order, null)) {
try (MmsSmsDatabase.Reader reader = readerFor(cursor)) {
MessageRecord messageRecord;
long attempts = 0;
long maxAttempts = 20;
while ((messageRecord = reader.getNext()) != null) {
// Note: We rely on the message order to get us the most recent outgoing message - so we
// take the first outgoing message we find as the last outgoing message.
if (messageRecord.isOutgoing()) return messageRecord.getTimestamp();
if (attempts++ > maxAttempts) break;
}
}
}
Log.i(TAG, "Could not find last sent message from us - returning -1.");
return -1;
}
public long getLastMessageTimestamp(long threadId) {
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC";
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
try (Cursor cursor = queryTables(PROJECTION, selection, order, "1")) {
if (cursor.moveToFirst()) {
return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_SENT));
}
}
return -1;
}
public Cursor getUnread() { public Cursor getUnread() {
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " ASC"; String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " ASC";
String selection = "(" + MmsSmsColumns.READ + " = 0 OR " + MmsSmsColumns.REACTIONS_UNREAD + " = 1) AND " + MmsSmsColumns.NOTIFIED + " = 0"; String selection = "(" + MmsSmsColumns.READ + " = 0 OR " + MmsSmsColumns.REACTIONS_UNREAD + " = 1) AND " + MmsSmsColumns.NOTIFIED + " = 0";
@@ -514,7 +639,11 @@ public class MmsSmsDatabase extends Database {
} }
public Reader readerFor(@NonNull Cursor cursor) { public Reader readerFor(@NonNull Cursor cursor) {
return new Reader(cursor); return readerFor(cursor, true);
}
public Reader readerFor(@NonNull Cursor cursor, boolean getQuote) {
return new Reader(cursor, getQuote);
} }
@NotNull @NotNull
@@ -537,11 +666,13 @@ public class MmsSmsDatabase extends Database {
public class Reader implements Closeable { public class Reader implements Closeable {
private final Cursor cursor; private final Cursor cursor;
private final boolean getQuote;
private SmsDatabase.Reader smsReader; private SmsDatabase.Reader smsReader;
private MmsDatabase.Reader mmsReader; private MmsDatabase.Reader mmsReader;
public Reader(Cursor cursor) { public Reader(Cursor cursor, boolean getQuote) {
this.cursor = cursor; this.cursor = cursor;
this.getQuote = getQuote;
} }
private SmsDatabase.Reader getSmsReader() { private SmsDatabase.Reader getSmsReader() {
@@ -554,7 +685,7 @@ public class MmsSmsDatabase extends Database {
private MmsDatabase.Reader getMmsReader() { private MmsDatabase.Reader getMmsReader() {
if (mmsReader == null) { if (mmsReader == null) {
mmsReader = DatabaseComponent.get(context).mmsDatabase().readerFor(cursor); mmsReader = DatabaseComponent.get(context).mmsDatabase().readerFor(cursor, getQuote);
} }
return mmsReader; return mmsReader;

View File

@@ -1,6 +1,6 @@
package org.thoughtcrime.securesms.database; package org.thoughtcrime.securesms.database;
import static org.session.libsession.utilities.GroupUtil.OPEN_GROUP_PREFIX; import static org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX;
import android.content.ContentValues; import android.content.ContentValues;
import android.content.Context; import android.content.Context;
@@ -46,7 +46,8 @@ public class RecipientDatabase extends Database {
private static final String COLOR = "color"; private static final String COLOR = "color";
private static final String SEEN_INVITE_REMINDER = "seen_invite_reminder"; private static final String SEEN_INVITE_REMINDER = "seen_invite_reminder";
private static final String DEFAULT_SUBSCRIPTION_ID = "default_subscription_id"; private static final String DEFAULT_SUBSCRIPTION_ID = "default_subscription_id";
private static final String EXPIRE_MESSAGES = "expire_messages"; static final String EXPIRE_MESSAGES = "expire_messages";
private static final String DISAPPEARING_STATE = "disappearing_state";
private static final String REGISTERED = "registered"; private static final String REGISTERED = "registered";
private static final String PROFILE_KEY = "profile_key"; private static final String PROFILE_KEY = "profile_key";
private static final String SYSTEM_DISPLAY_NAME = "system_display_name"; private static final String SYSTEM_DISPLAY_NAME = "system_display_name";
@@ -63,13 +64,14 @@ public class RecipientDatabase extends Database {
private static final String FORCE_SMS_SELECTION = "force_sms_selection"; private static final String FORCE_SMS_SELECTION = "force_sms_selection";
private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none
private static final String WRAPPER_HASH = "wrapper_hash"; private static final String WRAPPER_HASH = "wrapper_hash";
private static final String BLOCKS_COMMUNITY_MESSAGE_REQUESTS = "blocks_community_message_requests";
private static final String[] RECIPIENT_PROJECTION = new String[] { private static final String[] RECIPIENT_PROJECTION = new String[] {
BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED, BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED,
PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI, PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI,
SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL, SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
UNIDENTIFIED_ACCESS_MODE, UNIDENTIFIED_ACCESS_MODE,
FORCE_SMS_SELECTION, NOTIFY_TYPE, WRAPPER_HASH FORCE_SMS_SELECTION, NOTIFY_TYPE, DISAPPEARING_STATE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS
}; };
static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION) static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
@@ -121,27 +123,37 @@ public class RecipientDatabase extends Database {
public static String getUpdateApprovedCommand() { public static String getUpdateApprovedCommand() {
return "UPDATE "+ TABLE_NAME + " " + return "UPDATE "+ TABLE_NAME + " " +
"SET " + APPROVED + " = 1, " + APPROVED_ME + " = 1 " + "SET " + APPROVED + " = 1, " + APPROVED_ME + " = 1 " +
"WHERE " + ADDRESS + " NOT LIKE '" + OPEN_GROUP_PREFIX + "%'"; "WHERE " + ADDRESS + " NOT LIKE '" + COMMUNITY_PREFIX + "%'";
} }
public static String getUpdateResetApprovedCommand() { public static String getUpdateResetApprovedCommand() {
return "UPDATE "+ TABLE_NAME + " " + return "UPDATE "+ TABLE_NAME + " " +
"SET " + APPROVED + " = 0, " + APPROVED_ME + " = 0 " + "SET " + APPROVED + " = 0, " + APPROVED_ME + " = 0 " +
"WHERE " + ADDRESS + " NOT LIKE '" + OPEN_GROUP_PREFIX + "%'"; "WHERE " + ADDRESS + " NOT LIKE '" + COMMUNITY_PREFIX + "%'";
} }
public static String getUpdateApprovedSelectConversations() { public static String getUpdateApprovedSelectConversations() {
return "UPDATE "+ TABLE_NAME + " SET "+APPROVED+" = 1, "+APPROVED_ME+" = 1 "+ return "UPDATE "+ TABLE_NAME + " SET "+APPROVED+" = 1, "+APPROVED_ME+" = 1 "+
"WHERE "+ADDRESS+ " NOT LIKE '"+OPEN_GROUP_PREFIX+"%' " + "WHERE "+ADDRESS+ " NOT LIKE '"+ COMMUNITY_PREFIX +"%' " +
"AND ("+ADDRESS+" IN (SELECT "+ThreadDatabase.TABLE_NAME+"."+ThreadDatabase.ADDRESS+" FROM "+ThreadDatabase.TABLE_NAME+" WHERE ("+ThreadDatabase.MESSAGE_COUNT+" != 0) "+ "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+")))"; "OR "+ADDRESS+" IN (SELECT "+GroupDatabase.TABLE_NAME+"."+GroupDatabase.ADMINS+" FROM "+GroupDatabase.TABLE_NAME+")))";
} }
public static String getCreateDisappearingStateCommand() {
return "ALTER TABLE "+ TABLE_NAME + " " +
"ADD COLUMN " + DISAPPEARING_STATE + " INTEGER DEFAULT 0;";
}
public static String getAddWrapperHash() { public static String getAddWrapperHash() {
return "ALTER TABLE "+TABLE_NAME+" "+ return "ALTER TABLE "+TABLE_NAME+" "+
"ADD COLUMN "+WRAPPER_HASH+" TEXT DEFAULT NULL;"; "ADD COLUMN "+WRAPPER_HASH+" TEXT DEFAULT NULL;";
} }
public static String getAddBlocksCommunityMessageRequests() {
return "ALTER TABLE "+TABLE_NAME+" "+
"ADD COLUMN "+BLOCKS_COMMUNITY_MESSAGE_REQUESTS+" INT DEFAULT 0;";
}
public static final int NOTIFY_TYPE_ALL = 0; public static final int NOTIFY_TYPE_ALL = 0;
public static final int NOTIFY_TYPE_MENTIONS = 1; public static final int NOTIFY_TYPE_MENTIONS = 1;
public static final int NOTIFY_TYPE_NONE = 2; public static final int NOTIFY_TYPE_NONE = 2;
@@ -177,6 +189,7 @@ public class RecipientDatabase extends Database {
boolean approvedMe = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED_ME)) == 1; boolean approvedMe = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED_ME)) == 1;
String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION)); String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION));
String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE)); String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE));
int disappearingState = cursor.getInt(cursor.getColumnIndexOrThrow(DISAPPEARING_STATE));
int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE)); int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE));
int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE)); int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE));
long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL)); long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL));
@@ -197,6 +210,7 @@ public class RecipientDatabase extends Database {
int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE)); int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE));
boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1; boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1;
String wrapperHash = cursor.getString(cursor.getColumnIndexOrThrow(WRAPPER_HASH)); String wrapperHash = cursor.getString(cursor.getColumnIndexOrThrow(WRAPPER_HASH));
boolean blocksCommunityMessageRequests = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCKS_COMMUNITY_MESSAGE_REQUESTS)) == 1;
MaterialColor color; MaterialColor color;
byte[] profileKey = null; byte[] profileKey = null;
@@ -219,6 +233,7 @@ public class RecipientDatabase extends Database {
return Optional.of(new RecipientSettings(blocked, approved, approvedMe, muteUntil, return Optional.of(new RecipientSettings(blocked, approved, approvedMe, muteUntil,
notifyType, notifyType,
Recipient.DisappearingState.fromId(disappearingState),
Recipient.VibrateState.fromId(messageVibrateState), Recipient.VibrateState.fromId(messageVibrateState),
Recipient.VibrateState.fromId(callVibrateState), Recipient.VibrateState.fromId(callVibrateState),
Util.uri(messageRingtone), Util.uri(callRingtone), Util.uri(messageRingtone), Util.uri(callRingtone),
@@ -228,7 +243,7 @@ public class RecipientDatabase extends Database {
systemPhoneLabel, systemContactUri, systemPhoneLabel, systemContactUri,
signalProfileName, signalProfileAvatar, profileSharing, signalProfileName, signalProfileAvatar, profileSharing,
notificationChannel, Recipient.UnidentifiedAccessMode.fromMode(unidentifiedAccessMode), notificationChannel, Recipient.UnidentifiedAccessMode.fromMode(unidentifiedAccessMode),
forceSmsSelection, wrapperHash)); forceSmsSelection, wrapperHash, blocksCommunityMessageRequests));
} }
public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) { public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) {
@@ -328,16 +343,6 @@ public class RecipientDatabase extends Database {
notifyRecipientListeners(); notifyRecipientListeners();
} }
public void setExpireMessages(@NonNull Recipient recipient, int expiration) {
recipient.setExpireMessages(expiration);
ContentValues values = new ContentValues(1);
values.put(EXPIRE_MESSAGES, expiration);
updateOrInsert(recipient.getAddress(), values);
recipient.resolve().setExpireMessages(expiration);
notifyRecipientListeners();
}
public void setUnidentifiedAccessMode(@NonNull Recipient recipient, @NonNull UnidentifiedAccessMode unidentifiedAccessMode) { public void setUnidentifiedAccessMode(@NonNull Recipient recipient, @NonNull UnidentifiedAccessMode unidentifiedAccessMode) {
ContentValues values = new ContentValues(1); ContentValues values = new ContentValues(1);
values.put(UNIDENTIFIED_ACCESS_MODE, unidentifiedAccessMode.getMode()); values.put(UNIDENTIFIED_ACCESS_MODE, unidentifiedAccessMode.getMode());
@@ -395,6 +400,14 @@ public class RecipientDatabase extends Database {
notifyRecipientListeners(); notifyRecipientListeners();
} }
public void setBlocksCommunityMessageRequests(@NonNull Recipient recipient, boolean isBlocked) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(BLOCKS_COMMUNITY_MESSAGE_REQUESTS, isBlocked ? 1 : 0);
updateOrInsert(recipient.getAddress(), contentValues);
recipient.resolve().setBlocksCommunityMessageRequests(isBlocked);
notifyRecipientListeners();
}
private void updateOrInsert(Address address, ContentValues contentValues) { private void updateOrInsert(Address address, ContentValues contentValues) {
SQLiteDatabase database = databaseHelper.getWritableDatabase(); SQLiteDatabase database = databaseHelper.getWritableDatabase();
@@ -428,6 +441,14 @@ public class RecipientDatabase extends Database {
return returnList; return returnList;
} }
public void setDisappearingState(@NonNull Recipient recipient, @NonNull Recipient.DisappearingState disappearingState) {
ContentValues values = new ContentValues();
values.put(DISAPPEARING_STATE, disappearingState.getId());
updateOrInsert(recipient.getAddress(), values);
recipient.resolve().setDisappearingState(disappearingState);
notifyRecipientListeners();
}
public static class RecipientReader implements Closeable { public static class RecipientReader implements Closeable {
private final Context context; private final Context context;

View File

@@ -10,6 +10,7 @@ import com.annimon.stream.Stream;
import net.zetetic.database.sqlcipher.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.session.libsession.utilities.Util; import org.session.libsession.utilities.Util;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import java.util.List; import java.util.List;
@@ -115,11 +116,9 @@ public class SearchDatabase extends Database {
public Cursor queryMessages(@NonNull String query) { public Cursor queryMessages(@NonNull String query) {
SQLiteDatabase db = databaseHelper.getReadableDatabase(); SQLiteDatabase db = databaseHelper.getReadableDatabase();
String prefixQuery = adjustQuery(query); String prefixQuery = adjustQuery(query);
int queryLimit = Math.min(query.length()*50,500); int queryLimit = Math.min(query.length()*50,500);
Cursor cursor = db.rawQuery(MESSAGES_QUERY, new String[] { prefixQuery, prefixQuery, String.valueOf(queryLimit) }); Cursor cursor = db.rawQuery(MESSAGES_QUERY, new String[] { prefixQuery, prefixQuery, String.valueOf(queryLimit) });
setNotifyConverationListListeners(cursor); setNotifyConversationListListeners(cursor);
return cursor; return cursor;
} }
@@ -128,7 +127,7 @@ public class SearchDatabase extends Database {
String prefixQuery = adjustQuery(query); String prefixQuery = adjustQuery(query);
Cursor cursor = db.rawQuery(MESSAGES_FOR_THREAD_QUERY, new String[] { prefixQuery, String.valueOf(threadId), prefixQuery, String.valueOf(threadId) }); Cursor cursor = db.rawQuery(MESSAGES_FOR_THREAD_QUERY, new String[] { prefixQuery, String.valueOf(threadId), prefixQuery, String.valueOf(threadId) });
setNotifyConverationListListeners(cursor); setNotifyConversationListListeners(cursor);
return cursor; return cursor;
} }

Some files were not shown because too many files have changed in this diff Show More