Merge branch 'dev' into swap-video-views

This commit is contained in:
Ryan ZHAO 2024-04-08 10:05:28 +10:00
commit 8678ac5b3e
622 changed files with 21580 additions and 22204 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
*.sh
pkcs11.password
play
app/play
app/huawei
!/scripts/drone-static-upload.sh
!/scripts/drone-upload-exists.sh

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "libsession-util/libsession-util"]
path = libsession-util/libsession-util
url = https://github.com/oxen-io/libsession-util.git

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

@ -32,6 +32,13 @@ Setting up a development environment and building from Android Studio
4. Android Studio should detect the presence of a project file and ask you whether to open it. Click "yes".
5. Default config options should be good enough.
6. Project initialization and building should proceed.
7. Clone submodules with `git submodule update --init --recursive`
If you would like to build the Huawei Flavor with Huawei HMS push notifications you will need to pass 'huawei' as a command line arg to include the required dependencies.
e.g. `./gradlew assembleHuaweiDebug -Phuawei`
If you are building in Android Studio then add `-Phuawei` to `Preferences > Build, Execution, Deployment > Gradle-Android Compiler > Command-line Options`
Contributing code
-----------------

View File

@ -1,16 +1,16 @@
# Session Android
# Session Android
[Download on the Google Play Store](https://getsession.org/android)
Add the [F-Droid repo](https://fdroid.getsession.org/)
[Download the APK from here](https://github.com/loki-project/session-android/releases/latest)
[Download the APK from here](https://github.com/oxen-io/session-android/releases/latest)
## Summary
Session integrates directly with [Oxen Service Nodes](https://docs.oxen.io/about-the-oxen-blockchain/oxen-service-nodes), which are a set of distributed, decentralized and Sybil resistant nodes. Service Nodes act as servers which store messages offline, and a set of nodes which allow for onion routing functionality obfuscating users' IP addresses. For a full understanding of how Session works, read the [Session Whitepaper](https://getsession.org/whitepaper).
<img src="https://i.imgur.com/dO9f7Hg.jpg" width="320" />
<img src="https://i.imgur.com/wcdAGBh.png" width="320" />
## Want to contribute? Found a bug or have a feature request?

View File

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

View File

@ -1,5 +1,6 @@
package network.loki.messenger
import android.Manifest
import android.app.Instrumentation
import android.content.ClipboardManager
import android.content.Context
@ -21,6 +22,7 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import com.adevinta.android.barista.interaction.PermissionGranter
import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable
import org.hamcrest.Matcher
import org.hamcrest.Matchers.allOf
@ -85,6 +87,8 @@ class HomeActivityTests {
}
onView(withId(R.id.backgroundPollingOptionView)).perform(ViewActions.click())
onView(withId(R.id.registerButton)).perform(ViewActions.click())
// allow notification permission
PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS)
}
private fun goToMyChat() {
@ -100,6 +104,7 @@ class HomeActivityTests {
copied = clipboardManager.primaryClip!!.getItemAt(0).text.toString()
}
onView(withId(R.id.publicKeyEditText)).perform(ViewActions.typeText(copied))
onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard())
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
}
@ -153,6 +158,7 @@ class HomeActivityTests {
val dialogPromptText = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.dialog_open_url_explanation, amazonPuny)
onView(isRoot()).perform(waitFor(1000)) // no other way for this to work apparently
onView(withText(dialogPromptText)).check(matches(isDisplayed()))
}

View File

@ -0,0 +1,195 @@
package network.loki.messenger
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.Contacts
import network.loki.messenger.libsession_util.ConversationVolatileConfig
import network.loki.messenger.libsession_util.util.Contact
import network.loki.messenger.libsession_util.util.Conversation
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.CoreMatchers.instanceOf
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.argThat
import org.mockito.kotlin.argWhere
import org.mockito.kotlin.eq
import org.mockito.kotlin.spy
import org.mockito.kotlin.verify
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
@SmallTest
class LibSessionTests {
private fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() }
private fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray())
private fun randomSessionId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey
private var fakeHashI = 0
private val nextFakeHash: String
get() = "fakehash${fakeHashI++}"
private fun maybeGetUserInfo(): Pair<ByteArray, String>? {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
val prefs = appContext.prefs
val localUserPublicKey = prefs.getLocalNumber()
val secretKey = with(appContext) {
val edKey = KeyPairUtilities.getUserED25519KeyPair(this) ?: return null
edKey.secretKey.asBytes
}
return if (localUserPublicKey == null || secretKey == null) null
else secretKey to localUserPublicKey
}
private fun buildContactMessage(contactList: List<Contact>): ByteArray {
val (key,_) = maybeGetUserInfo()!!
val contacts = Contacts.newInstance(key)
contactList.forEach { contact ->
contacts.set(contact)
}
return contacts.push().config
}
private fun buildVolatileMessage(conversations: List<Conversation>): ByteArray {
val (key, _) = maybeGetUserInfo()!!
val volatile = ConversationVolatileConfig.newInstance(key)
conversations.forEach { conversation ->
volatile.set(conversation)
}
return volatile.push().config
}
private fun fakePollNewConfig(configBase: ConfigBase, toMerge: ByteArray) {
configBase.merge(nextFakeHash to toMerge)
MessagingModuleConfiguration.shared.configFactory.persist(configBase, System.currentTimeMillis())
}
@Before
fun setupUser() {
PreferenceManager.getDefaultSharedPreferences(InstrumentationRegistry.getInstrumentation().targetContext.applicationContext).edit {
putBoolean(TextSecurePreferences.HAS_FORCED_NEW_CONFIG, true).apply()
}
val newBytes = randomSeedBytes().toByteArray()
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
val kp = KeyPairUtilities.generate(newBytes)
KeyPairUtilities.store(context, kp.seed, kp.ed25519KeyPair, kp.x25519KeyPair)
val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(context, registrationID)
TextSecurePreferences.setLocalNumber(context, kp.x25519KeyPair.hexEncodedPublicKey)
TextSecurePreferences.setRestorationTime(context, 0)
TextSecurePreferences.setHasViewedSeed(context, false)
}
@Test
fun migration_one_to_ones() {
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
val storageSpy = spy(app.storage)
app.storage = storageSpy
val newContactId = randomSessionId()
val singleContact = Contact(
id = newContactId,
approved = true,
expiryMode = ExpiryMode.NONE
)
val newContactMerge = buildContactMessage(listOf(singleContact))
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
fakePollNewConfig(contacts, newContactMerge)
verify(storageSpy).addLibSessionContacts(argThat {
first().let { it.id == newContactId && it.approved } && size == 1
}, any())
verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true))
}
@Test
fun test_expected_configs() {
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
val storageSpy = spy(app.storage)
app.storage = storageSpy
val randomRecipient = randomSessionId()
val newContact = Contact(
id = randomRecipient,
approved = true,
expiryMode = ExpiryMode.AfterSend(1000)
)
val newConvo = Conversation.OneToOne(
randomRecipient,
SnodeAPI.nowWithOffset,
false
)
val volatiles = MessagingModuleConfiguration.shared.configFactory.convoVolatile!!
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
val newContactMerge = buildContactMessage(listOf(newContact))
val newVolatileMerge = buildVolatileMessage(listOf(newConvo))
fakePollNewConfig(contacts, newContactMerge)
fakePollNewConfig(volatiles, newVolatileMerge)
verify(storageSpy).setExpirationConfiguration(argWhere { config ->
config.expiryMode is ExpiryMode.AfterSend
&& config.expiryMode.expirySeconds == 1000L
})
val threadId = storageSpy.getThreadId(Address.fromSerialized(randomRecipient))!!
val newExpiry = storageSpy.getExpirationConfiguration(threadId)!!
assertThat(newExpiry.expiryMode, instanceOf(ExpiryMode.AfterSend::class.java))
assertThat(newExpiry.expiryMode.expirySeconds, equalTo(1000))
assertThat(newExpiry.expiryMode.expiryMillis, equalTo(1000000))
}
@Test
fun test_overwrite_config() {
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
val storageSpy = spy(app.storage)
app.storage = storageSpy
// Initial state
val randomRecipient = randomSessionId()
val currentContact = Contact(
id = randomRecipient,
approved = true,
expiryMode = ExpiryMode.NONE
)
val newConvo = Conversation.OneToOne(
randomRecipient,
SnodeAPI.nowWithOffset,
false
)
val volatiles = MessagingModuleConfiguration.shared.configFactory.convoVolatile!!
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
val newContactMerge = buildContactMessage(listOf(currentContact))
val newVolatileMerge = buildVolatileMessage(listOf(newConvo))
fakePollNewConfig(contacts, newContactMerge)
fakePollNewConfig(volatiles, newVolatileMerge)
verify(storageSpy).setExpirationConfiguration(argWhere { config ->
config.expiryMode == ExpiryMode.NONE
})
val threadId = storageSpy.getThreadId(Address.fromSerialized(randomRecipient))!!
val currentExpiryConfig = storageSpy.getExpirationConfiguration(threadId)!!
assertThat(currentExpiryConfig.expiryMode, equalTo(ExpiryMode.NONE))
assertThat(currentExpiryConfig.expiryMode.expirySeconds, equalTo(0))
assertThat(currentExpiryConfig.expiryMode.expiryMillis, equalTo(0))
// Set new state and overwrite
val updatedContact = currentContact.copy(expiryMode = ExpiryMode.AfterSend(1000))
val updateContactMerge = buildContactMessage(listOf(updatedContact))
fakePollNewConfig(contacts, updateContactMerge)
val updatedExpiryConfig = storageSpy.getExpirationConfiguration(threadId)!!
assertThat(updatedExpiryConfig.expiryMode, instanceOf(ExpiryMode.AfterSend::class.java))
assertThat(updatedExpiryConfig.expiryMode.expirySeconds, equalTo(1000))
}
}

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

@ -29,12 +29,18 @@
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
<uses-permission android:name="network.loki.messenger.ACCESS_SESSION_SECRETS" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
@ -100,11 +106,6 @@
android:name="org.thoughtcrime.securesms.onboarding.RegisterActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.RecoveryPhraseRestoreActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.LinkDeviceActivity"
android:screenOrientation="portrait"
@ -172,6 +173,9 @@
android:screenOrientation="portrait" />
<activity android:name="org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity"
android:screenOrientation="portrait"/>
<activity android:name="org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.NoActionBar" />
<activity
android:exported="true"
@ -221,20 +225,18 @@
android:name="org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2"
android:screenOrientation="portrait"
android:parentActivityName="org.thoughtcrime.securesms.home.HomeActivity"
android:theme="@style/Theme.Session.DayNight.NoActionBar">
android:theme="@style/Theme.Session.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize" >
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
</activity>
<activity
android:name="org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight">
</activity>
<activity
android:name="org.thoughtcrime.securesms.longmessage.LongMessageActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight" />
<activity
android:name="org.thoughtcrime.securesms.DatabaseUpgradeActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
@ -306,20 +308,16 @@
android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
</activity>
<service
android:name="org.thoughtcrime.securesms.notifications.PushNotificationService"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService"
android:foregroundServiceType="microphone"
android:exported="false" />
<service
android:name="org.thoughtcrime.securesms.service.KeyCachingService"
android:enabled="true"
android:exported="false" />
android:exported="false" android:foregroundServiceType="specialUse">
<!-- <property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"-->
<!-- android:value="@string/preferences_app_protection__lock_signal_access_with_android_screen_lock_or_fingerprint"/>-->
</service>
<service
android:name="org.thoughtcrime.securesms.service.DirectShareService"
android:exported="true"
@ -407,12 +405,6 @@
<action android:name="network.loki.securesms.RESTART" />
</intent-filter>
</receiver>
<receiver android:name="org.thoughtcrime.securesms.service.LocalBackupListener"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver android:name="org.thoughtcrime.securesms.service.PersistentConnectionBootListener"
android:exported="true">
<intent-filter>
@ -446,17 +438,9 @@
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<service
android:name="org.thoughtcrime.securesms.jobmanager.JobSchedulerScheduler$SystemService"
android:enabled="@bool/enable_job_service"
android:permission="android.permission.BIND_JOB_SERVICE"
tools:targetApi="26" />
<service
android:name="org.thoughtcrime.securesms.jobmanager.KeepAliveService"
android:enabled="@bool/enable_alarm_manager" />
<receiver
android:name="org.thoughtcrime.securesms.jobmanager.AlarmManagerScheduler$RetryReceiver"
android:enabled="@bool/enable_alarm_manager" /> <!-- Probably don't need this one -->
<uses-library
android:name="com.sec.android.app.multiwindow"
android:required="false" />

View File

@ -40,6 +40,8 @@ import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPol
import org.session.libsession.messaging.sending_receiving.pollers.Poller;
import org.session.libsession.snode.SnodeModule;
import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.ConfigFactoryUpdateListener;
import org.session.libsession.utilities.Device;
import org.session.libsession.utilities.ProfilePictureUtilities;
import org.session.libsession.utilities.SSKEnvironment;
import org.session.libsession.utilities.TextSecurePreferences;
@ -55,34 +57,29 @@ import org.signal.aesgcmprovider.AesGcmProvider;
import org.thoughtcrime.securesms.components.TypingStatusSender;
import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
import org.thoughtcrime.securesms.database.JobDatabase;
import org.thoughtcrime.securesms.database.LokiAPIDatabase;
import org.thoughtcrime.securesms.database.Storage;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.EmojiSearchData;
import org.thoughtcrime.securesms.dependencies.AppComponent;
import org.thoughtcrime.securesms.dependencies.ConfigFactory;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.dependencies.DatabaseModule;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.groups.OpenGroupManager;
import org.thoughtcrime.securesms.home.HomeActivity;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.jobs.FastJobStorage;
import org.thoughtcrime.securesms.jobs.JobManagerFactories;
import org.thoughtcrime.securesms.logging.AndroidLogger;
import org.thoughtcrime.securesms.logging.PersistentLogger;
import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger;
import org.thoughtcrime.securesms.notifications.BackgroundPollWorker;
import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier;
import org.thoughtcrime.securesms.notifications.FcmUtils;
import org.thoughtcrime.securesms.notifications.LokiPushNotificationManager;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier;
import org.thoughtcrime.securesms.notifications.PushRegistry;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
import org.thoughtcrime.securesms.sskenvironment.ProfileManager;
import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager;
import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository;
@ -113,6 +110,8 @@ import dagger.hilt.android.HiltAndroidApp;
import kotlin.Unit;
import kotlinx.coroutines.Job;
import network.loki.messenger.BuildConfig;
import network.loki.messenger.libsession_util.ConfigBase;
import network.loki.messenger.libsession_util.UserProfile;
/**
* Will be called once when the TextSecure process is created.
@ -123,7 +122,7 @@ import network.loki.messenger.BuildConfig;
* @author Moxie Marlinspike
*/
@HiltAndroidApp
public class ApplicationContext extends Application implements DefaultLifecycleObserver {
public class ApplicationContext extends Application implements DefaultLifecycleObserver, ConfigFactoryUpdateListener {
public static final String PREFERENCES_NAME = "SecureSMS-Preferences";
@ -132,7 +131,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
private ExpiringMessageManager expiringMessageManager;
private TypingStatusRepository typingStatusRepository;
private TypingStatusSender typingStatusSender;
private JobManager jobManager;
private ReadReceiptManager readReceiptManager;
private ProfileManager profileManager;
public MessageNotifier messageNotifier = null;
@ -145,10 +143,12 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
private PersistentLogger persistentLogger;
@Inject LokiAPIDatabase lokiAPIDatabase;
@Inject Storage storage;
@Inject public Storage storage;
@Inject Device device;
@Inject MessageDataProvider messageDataProvider;
@Inject JobDatabase jobDatabase;
@Inject TextSecurePreferences textSecurePreferences;
@Inject PushRegistry pushRegistry;
@Inject ConfigFactory configFactory;
CallMessageProcessor callMessageProcessor;
MessagingModuleConfiguration messagingModuleConfiguration;
@ -167,7 +167,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
}
public TextSecurePreferences getPrefs() {
return textSecurePreferences;
return EntryPoints.get(getApplicationContext(), AppComponent.class).getPrefs();
}
public DatabaseComponent getDatabaseComponent() {
@ -196,15 +196,30 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
return this.persistentLogger;
}
@Override
public void notifyUpdates(@NonNull ConfigBase forConfigObject, long messageTimestamp) {
// forward to the config factory / storage ig
if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) {
textSecurePreferences.setConfigurationMessageSynced(true);
}
storage.notifyConfigUpdates(forConfigObject, messageTimestamp);
}
@Override
public void onCreate() {
TextSecurePreferences.setPushSuffix(BuildConfig.PUSH_KEY_SUFFIX);
DatabaseModule.init(this);
MessagingModuleConfiguration.configure(this);
super.onCreate();
messagingModuleConfiguration = new MessagingModuleConfiguration(this,
messagingModuleConfiguration = new MessagingModuleConfiguration(
this,
storage,
device,
messageDataProvider,
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this));
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this),
configFactory
);
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
Log.i(TAG, "onCreate()");
startKovenant();
@ -218,10 +233,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
broadcaster = new Broadcaster(this);
LokiAPIDatabase apiDB = getDatabaseComponent().lokiAPIDatabase();
SnodeModule.Companion.configure(apiDB, broadcaster);
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
if (userPublicKey != null) {
registerForFCMIfNeeded(false);
}
initializeExpiringMessageManager();
initializeTypingStatusRepository();
initializeTypingStatusSender();
@ -229,7 +240,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
initializeProfileManager();
initializePeriodicTasks();
SSKEnvironment.Companion.configure(getTypingStatusRepository(), getReadReceiptManager(), getProfileManager(), messageNotifier, getExpiringMessageManager());
initializeJobManager();
initializeWebRtc();
initializeBlobProvider();
resubmitProfilePictureIfNeeded();
@ -272,7 +282,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
if (poller != null) {
poller.stopIfNeeded();
}
ClosedGroupPollerV2.getShared().stop();
ClosedGroupPollerV2.getShared().stopAll();
}
@Override
@ -286,10 +296,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
LocaleParser.Companion.configure(new LocaleParseHelper());
}
public JobManager getJobManager() {
return jobManager;
}
public ExpiringMessageManager getExpiringMessageManager() {
return expiringMessageManager;
}
@ -352,16 +358,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionLogger(originalHandler));
}
private void initializeJobManager() {
this.jobManager = new JobManager(this, new JobManager.Configuration.Builder()
.setDataSerializer(new JsonDataSerializer())
.setJobFactories(JobManagerFactories.getJobFactories(this))
.setConstraintFactories(JobManagerFactories.getConstraintFactories(this))
.setConstraintObservers(JobManagerFactories.getConstraintObservers(this))
.setJobStorage(new FastJobStorage(jobDatabase))
.build());
}
private void initializeExpiringMessageManager() {
this.expiringMessageManager = new ExpiringMessageManager(this);
}
@ -375,7 +371,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
}
private void initializeProfileManager() {
this.profileManager = new ProfileManager();
this.profileManager = new ProfileManager(this, configFactory);
}
private void initializeTypingStatusSender() {
@ -384,10 +380,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
private void initializePeriodicTasks() {
BackgroundPollWorker.schedulePeriodic(this);
if (BuildConfig.PLAY_STORE_DISABLED) {
UpdateApkRefreshListener.schedule(this);
}
}
private void initializeWebRtc() {
@ -438,29 +430,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
}
private static class ProviderInitializationException extends RuntimeException { }
public void registerForFCMIfNeeded(final Boolean force) {
if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive() && !force) return;
if (force && firebaseInstanceIdJob != null) {
firebaseInstanceIdJob.cancel(null);
}
firebaseInstanceIdJob = FcmUtils.getFcmInstanceId(task->{
if (!task.isSuccessful()) {
Log.w("Loki", "FirebaseInstanceId.getInstance().getInstanceId() failed." + task.getException());
return Unit.INSTANCE;
}
String token = task.getResult().getToken();
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
if (userPublicKey == null) return Unit.INSTANCE;
if (TextSecurePreferences.isUsingFCM(this)) {
LokiPushNotificationManager.register(token, userPublicKey, this, force);
} else {
LokiPushNotificationManager.unregister(token, this);
}
return Unit.INSTANCE;
});
}
private void setUpPollingIfNeeded() {
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
if (userPublicKey == null) return;
@ -468,7 +437,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
poller.setUserPublicKey(userPublicKey);
return;
}
poller = new Poller();
poller = new Poller(configFactory, new Timer());
}
public void startPollingIfNeeded() {
@ -509,8 +478,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
Log.d("Loki-Avatar", "Uploading Avatar Finished");
return Unit.INSTANCE;
});
} catch (Exception exception) {
// Do nothing
} catch (Exception e) {
Log.e("Loki-Avatar", "Uploading avatar failed.");
}
});
}
@ -530,24 +499,21 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
}
public void clearAllData(boolean isMigratingToV2KeyPair) {
String token = TextSecurePreferences.getFCMToken(this);
if (token != null && !token.isEmpty()) {
LokiPushNotificationManager.unregister(token, this);
}
if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive()) {
firebaseInstanceIdJob.cancel(null);
}
String displayName = TextSecurePreferences.getProfileName(this);
boolean isUsingFCM = TextSecurePreferences.isUsingFCM(this);
boolean isUsingFCM = TextSecurePreferences.isPushEnabled(this);
TextSecurePreferences.clearAll(this);
if (isMigratingToV2KeyPair) {
TextSecurePreferences.setIsUsingFCM(this, isUsingFCM);
TextSecurePreferences.setPushEnabled(this, isUsingFCM);
TextSecurePreferences.setProfileName(this, displayName);
}
getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit();
if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) {
Log.d("Loki", "Failed to delete database.");
}
configFactory.keyPairChanged();
Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200));
}

View File

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms;
import static android.os.Build.VERSION.SDK_INT;
import static org.session.libsession.utilities.TextSecurePreferences.SELECTED_ACCENT_COLOR;
import android.app.ActivityManager;
@ -18,6 +19,7 @@ import androidx.appcompat.app.AppCompatActivity;
import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageActivityHelper;
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
import org.thoughtcrime.securesms.conversation.v2.WindowUtil;
import org.thoughtcrime.securesms.util.ActivityUtilitiesKt;
import org.thoughtcrime.securesms.util.ThemeState;
import org.thoughtcrime.securesms.util.UiModeUtilities;
@ -92,6 +94,11 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
if (!currentThemeState.equals(ActivityUtilitiesKt.themeState(getPreferences()))) {
recreate();
}
// apply lightStatusBar manually as API 26 does not update properly via applyTheme
// https://issuetracker.google.com/issues/65883460?pli=1
if (SDK_INT >= 26 && SDK_INT <= 27) WindowUtil.setLightStatusBarFromTheme(this);
if (SDK_INT == 27) WindowUtil.setLightNavigationBarFromTheme(this);
}
@Override

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,28 @@
package org.thoughtcrime.securesms
import android.content.Context
import network.loki.messenger.R
class DeleteMediaDialog {
companion object {
@JvmStatic
fun show(context: Context, recordCount: Int, doDelete: Runnable) = context.showSessionDialog {
iconAttribute(R.attr.dialog_alert_icon)
title(
context.resources.getQuantityString(
R.plurals.MediaOverviewActivity_Media_delete_confirm_title,
recordCount,
recordCount
)
)
text(
context.resources.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message,
recordCount,
recordCount
)
)
button(R.string.delete) { doDelete.run() }
cancelButton()
}
}
}

View File

@ -0,0 +1,19 @@
package org.thoughtcrime.securesms
import android.content.Context
import network.loki.messenger.R
class DeleteMediaPreviewDialog {
companion object {
@JvmStatic
fun show(context: Context, doDelete: Runnable) {
context.showSessionDialog {
iconAttribute(R.attr.dialog_alert_icon)
title(R.string.MediaPreviewActivity_media_delete_confirmation_title)
text(R.string.MediaPreviewActivity_media_delete_confirmation_message)
button(R.string.delete) { doDelete.run() }
cancelButton()
}
}
}
}

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,88 +0,0 @@
package org.thoughtcrime.securesms;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.TextView;
import org.session.libsession.utilities.ExpirationUtil;
import cn.carbswang.android.numberpickerview.library.NumberPickerView;
import network.loki.messenger.R;
public class ExpirationDialog extends AlertDialog {
protected ExpirationDialog(Context context) {
super(context);
}
protected ExpirationDialog(Context context, int theme) {
super(context, theme);
}
protected ExpirationDialog(Context context, boolean cancelable, OnCancelListener cancelListener) {
super(context, cancelable, cancelListener);
}
public static void show(final Context context,
final int currentExpiration,
final @NonNull OnClickListener listener)
{
final View view = createNumberPickerView(context, currentExpiration);
AlertDialog.Builder builder = new AlertDialog.Builder(context);
builder.setTitle(context.getString(R.string.ExpirationDialog_disappearing_messages));
builder.setView(view);
builder.setPositiveButton(android.R.string.ok, (dialog, which) -> {
int selected = ((NumberPickerView)view.findViewById(R.id.expiration_number_picker)).getValue();
listener.onClick(context.getResources().getIntArray(R.array.expiration_times)[selected]);
});
builder.setNegativeButton(android.R.string.cancel, null);
builder.show();
}
private static View createNumberPickerView(final Context context, final int currentExpiration) {
final LayoutInflater inflater = LayoutInflater.from(context);
final View view = inflater.inflate(R.layout.expiration_dialog, null);
final NumberPickerView numberPickerView = view.findViewById(R.id.expiration_number_picker);
final TextView textView = view.findViewById(R.id.expiration_details);
final int[] expirationTimes = context.getResources().getIntArray(R.array.expiration_times);
final String[] expirationDisplayValues = new String[expirationTimes.length];
int selectedIndex = expirationTimes.length - 1;
for (int i=0;i<expirationTimes.length;i++) {
expirationDisplayValues[i] = ExpirationUtil.getExpirationDisplayValue(context, expirationTimes[i]);
if ((currentExpiration >= expirationTimes[i]) &&
(i == expirationTimes.length -1 || currentExpiration < expirationTimes[i+1])) {
selectedIndex = i;
}
}
numberPickerView.setDisplayedValues(expirationDisplayValues);
numberPickerView.setMinValue(0);
numberPickerView.setMaxValue(expirationTimes.length-1);
NumberPickerView.OnValueChangeListener listener = (picker, oldVal, newVal) -> {
if (newVal == 0) {
textView.setText(R.string.ExpirationDialog_your_messages_will_not_expire);
} else {
textView.setText(context.getString(R.string.ExpirationDialog_your_messages_will_disappear_s_after_they_have_been_seen, picker.getDisplayedValues()[newVal]));
}
};
numberPickerView.setOnValueChangedListener(listener);
numberPickerView.setValue(selectedIndex);
listener.onValueChange(numberPickerView, selectedIndex, selectedIndex);
return view;
}
public interface OnClickListener {
public void onClick(int expirationTime);
}
}

View File

@ -54,6 +54,7 @@ import com.google.android.material.tabs.TabLayout;
import org.session.libsession.messaging.messages.control.DataExtractionNotification;
import org.session.libsession.messaging.sending_receiving.MessageSender;
import org.session.libsession.snode.SnodeAPI;
import org.session.libsession.utilities.Address;
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
import org.thoughtcrime.securesms.database.MediaDatabase;
@ -75,6 +76,7 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import kotlin.Unit;
import network.loki.messenger.R;
/**
@ -317,9 +319,9 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity {
@SuppressWarnings("CodeBlock2Expr")
@SuppressLint({"InlinedApi", "StaticFieldLeak"})
private void handleSaveMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
final Context context = getContext();
final Context context = requireContext();
SaveAttachmentTask.showWarningDialog(context, (dialogInterface, which) -> {
SaveAttachmentTask.showWarningDialog(context, mediaRecords.size(), () -> {
Permissions.with(this)
.request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
.maxSdkVersion(Build.VERSION_CODES.P)
@ -361,53 +363,39 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity {
}.execute();
})
.execute();
}, mediaRecords.size());
return Unit.INSTANCE;
});
}
private void sendMediaSavedNotificationIfNeeded() {
if (recipient.isGroupRecipient()) return;
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(System.currentTimeMillis()));
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset()));
MessageSender.send(message, recipient.getAddress());
}
@SuppressLint("StaticFieldLeak")
private void handleDeleteMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
int recordCount = mediaRecords.size();
Resources res = getContext().getResources();
String confirmTitle = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_title,
recordCount,
recordCount);
String confirmMessage = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message,
recordCount,
recordCount);
AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
builder.setIconAttribute(R.attr.dialog_alert_icon);
builder.setTitle(confirmTitle);
builder.setMessage(confirmMessage);
builder.setCancelable(true);
builder.setPositiveButton(R.string.delete, (dialogInterface, i) -> {
new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>(getContext(),
R.string.MediaOverviewActivity_Media_delete_progress_title,
R.string.MediaOverviewActivity_Media_delete_progress_message)
{
@Override
protected Void doInBackground(MediaDatabase.MediaRecord... records) {
if (records == null || records.length == 0) {
return null;
}
for (MediaDatabase.MediaRecord record : records) {
AttachmentUtil.deleteAttachment(getContext(), record.getAttachment());
}
DeleteMediaDialog.show(
requireContext(),
recordCount,
() -> new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>(
requireContext(),
R.string.MediaOverviewActivity_Media_delete_progress_title,
R.string.MediaOverviewActivity_Media_delete_progress_message) {
@Override
protected Void doInBackground(MediaDatabase.MediaRecord... records) {
if (records == null || records.length == 0) {
return null;
}
}.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()]));
});
builder.setNegativeButton(android.R.string.cancel, null);
builder.show();
for (MediaDatabase.MediaRecord record : records) {
AttachmentUtil.deleteAttachment(getContext(), record.getAttachment());
}
return null;
}
}.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()])));
}
private void handleSelectAllMedia() {

View File

@ -47,7 +47,6 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.core.util.Pair;
import androidx.lifecycle.ViewModelProvider;
import androidx.loader.app.LoaderManager;
@ -60,6 +59,7 @@ import androidx.viewpager.widget.ViewPager;
import org.session.libsession.messaging.messages.control.DataExtractionNotification;
import org.session.libsession.messaging.sending_receiving.MessageSender;
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment;
import org.session.libsession.snode.SnodeAPI;
import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.Util;
import org.session.libsession.utilities.recipients.Recipient;
@ -84,6 +84,7 @@ import java.io.IOException;
import java.util.Locale;
import java.util.WeakHashMap;
import kotlin.Unit;
import network.loki.messenger.R;
/**
@ -145,6 +146,10 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
}
};
public static Intent getPreviewIntent(Context context, MediaPreviewArgs args) {
return getPreviewIntent(context, args.getSlide(), args.getMmsRecord(), args.getThread());
}
public static Intent getPreviewIntent(Context context, Slide slide, MmsMessageRecord mms, Recipient threadRecipient) {
Intent previewIntent = null;
if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) {
@ -415,7 +420,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
MediaItem mediaItem = getCurrentMediaItem();
if (mediaItem == null) return;
SaveAttachmentTask.showWarningDialog(this, (dialogInterface, i) -> {
SaveAttachmentTask.showWarningDialog(this, 1, () -> {
Permissions.with(this)
.request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
.maxSdkVersion(Build.VERSION_CODES.P)
@ -423,7 +428,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
.onAnyDenied(() -> Toast.makeText(this, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show())
.onAllGranted(() -> {
SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this);
long saveDate = (mediaItem.date > 0) ? mediaItem.date : System.currentTimeMillis();
long saveDate = (mediaItem.date > 0) ? mediaItem.date : SnodeAPI.getNowWithOffset();
saveTask.executeOnExecutor(
AsyncTask.THREAD_POOL_EXECUTOR,
new Attachment(mediaItem.uri, mediaItem.type, saveDate, null));
@ -432,12 +437,13 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
}
})
.execute();
return Unit.INSTANCE;
});
}
private void sendMediaSavedNotificationIfNeeded() {
if (conversationRecipient.isGroupRecipient()) return;
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(System.currentTimeMillis()));
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset()));
MessageSender.send(message, conversationRecipient.getAddress());
}
@ -448,29 +454,20 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
return;
}
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setIconAttribute(R.attr.dialog_alert_icon);
builder.setTitle(R.string.MediaPreviewActivity_media_delete_confirmation_title);
builder.setMessage(R.string.MediaPreviewActivity_media_delete_confirmation_message);
builder.setCancelable(true);
builder.setPositiveButton(R.string.delete, (dialogInterface, which) -> {
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... voids) {
if (mediaItem.attachment == null) {
return null;
}
AttachmentUtil.deleteAttachment(MediaPreviewActivity.this.getApplicationContext(),
mediaItem.attachment);
return null;
}
}.execute();
DeleteMediaPreviewDialog.show(this, () -> {
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... voids) {
DatabaseAttachment attachment = mediaItem.attachment;
if (attachment != null) {
AttachmentUtil.deleteAttachment(getApplicationContext(), attachment);
}
return null;
}
}.execute();
finish();
});
builder.setNegativeButton(android.R.string.cancel, null);
builder.show();
}
@Override
@ -530,18 +527,21 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
@Override
public void onLoadFinished(@NonNull Loader<Pair<Cursor, Integer>> loader, @Nullable Pair<Cursor, Integer> data) {
if (data != null) {
@SuppressWarnings("ConstantConditions")
CursorPagerAdapter adapter = new CursorPagerAdapter(this, GlideApp.with(this), getWindow(), data.first, data.second, leftIsRecent);
mediaPager.setAdapter(adapter);
adapter.setActive(true);
viewModel.setCursor(this, data.first, leftIsRecent);
int item = restartItem >= 0 ? restartItem : data.second;
mediaPager.setCurrentItem(item);
if (restartItem >= 0 || data.second >= 0) {
int item = restartItem >= 0 ? restartItem : data.second;
mediaPager.setCurrentItem(item);
if (item == 0) {
viewPagerListener.onPageSelected(0);
if (item == 0) {
viewPagerListener.onPageSelected(0);
}
} else {
Log.w(TAG, "one of restartItem "+restartItem+" and data.second "+data.second+" would cause OOB exception");
}
}
}

View File

@ -0,0 +1,11 @@
package org.thoughtcrime.securesms
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.mms.Slide
data class MediaPreviewArgs(
val slide: Slide,
val mmsRecord: MmsMessageRecord?,
val thread: Recipient?,
)

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

@ -1,56 +0,0 @@
package org.thoughtcrime.securesms;
import android.content.Context;
import android.content.DialogInterface;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import java.util.concurrent.TimeUnit;
import network.loki.messenger.R;
public class MuteDialog extends AlertDialog {
protected MuteDialog(Context context) {
super(context);
}
protected MuteDialog(Context context, boolean cancelable, OnCancelListener cancelListener) {
super(context, cancelable, cancelListener);
}
protected MuteDialog(Context context, int theme) {
super(context, theme);
}
public static void show(final Context context, final @NonNull MuteSelectionListener listener) {
AlertDialog.Builder builder = new AlertDialog.Builder(context);
builder.setTitle(R.string.MuteDialog_mute_notifications);
builder.setItems(R.array.mute_durations, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, final int which) {
final long muteUntil;
switch (which) {
case 1: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(2); break;
case 2: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1); break;
case 3: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7); break;
case 4: muteUntil = Long.MAX_VALUE; break;
default: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
}
listener.onMuted(muteUntil);
}
});
builder.show();
}
public interface MuteSelectionListener {
public void onMuted(long until);
}
}

View File

@ -0,0 +1,27 @@
package org.thoughtcrime.securesms
import android.content.Context
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import network.loki.messenger.R
import java.util.concurrent.TimeUnit
fun showMuteDialog(
context: Context,
onMuteDuration: (Long) -> Unit
): AlertDialog = context.showSessionDialog {
title(R.string.MuteDialog_mute_notifications)
items(Option.values().map { it.stringRes }.map(context::getString).toTypedArray()) {
onMuteDuration(Option.values()[it].getTime())
}
}
private enum class Option(@StringRes val stringRes: Int, val getTime: () -> Long) {
ONE_HOUR(R.string.arrays__mute_for_one_hour, duration = TimeUnit.HOURS.toMillis(1)),
TWO_HOURS(R.string.arrays__mute_for_two_hours, duration = TimeUnit.DAYS.toMillis(2)),
ONE_DAY(R.string.arrays__mute_for_one_day, duration = TimeUnit.DAYS.toMillis(1)),
SEVEN_DAYS(R.string.arrays__mute_for_seven_days, duration = TimeUnit.DAYS.toMillis(7)),
FOREVER(R.string.arrays__mute_forever, getTime = { Long.MAX_VALUE });
constructor(@StringRes stringRes: Int, duration: Long): this(stringRes, { System.currentTimeMillis() + duration })
}

View File

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

View File

@ -0,0 +1,146 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.Button
import android.widget.LinearLayout
import android.widget.LinearLayout.VERTICAL
import android.widget.TextView
import androidx.annotation.AttrRes
import androidx.annotation.LayoutRes
import androidx.annotation.StringRes
import androidx.annotation.StyleRes
import androidx.appcompat.app.AlertDialog
import androidx.core.view.setMargins
import androidx.core.view.setPadding
import androidx.core.view.updateMargins
import androidx.fragment.app.Fragment
import network.loki.messenger.R
import org.thoughtcrime.securesms.util.toPx
@DslMarker
@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE)
annotation class DialogDsl
@DialogDsl
class SessionDialogBuilder(val context: Context) {
private val dp20 = toPx(20, context.resources)
private val dp40 = toPx(40, context.resources)
private val dialogBuilder: AlertDialog.Builder = AlertDialog.Builder(context)
private var dialog: AlertDialog? = null
private fun dismiss() = dialog?.dismiss()
private val topView = LinearLayout(context).apply { orientation = VERTICAL }
.also(dialogBuilder::setCustomTitle)
private val contentView = LinearLayout(context).apply { orientation = VERTICAL }
private val buttonLayout = LinearLayout(context)
private val root = LinearLayout(context).apply { orientation = VERTICAL }
.also(dialogBuilder::setView)
.apply {
addView(contentView)
addView(buttonLayout)
}
fun title(@StringRes id: Int) = title(context.getString(id))
fun title(text: CharSequence?) = title(text?.toString())
fun title(text: String?) {
text(text, R.style.TextAppearance_AppCompat_Title) { setPadding(dp20) }
}
fun text(@StringRes id: Int, style: Int = 0) = text(context.getString(id), style)
fun text(text: CharSequence?, @StyleRes style: Int = 0) {
text(text, style) {
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
.apply { updateMargins(dp40, 0, dp40, dp20) }
}
}
private fun text(text: CharSequence?, @StyleRes style: Int, modify: TextView.() -> Unit) {
text ?: return
TextView(context, null, 0, style)
.apply {
setText(text)
textAlignment = View.TEXT_ALIGNMENT_CENTER
modify()
}.let(topView::addView)
}
fun view(view: View) = contentView.addView(view)
fun view(@LayoutRes layout: Int): View = LayoutInflater.from(context).inflate(layout, contentView)
fun iconAttribute(@AttrRes icon: Int): AlertDialog.Builder = dialogBuilder.setIconAttribute(icon)
fun singleChoiceItems(
options: Collection<String>,
currentSelected: Int = 0,
onSelect: (Int) -> Unit
) = singleChoiceItems(options.toTypedArray(), currentSelected, onSelect)
fun singleChoiceItems(
options: Array<String>,
currentSelected: Int = 0,
onSelect: (Int) -> Unit
): AlertDialog.Builder = dialogBuilder.setSingleChoiceItems(
options,
currentSelected
) { dialog, it -> onSelect(it); dialog.dismiss() }
fun items(
options: Array<String>,
onSelect: (Int) -> Unit
): AlertDialog.Builder = dialogBuilder.setItems(
options,
) { dialog, it -> onSelect(it); dialog.dismiss() }
fun destructiveButton(
@StringRes text: Int,
@StringRes contentDescription: Int = text,
listener: () -> Unit = {}
) = button(
text,
contentDescription,
R.style.Widget_Session_Button_Dialog_DestructiveText,
) { listener() }
fun okButton(listener: (() -> Unit) = {}) = button(android.R.string.ok) { listener() }
fun cancelButton(listener: (() -> Unit) = {}) = button(android.R.string.cancel, R.string.AccessibilityId_cancel_button) { listener() }
fun button(
@StringRes text: Int,
@StringRes contentDescriptionRes: Int = text,
@StyleRes style: Int = R.style.Widget_Session_Button_Dialog_UnimportantText,
dismiss: Boolean = true,
listener: (() -> Unit) = {}
) = Button(context, null, 0, style).apply {
setText(text)
contentDescription = resources.getString(contentDescriptionRes)
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, 1f)
.apply { setMargins(toPx(20, resources)) }
setOnClickListener {
listener.invoke()
if (dismiss) dismiss()
}
}.let(buttonLayout::addView)
fun create(): AlertDialog = dialogBuilder.create().also { dialog = it }
fun show(): AlertDialog = dialogBuilder.show().also { dialog = it }
}
fun Context.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog =
SessionDialogBuilder(this).apply { build() }.show()
fun Fragment.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog =
SessionDialogBuilder(requireContext()).apply { build() }.show()
fun Fragment.createSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog =
SessionDialogBuilder(requireContext()).apply { build() }.create()

View File

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

View File

@ -184,18 +184,23 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
override fun deleteMessage(messageID: Long, isSms: Boolean) {
val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase()
else DatabaseComponent.get(context).mmsDatabase()
messagingDatabase.deleteMessage(messageID)
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessage(messageID, isSms)
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID)
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID, mms = !isSms)
}
override fun deleteMessages(messageIDs: List<Long>, threadId: Long, isSms: Boolean) {
val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase()
else DatabaseComponent.get(context).mmsDatabase()
// Perform local delete
messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId)
// Perform online delete
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs)
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs)
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs, mms = !isSms)
}
override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? {
@ -212,15 +217,12 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
return message.id
}
override fun getServerHashForMessage(messageID: Long): String? {
val messageDB = DatabaseComponent.get(context).lokiMessageDatabase()
return messageDB.getMessageServerHash(messageID)
}
override fun getServerHashForMessage(messageID: Long, mms: Boolean): String? =
DatabaseComponent.get(context).lokiMessageDatabase().getMessageServerHash(messageID, mms)
override fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? {
val attachmentDatabase = DatabaseComponent.get(context).attachmentDatabase()
return attachmentDatabase.getAttachment(AttachmentId(attachmentId, 0))
}
override fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? =
DatabaseComponent.get(context).attachmentDatabase()
.getAttachment(AttachmentId(attachmentId, 0))
private fun scaleAndStripExif(attachmentDatabase: AttachmentDatabase, constraints: MediaConstraints, attachment: Attachment): Attachment? {
return try {

View File

@ -7,6 +7,10 @@ import android.os.Build
import android.os.Handler
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer
private const val TAG = "ScreenshotObserver"
class ScreenshotObserver(private val context: Context, handler: Handler, private val screenshotTriggered: ()->Unit): ContentObserver(handler) {
@ -31,22 +35,26 @@ class ScreenshotObserver(private val context: Context, handler: Handler, private
val projection = arrayOf(
MediaStore.Images.Media.DATA
)
context.contentResolver.query(
uri,
projection,
null,
null,
null
)?.use { cursor ->
val dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA)
while (cursor.moveToNext()) {
val path = cursor.getString(dataColumn)
if (path.contains("screenshot", true)) {
if (cache.add(uri.hashCode())) {
screenshotTriggered()
try {
context.contentResolver.query(
uri,
projection,
null,
null,
null
)?.use { cursor ->
val dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA)
while (cursor.moveToNext()) {
val path = cursor.getString(dataColumn)
if (path.contains("screenshot", true)) {
if (cache.add(uri.hashCode())) {
screenshotTriggered()
}
}
}
}
} catch (e: SecurityException) {
Log.e(TAG, e)
}
}
@ -56,28 +64,32 @@ class ScreenshotObserver(private val context: Context, handler: Handler, private
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.RELATIVE_PATH
)
context.contentResolver.query(
uri,
projection,
null,
null,
null
)?.use { cursor ->
val relativePathColumn =
cursor.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH)
val displayNameColumn =
cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
while (cursor.moveToNext()) {
val name = cursor.getString(displayNameColumn)
val relativePath = cursor.getString(relativePathColumn)
if (name.contains("screenshot", true) or
relativePath.contains("screenshot", true)) {
if (cache.add(uri.hashCode())) {
screenshotTriggered()
try {
context.contentResolver.query(
uri,
projection,
null,
null,
null
)?.use { cursor ->
val relativePathColumn =
cursor.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH)
val displayNameColumn =
cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
while (cursor.moveToNext()) {
val name = cursor.getString(displayNameColumn)
val relativePath = cursor.getString(relativePathColumn)
if (name.contains("screenshot", true) or
relativePath.contains("screenshot", true)) {
if (cache.add(uri.hashCode())) {
screenshotTriggered()
}
}
}
}
} catch (e: IllegalStateException) {
Log.e(TAG, e)
}
}
}
}

View File

@ -1,14 +0,0 @@
package org.thoughtcrime.securesms.backup
data class BackupEvent constructor(val type: Type, val count: Int, val exception: Exception?) {
enum class Type {
PROGRESS, FINISHED
}
companion object {
@JvmStatic fun createProgress(count: Int) = BackupEvent(Type.PROGRESS, count, null)
@JvmStatic fun createFinished() = BackupEvent(Type.FINISHED, 0, null)
@JvmStatic fun createFinished(e: Exception?) = BackupEvent(Type.FINISHED, 0, e)
}
}

View File

@ -1,47 +0,0 @@
package org.thoughtcrime.securesms.backup;
import android.content.Context;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.crypto.KeyStoreHelper;
import org.session.libsignal.utilities.Log;
import org.session.libsession.utilities.TextSecurePreferences;
/**
* Allows the getting and setting of the backup passphrase, which is stored encrypted on API >= 23.
*/
public class BackupPassphrase {
private static final String TAG = BackupPassphrase.class.getSimpleName();
public static @Nullable String get(@NonNull Context context) {
String passphrase = TextSecurePreferences.getBackupPassphrase(context);
String encryptedPassphrase = TextSecurePreferences.getEncryptedBackupPassphrase(context);
if (Build.VERSION.SDK_INT < 23 || (passphrase == null && encryptedPassphrase == null)) {
return passphrase;
}
if (encryptedPassphrase == null) {
Log.i(TAG, "Migrating to encrypted passphrase.");
set(context, passphrase);
encryptedPassphrase = TextSecurePreferences.getEncryptedBackupPassphrase(context);
}
KeyStoreHelper.SealedData data = KeyStoreHelper.SealedData.fromString(encryptedPassphrase);
return new String(KeyStoreHelper.unseal(data));
}
public static void set(@NonNull Context context, @Nullable String passphrase) {
if (passphrase == null || Build.VERSION.SDK_INT < 23) {
TextSecurePreferences.setBackupPassphrase(context, passphrase);
TextSecurePreferences.setEncryptedBackupPassphrase(context, null);
} else {
KeyStoreHelper.SealedData encryptedPassphrase = KeyStoreHelper.seal(passphrase.getBytes());
TextSecurePreferences.setEncryptedBackupPassphrase(context, encryptedPassphrase.serialize());
TextSecurePreferences.setBackupPassphrase(context, null);
}
}
}

View File

@ -1,101 +0,0 @@
package org.thoughtcrime.securesms.backup
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.preference.PreferenceManager
import android.preference.PreferenceManager.getDefaultSharedPreferencesName
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.backup.FullBackupImporter.PREF_PREFIX_TYPE_BOOLEAN
import org.thoughtcrime.securesms.backup.FullBackupImporter.PREF_PREFIX_TYPE_INT
import java.util.*
object BackupPreferences {
// region Backup related
fun getBackupRecords(context: Context): List<BackupProtos.SharedPreference> {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
val prefsFileName: String
prefsFileName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
getDefaultSharedPreferencesName(context)
} else {
context.packageName + "_preferences"
}
val prefList: LinkedList<BackupProtos.SharedPreference> = LinkedList<BackupProtos.SharedPreference>()
addBackupEntryInt(prefList, preferences, prefsFileName, TextSecurePreferences.LOCAL_REGISTRATION_ID_PREF)
addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.LOCAL_NUMBER_PREF)
addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_NAME_PREF)
addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_AVATAR_URL_PREF)
addBackupEntryInt(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_AVATAR_ID_PREF)
addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_KEY_PREF)
addBackupEntryBoolean(prefList, preferences, prefsFileName, TextSecurePreferences.IS_USING_FCM)
return prefList
}
private fun addBackupEntryString(
outPrefList: MutableList<BackupProtos.SharedPreference>,
prefs: SharedPreferences,
prefFileName: String,
prefKey: String,
) {
val value = prefs.getString(prefKey, null)
if (value == null) {
logBackupEntry(prefKey, false)
return
}
outPrefList.add(BackupProtos.SharedPreference.newBuilder()
.setFile(prefFileName)
.setKey(prefKey)
.setValue(value)
.build())
logBackupEntry(prefKey, true)
}
private fun addBackupEntryInt(
outPrefList: MutableList<BackupProtos.SharedPreference>,
prefs: SharedPreferences,
prefFileName: String,
prefKey: String,
) {
val value = prefs.getInt(prefKey, -1)
if (value == -1) {
logBackupEntry(prefKey, false)
return
}
outPrefList.add(BackupProtos.SharedPreference.newBuilder()
.setFile(prefFileName)
.setKey(PREF_PREFIX_TYPE_INT + prefKey) // The prefix denotes the type of the preference.
.setValue(value.toString())
.build())
logBackupEntry(prefKey, true)
}
private fun addBackupEntryBoolean(
outPrefList: MutableList<BackupProtos.SharedPreference>,
prefs: SharedPreferences,
prefFileName: String,
prefKey: String,
) {
if (!prefs.contains(prefKey)) {
logBackupEntry(prefKey, false)
return
}
outPrefList.add(BackupProtos.SharedPreference.newBuilder()
.setFile(prefFileName)
.setKey(PREF_PREFIX_TYPE_BOOLEAN + prefKey) // The prefix denotes the type of the preference.
.setValue(prefs.getBoolean(prefKey, false).toString())
.build())
logBackupEntry(prefKey, true)
}
private fun logBackupEntry(prefName: String, wasIncluded: Boolean) {
val sb = StringBuilder()
sb.append("Backup preference ")
sb.append(if (wasIncluded) "+ " else "- ")
sb.append('\"').append(prefName).append("\" ")
if (!wasIncluded) {
sb.append("(is empty and not included)")
}
Log.d("Loki", sb.toString())
} // endregion
}

View File

@ -1,447 +0,0 @@
package org.thoughtcrime.securesms.backup
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.text.TextUtils
import androidx.annotation.WorkerThread
import com.annimon.stream.function.Consumer
import com.annimon.stream.function.Predicate
import com.google.protobuf.ByteString
import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.greenrobot.eventbus.EventBus
import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
import org.session.libsession.utilities.Conversions
import org.session.libsession.utilities.Util
import org.session.libsignal.crypto.kdf.HKDFv3
import org.session.libsignal.utilities.ByteUtil
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.backup.BackupProtos.Attachment
import org.thoughtcrime.securesms.backup.BackupProtos.Avatar
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame
import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion
import org.thoughtcrime.securesms.backup.BackupProtos.Header
import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference
import org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement
import org.thoughtcrime.securesms.backup.BackupProtos.Sticker
import org.thoughtcrime.securesms.crypto.AttachmentSecret
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream
import org.thoughtcrime.securesms.database.AttachmentDatabase
import org.thoughtcrime.securesms.database.GroupReceiptDatabase
import org.thoughtcrime.securesms.database.JobDatabase
import org.thoughtcrime.securesms.database.LokiAPIDatabase
import org.thoughtcrime.securesms.database.LokiBackupFilesDatabase
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.MmsSmsColumns
import org.thoughtcrime.securesms.database.PushDatabase
import org.thoughtcrime.securesms.database.SearchDatabase
import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.util.BackupUtil
import java.io.Closeable
import java.io.File
import java.io.FileInputStream
import java.io.Flushable
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.security.InvalidAlgorithmParameterException
import java.security.InvalidKeyException
import java.security.NoSuchAlgorithmException
import java.util.LinkedList
import javax.crypto.BadPaddingException
import javax.crypto.Cipher
import javax.crypto.IllegalBlockSizeException
import javax.crypto.Mac
import javax.crypto.NoSuchPaddingException
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
object FullBackupExporter {
private val TAG = FullBackupExporter::class.java.simpleName
@JvmStatic
@WorkerThread
@Throws(IOException::class)
fun export(context: Context,
attachmentSecret: AttachmentSecret,
input: SQLiteDatabase,
fileUri: Uri,
passphrase: String) {
val baseOutputStream = context.contentResolver.openOutputStream(fileUri)
?: throw IOException("Cannot open an output stream for the file URI: $fileUri")
var count = 0
try {
BackupFrameOutputStream(baseOutputStream, passphrase).use { outputStream ->
outputStream.writeDatabaseVersion(input.version)
val tables = exportSchema(input, outputStream)
for (table in tables) if (shouldExportTable(table)) {
count = when (table) {
SmsDatabase.TABLE_NAME, MmsDatabase.TABLE_NAME -> {
exportTable(table, input, outputStream,
{ cursor: Cursor ->
cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0
},
null,
count)
}
GroupReceiptDatabase.TABLE_NAME -> {
exportTable(table, input, outputStream,
{ cursor: Cursor ->
isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID)))
},
null,
count)
}
AttachmentDatabase.TABLE_NAME -> {
exportTable(table, input, outputStream,
{ cursor: Cursor ->
isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID)))
},
{ cursor: Cursor ->
exportAttachment(attachmentSecret, cursor, outputStream)
},
count)
}
else -> {
exportTable(table, input, outputStream, null, null, count)
}
}
}
for (preference in BackupUtil.getBackupRecords(context)) {
EventBus.getDefault().post(BackupEvent.createProgress(++count))
outputStream.writePreferenceEntry(preference)
}
for (preference in BackupPreferences.getBackupRecords(context)) {
EventBus.getDefault().post(BackupEvent.createProgress(++count))
outputStream.writePreferenceEntry(preference)
}
for (avatar in AvatarHelper.getAvatarFiles(context)) {
EventBus.getDefault().post(BackupEvent.createProgress(++count))
outputStream.writeAvatar(avatar.name, FileInputStream(avatar), avatar.length())
}
outputStream.writeEnd()
}
EventBus.getDefault().post(BackupEvent.createFinished())
} catch (e: Exception) {
Log.e(TAG, "Failed to make full backup.", e)
EventBus.getDefault().post(BackupEvent.createFinished(e))
throw e
}
}
private inline fun shouldExportTable(table: String): Boolean {
return table != PushDatabase.TABLE_NAME &&
table != LokiBackupFilesDatabase.TABLE_NAME &&
table != LokiAPIDatabase.openGroupProfilePictureTable &&
table != JobDatabase.Jobs.TABLE_NAME &&
table != JobDatabase.Constraints.TABLE_NAME &&
table != JobDatabase.Dependencies.TABLE_NAME &&
!table.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME) &&
!table.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME) &&
!table.startsWith("sqlite_")
}
@Throws(IOException::class)
private fun exportSchema(input: SQLiteDatabase, outputStream: BackupFrameOutputStream): List<String> {
val tables: MutableList<String> = LinkedList()
input.rawQuery("SELECT sql, name, type FROM sqlite_master", null).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
val sql = cursor.getString(0)
val name = cursor.getString(1)
val type = cursor.getString(2)
if (sql != null) {
val isSmsFtsSecretTable = name != null && name != SearchDatabase.SMS_FTS_TABLE_NAME && name.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME)
val isMmsFtsSecretTable = name != null && name != SearchDatabase.MMS_FTS_TABLE_NAME && name.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME)
if (!isSmsFtsSecretTable && !isMmsFtsSecretTable) {
if ("table" == type) {
tables.add(name)
}
outputStream.writeSql(SqlStatement.newBuilder().setStatement(cursor.getString(0)).build())
}
}
}
}
return tables
}
@Throws(IOException::class)
private fun exportTable(table: String,
input: SQLiteDatabase,
outputStream: BackupFrameOutputStream,
predicate: Predicate<Cursor>?,
postProcess: Consumer<Cursor>?,
count: Int): Int {
var count = count
val template = "INSERT INTO $table VALUES "
input.rawQuery("SELECT * FROM $table", null).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
EventBus.getDefault().post(BackupEvent.createProgress(++count))
if (predicate != null && !predicate.test(cursor)) continue
val statement = StringBuilder(template)
val statementBuilder = SqlStatement.newBuilder()
statement.append('(')
for (i in 0 until cursor.columnCount) {
statement.append('?')
when (cursor.getType(i)) {
Cursor.FIELD_TYPE_STRING -> {
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
.setStringParamter(cursor.getString(i)))
}
Cursor.FIELD_TYPE_FLOAT -> {
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
.setDoubleParameter(cursor.getDouble(i)))
}
Cursor.FIELD_TYPE_INTEGER -> {
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
.setIntegerParameter(cursor.getLong(i)))
}
Cursor.FIELD_TYPE_BLOB -> {
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
.setBlobParameter(ByteString.copyFrom(cursor.getBlob(i))))
}
Cursor.FIELD_TYPE_NULL -> {
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
.setNullparameter(true))
}
else -> {
throw AssertionError("unknown type?" + cursor.getType(i))
}
}
if (i < cursor.columnCount - 1) {
statement.append(',')
}
}
statement.append(')')
outputStream.writeSql(statementBuilder.setStatement(statement.toString()).build())
postProcess?.accept(cursor)
}
}
return count
}
private fun exportAttachment(attachmentSecret: AttachmentSecret, cursor: Cursor, outputStream: BackupFrameOutputStream) {
try {
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.ROW_ID))
val uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.UNIQUE_ID))
var size = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.SIZE))
val data = cursor.getString(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA))
val random = cursor.getBlob(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA_RANDOM))
if (!TextUtils.isEmpty(data) && size <= 0) {
size = calculateVeryOldStreamLength(attachmentSecret, random, data)
}
if (!TextUtils.isEmpty(data) && size > 0) {
val inputStream: InputStream = if (random != null && random.size == 32) {
ModernDecryptingPartInputStream.createFor(attachmentSecret, random, File(data), 0)
} else {
ClassicDecryptingPartInputStream.createFor(attachmentSecret, File(data))
}
outputStream.writeAttachment(AttachmentId(rowId, uniqueId), inputStream, size)
}
} catch (e: IOException) {
Log.w(TAG, e)
}
}
@Throws(IOException::class)
private fun calculateVeryOldStreamLength(attachmentSecret: AttachmentSecret, random: ByteArray?, data: String): Long {
var result: Long = 0
val inputStream: InputStream = if (random != null && random.size == 32) {
ModernDecryptingPartInputStream.createFor(attachmentSecret, random, File(data), 0)
} else {
ClassicDecryptingPartInputStream.createFor(attachmentSecret, File(data))
}
var read: Int
val buffer = ByteArray(8192)
while (inputStream.read(buffer, 0, buffer.size).also { read = it } != -1) {
result += read.toLong()
}
return result
}
private fun isForNonExpiringMessage(db: SQLiteDatabase, mmsId: Long): Boolean {
val columns = arrayOf(MmsSmsColumns.EXPIRES_IN)
val where = MmsSmsColumns.ID + " = ?"
val args = arrayOf(mmsId.toString())
db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null).use { mmsCursor ->
if (mmsCursor != null && mmsCursor.moveToFirst()) {
return mmsCursor.getLong(0) == 0L
}
}
return false
}
private class BackupFrameOutputStream : Closeable, Flushable {
private val outputStream: OutputStream
private var cipher: Cipher
private var mac: Mac
private val cipherKey: ByteArray
private val macKey: ByteArray
private val iv: ByteArray
private var counter: Int = 0
constructor(outputStream: OutputStream, passphrase: String) : super() {
try {
val salt = Util.getSecretBytes(32)
val key = BackupUtil.computeBackupKey(passphrase, salt)
val derived = HKDFv3().deriveSecrets(key, "Backup Export".toByteArray(), 64)
val split = ByteUtil.split(derived, 32, 32)
cipherKey = split[0]
macKey = split[1]
cipher = Cipher.getInstance("AES/CTR/NoPadding")
mac = Mac.getInstance("HmacSHA256")
this.outputStream = outputStream
iv = Util.getSecretBytes(16)
counter = Conversions.byteArrayToInt(iv)
mac.init(SecretKeySpec(macKey, "HmacSHA256"))
val header = BackupFrame.newBuilder().setHeader(Header.newBuilder()
.setIv(ByteString.copyFrom(iv))
.setSalt(ByteString.copyFrom(salt)))
.build().toByteArray()
outputStream.write(Conversions.intToByteArray(header.size))
outputStream.write(header)
} catch (e: Exception) {
when (e) {
is NoSuchAlgorithmException,
is NoSuchPaddingException,
is InvalidKeyException -> {
throw AssertionError(e)
}
else -> throw e
}
}
}
@Throws(IOException::class)
fun writeSql(statement: SqlStatement) {
write(outputStream, BackupFrame.newBuilder().setStatement(statement).build())
}
@Throws(IOException::class)
fun writePreferenceEntry(preference: SharedPreference?) {
write(outputStream, BackupFrame.newBuilder().setPreference(preference).build())
}
@Throws(IOException::class)
fun writeAvatar(avatarName: String, inputStream: InputStream, size: Long) {
write(outputStream, BackupFrame.newBuilder()
.setAvatar(Avatar.newBuilder()
.setName(avatarName)
.setLength(Util.toIntExact(size))
.build())
.build())
writeStream(inputStream)
}
@Throws(IOException::class)
fun writeAttachment(attachmentId: AttachmentId, inputStream: InputStream, size: Long) {
write(outputStream, BackupFrame.newBuilder()
.setAttachment(Attachment.newBuilder()
.setRowId(attachmentId.rowId)
.setAttachmentId(attachmentId.uniqueId)
.setLength(Util.toIntExact(size))
.build())
.build())
writeStream(inputStream)
}
@Throws(IOException::class)
fun writeSticker(rowId: Long, inputStream: InputStream, size: Long) {
write(outputStream, BackupFrame.newBuilder()
.setSticker(Sticker.newBuilder()
.setRowId(rowId)
.setLength(Util.toIntExact(size))
.build())
.build())
writeStream(inputStream)
}
@Throws(IOException::class)
fun writeDatabaseVersion(version: Int) {
write(outputStream, BackupFrame.newBuilder()
.setVersion(DatabaseVersion.newBuilder().setVersion(version))
.build())
}
@Throws(IOException::class)
fun writeEnd() {
write(outputStream, BackupFrame.newBuilder().setEnd(true).build())
}
@Throws(IOException::class)
private fun writeStream(inputStream: InputStream) {
try {
Conversions.intToByteArray(iv, 0, counter++)
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
mac.update(iv)
val buffer = ByteArray(8192)
var read: Int
while (inputStream.read(buffer).also { read = it } != -1) {
val ciphertext = cipher.update(buffer, 0, read)
if (ciphertext != null) {
outputStream.write(ciphertext)
mac.update(ciphertext)
}
}
val remainder = cipher.doFinal()
outputStream.write(remainder)
mac.update(remainder)
val attachmentDigest = mac.doFinal()
outputStream.write(attachmentDigest, 0, 10)
} catch (e: Exception) {
when (e) {
is InvalidKeyException,
is InvalidAlgorithmParameterException,
is IllegalBlockSizeException,
is BadPaddingException -> {
throw AssertionError(e)
}
else -> throw e
}
}
}
@Throws(IOException::class)
private fun write(out: OutputStream, frame: BackupFrame) {
try {
Conversions.intToByteArray(iv, 0, counter++)
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
val frameCiphertext = cipher.doFinal(frame.toByteArray())
val frameMac = mac.doFinal(frameCiphertext)
val length = Conversions.intToByteArray(frameCiphertext.size + 10)
out.write(length)
out.write(frameCiphertext)
out.write(frameMac, 0, 10)
} catch (e: Exception) {
when (e) {
is InvalidKeyException,
is InvalidAlgorithmParameterException,
is IllegalBlockSizeException,
is BadPaddingException -> {
throw AssertionError(e)
}
else -> throw e
}
}
}
@Throws(IOException::class)
override fun flush() {
outputStream.flush()
}
@Throws(IOException::class)
override fun close() {
outputStream.close()
}
}
}

View File

@ -1,352 +0,0 @@
package org.thoughtcrime.securesms.backup
import android.annotation.SuppressLint
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import androidx.annotation.WorkerThread
import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.greenrobot.eventbus.EventBus
import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Conversions
import org.session.libsession.utilities.Util
import org.session.libsignal.crypto.kdf.HKDFv3
import org.session.libsignal.utilities.ByteUtil
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.backup.BackupProtos.Attachment
import org.thoughtcrime.securesms.backup.BackupProtos.Avatar
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame
import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion
import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference
import org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement
import org.thoughtcrime.securesms.crypto.AttachmentSecret
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream
import org.thoughtcrime.securesms.database.AttachmentDatabase
import org.thoughtcrime.securesms.database.GroupReceiptDatabase
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.MmsSmsColumns
import org.thoughtcrime.securesms.database.SearchDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.BackupUtil
import java.io.Closeable
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.security.InvalidAlgorithmParameterException
import java.security.InvalidKeyException
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.util.LinkedList
import java.util.Locale
import javax.crypto.BadPaddingException
import javax.crypto.Cipher
import javax.crypto.IllegalBlockSizeException
import javax.crypto.Mac
import javax.crypto.NoSuchPaddingException
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
object FullBackupImporter {
/**
* Because BackupProtos.SharedPreference was made only to serialize string values,
* we use these 3-char prefixes to explicitly cast the values before inserting to a preference file.
*/
const val PREF_PREFIX_TYPE_INT = "i__"
const val PREF_PREFIX_TYPE_BOOLEAN = "b__"
private val TAG = FullBackupImporter::class.java.simpleName
@JvmStatic
@WorkerThread
@Throws(IOException::class)
fun importFromUri(context: Context,
attachmentSecret: AttachmentSecret,
db: SQLiteDatabase,
fileUri: Uri,
passphrase: String) {
val baseInputStream = context.contentResolver.openInputStream(fileUri)
?: throw IOException("Cannot open an input stream for the file URI: $fileUri")
var count = 0
try {
BackupRecordInputStream(baseInputStream, passphrase).use { inputStream ->
db.beginTransaction()
dropAllTables(db)
var frame: BackupFrame
while (!inputStream.readFrame().also { frame = it }.end) {
if (count++ % 100 == 0) EventBus.getDefault().post(BackupEvent.createProgress(count))
when {
frame.hasVersion() -> processVersion(db, frame.version)
frame.hasStatement() -> processStatement(db, frame.statement)
frame.hasPreference() -> processPreference(context, frame.preference)
frame.hasAttachment() -> processAttachment(context, attachmentSecret, db, frame.attachment, inputStream)
frame.hasAvatar() -> processAvatar(context, frame.avatar, inputStream)
}
}
trimEntriesForExpiredMessages(context, db)
db.setTransactionSuccessful()
}
} finally {
if (db.inTransaction()) {
db.endTransaction()
}
}
EventBus.getDefault().post(BackupEvent.createFinished())
}
@Throws(IOException::class)
private fun processVersion(db: SQLiteDatabase, version: DatabaseVersion) {
if (version.version > db.version) {
throw DatabaseDowngradeException(db.version, version.version)
}
db.version = version.version
}
private fun processStatement(db: SQLiteDatabase, statement: SqlStatement) {
val isForSmsFtsSecretTable = statement.statement.contains(SearchDatabase.SMS_FTS_TABLE_NAME + "_")
val isForMmsFtsSecretTable = statement.statement.contains(SearchDatabase.MMS_FTS_TABLE_NAME + "_")
val isForSqliteSecretTable = statement.statement.toLowerCase(Locale.ENGLISH).startsWith("create table sqlite_")
if (isForSmsFtsSecretTable || isForMmsFtsSecretTable || isForSqliteSecretTable) {
Log.i(TAG, "Ignoring import for statement: " + statement.statement)
return
}
val parameters: MutableList<Any?> = LinkedList()
for (parameter in statement.parametersList) {
when {
parameter.hasStringParamter() -> parameters.add(parameter.stringParamter)
parameter.hasDoubleParameter() -> parameters.add(parameter.doubleParameter)
parameter.hasIntegerParameter() -> parameters.add(parameter.integerParameter)
parameter.hasBlobParameter() -> parameters.add(parameter.blobParameter.toByteArray())
parameter.hasNullparameter() -> parameters.add(null)
}
}
if (parameters.size > 0) {
db.execSQL(statement.statement, parameters.toTypedArray())
} else {
db.execSQL(statement.statement)
}
}
@Throws(IOException::class)
private fun processAttachment(context: Context, attachmentSecret: AttachmentSecret,
db: SQLiteDatabase, attachment: Attachment,
inputStream: BackupRecordInputStream) {
val partsDirectory = context.getDir(AttachmentDatabase.DIRECTORY, Context.MODE_PRIVATE)
val dataFile = File.createTempFile("part", ".mms", partsDirectory)
val output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false)
inputStream.readAttachmentTo(output.second, attachment.length)
val contentValues = ContentValues()
contentValues.put(AttachmentDatabase.DATA, dataFile.absolutePath)
contentValues.put(AttachmentDatabase.THUMBNAIL, null as String?)
contentValues.put(AttachmentDatabase.DATA_RANDOM, output.first)
db.update(AttachmentDatabase.TABLE_NAME, contentValues,
"${AttachmentDatabase.ROW_ID} = ? AND ${AttachmentDatabase.UNIQUE_ID} = ?",
arrayOf(attachment.rowId.toString(), attachment.attachmentId.toString()))
}
@Throws(IOException::class)
private fun processAvatar(context: Context, avatar: Avatar, inputStream: BackupRecordInputStream) {
inputStream.readAttachmentTo(FileOutputStream(
AvatarHelper.getAvatarFile(context, Address.fromExternal(context, avatar.name))), avatar.length)
}
@SuppressLint("ApplySharedPref")
private fun processPreference(context: Context, preference: SharedPreference) {
val preferences = context.getSharedPreferences(preference.file, 0)
val key = preference.key
val value = preference.value
// See the comment next to PREF_PREFIX_TYPE_* constants.
when {
key.startsWith(PREF_PREFIX_TYPE_INT) ->
preferences.edit().putInt(
key.substring(PREF_PREFIX_TYPE_INT.length),
value.toInt()
).commit()
key.startsWith(PREF_PREFIX_TYPE_BOOLEAN) ->
preferences.edit().putBoolean(
key.substring(PREF_PREFIX_TYPE_BOOLEAN.length),
value.toBoolean()
).commit()
else ->
preferences.edit().putString(key, value).commit()
}
}
private fun dropAllTables(db: SQLiteDatabase) {
db.rawQuery("SELECT name, type FROM sqlite_master", null).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
val name = cursor.getString(0)
val type = cursor.getString(1)
if ("table" == type && !name.startsWith("sqlite_")) {
db.execSQL("DROP TABLE IF EXISTS $name")
}
}
}
}
private fun trimEntriesForExpiredMessages(context: Context, db: SQLiteDatabase) {
val trimmedCondition = " NOT IN (SELECT ${MmsSmsColumns.ID} FROM ${MmsDatabase.TABLE_NAME})"
db.delete(GroupReceiptDatabase.TABLE_NAME, GroupReceiptDatabase.MMS_ID + trimmedCondition, null)
val columns = arrayOf(AttachmentDatabase.ROW_ID, AttachmentDatabase.UNIQUE_ID)
val where = AttachmentDatabase.MMS_ID + trimmedCondition
db.query(AttachmentDatabase.TABLE_NAME, columns, where, null, null, null, null).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
DatabaseComponent.get(context).attachmentDatabase()
.deleteAttachment(AttachmentId(cursor.getLong(0), cursor.getLong(1)))
}
}
db.query(ThreadDatabase.TABLE_NAME, arrayOf(ThreadDatabase.ID),
ThreadDatabase.EXPIRES_IN + " > 0", null, null, null, null).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
DatabaseComponent.get(context).threadDatabase().update(cursor.getLong(0), false)
}
}
}
private class BackupRecordInputStream : Closeable {
private val inputStream: InputStream
private val cipher: Cipher
private val mac: Mac
private val cipherKey: ByteArray
private val macKey: ByteArray
private val iv: ByteArray
private var counter = 0
@Throws(IOException::class)
constructor(inputStream: InputStream, passphrase: String) : super() {
try {
this.inputStream = inputStream
val headerLengthBytes = ByteArray(4)
Util.readFully(this.inputStream, headerLengthBytes)
val headerLength = Conversions.byteArrayToInt(headerLengthBytes)
val headerFrame = ByteArray(headerLength)
Util.readFully(this.inputStream, headerFrame)
val frame = BackupFrame.parseFrom(headerFrame)
if (!frame.hasHeader()) {
throw IOException("Backup stream does not start with header!")
}
val header = frame.header
iv = header.iv.toByteArray()
if (iv.size != 16) {
throw IOException("Invalid IV length!")
}
val key = BackupUtil.computeBackupKey(passphrase, if (header.hasSalt()) header.salt.toByteArray() else null)
val derived = HKDFv3().deriveSecrets(key, "Backup Export".toByteArray(), 64)
val split = ByteUtil.split(derived, 32, 32)
cipherKey = split[0]
macKey = split[1]
cipher = Cipher.getInstance("AES/CTR/NoPadding")
mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(macKey, "HmacSHA256"))
counter = Conversions.byteArrayToInt(iv)
} catch (e: Exception) {
when (e) {
is NoSuchAlgorithmException,
is NoSuchPaddingException,
is InvalidKeyException -> {
throw AssertionError(e)
}
else -> throw e
}
}
}
@Throws(IOException::class)
fun readFrame(): BackupFrame {
return readFrame(inputStream)
}
@Throws(IOException::class)
fun readAttachmentTo(out: OutputStream, length: Int) {
var length = length
try {
Conversions.intToByteArray(iv, 0, counter++)
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
mac.update(iv)
val buffer = ByteArray(8192)
while (length > 0) {
val read = inputStream.read(buffer, 0, Math.min(buffer.size, length))
if (read == -1) throw IOException("File ended early!")
mac.update(buffer, 0, read)
val plaintext = cipher.update(buffer, 0, read)
if (plaintext != null) {
out.write(plaintext, 0, plaintext.size)
}
length -= read
}
val plaintext = cipher.doFinal()
if (plaintext != null) {
out.write(plaintext, 0, plaintext.size)
}
out.close()
val ourMac = ByteUtil.trim(mac.doFinal(), 10)
val theirMac = ByteArray(10)
try {
Util.readFully(inputStream, theirMac)
} catch (e: IOException) {
throw IOException(e)
}
if (!MessageDigest.isEqual(ourMac, theirMac)) {
throw IOException("Bad MAC")
}
} catch (e: Exception) {
when (e) {
is InvalidKeyException,
is InvalidAlgorithmParameterException,
is IllegalBlockSizeException,
is BadPaddingException -> {
throw AssertionError(e)
}
else -> throw e
}
}
}
@Throws(IOException::class)
private fun readFrame(`in`: InputStream?): BackupFrame {
return try {
val length = ByteArray(4)
Util.readFully(`in`, length)
val frame = ByteArray(Conversions.byteArrayToInt(length))
Util.readFully(`in`, frame)
val theirMac = ByteArray(10)
System.arraycopy(frame, frame.size - 10, theirMac, 0, theirMac.size)
mac.update(frame, 0, frame.size - 10)
val ourMac = ByteUtil.trim(mac.doFinal(), 10)
if (!MessageDigest.isEqual(ourMac, theirMac)) {
throw IOException("Bad MAC")
}
Conversions.intToByteArray(iv, 0, counter++)
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
val plaintext = cipher.doFinal(frame, 0, frame.size - 10)
BackupFrame.parseFrom(plaintext)
} catch (e: Exception) {
when (e) {
is InvalidKeyException,
is InvalidAlgorithmParameterException,
is IllegalBlockSizeException,
is BadPaddingException -> {
throw AssertionError(e)
}
else -> throw e
}
}
}
@Throws(IOException::class)
override fun close() {
inputStream.close()
}
}
class DatabaseDowngradeException internal constructor(currentVersion: Int, backupVersion: Int) :
IOException("Tried to import a backup with version $backupVersion into a database with version $currentVersion")
}

View File

@ -255,17 +255,12 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
viewModel.callState.collect { state ->
Log.d("Loki", "Consuming view model state $state")
when (state) {
CALL_RINGING -> {
if (wantsToAnswer) {
answerCall()
wantsToAnswer = false
}
}
CALL_OUTGOING -> {
}
CALL_CONNECTED -> {
CALL_RINGING -> if (wantsToAnswer) {
answerCall()
wantsToAnswer = false
}
CALL_CONNECTED -> wantsToAnswer = false
else -> {}
}
updateControls(state)
}
@ -345,6 +340,11 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
binding.localRenderer.removeAllViews()
if (isEnabled) {
viewModel.localRenderer?.let { surfaceView ->
surfaceView.setZOrderOnTop(true)
// Mirror the video preview of the person making the call to prevent disorienting them
surfaceView.setMirror(true)
binding.localRenderer.addView(surfaceView)
}
viewModel.localFloatingRenderer?.let { surfaceView ->

View File

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

View File

@ -1,70 +0,0 @@
package org.thoughtcrime.securesms.components
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.RelativeLayout
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewSeparatorBinding
import org.thoughtcrime.securesms.util.toPx
import org.session.libsession.utilities.ThemeUtil
class LabeledSeparatorView : RelativeLayout {
private lateinit var binding: ViewSeparatorBinding
private val path = Path()
private val paint: Paint by lazy {
val result = Paint()
result.style = Paint.Style.STROKE
result.color = ThemeUtil.getThemedColor(context, R.attr.dividerHorizontal)
result.strokeWidth = toPx(1, resources).toFloat()
result.isAntiAlias = true
result
}
// region Lifecycle
constructor(context: Context) : super(context) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {
setUpViewHierarchy()
}
private fun setUpViewHierarchy() {
binding = ViewSeparatorBinding.inflate(LayoutInflater.from(context))
val layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
addView(binding.root, layoutParams)
setWillNotDraw(false)
}
// endregion
// region Updating
override fun onDraw(c: Canvas) {
super.onDraw(c)
val w = width.toFloat()
val h = height.toFloat()
val hMargin = toPx(16, resources).toFloat()
path.reset()
path.moveTo(0.0f, h / 2)
path.lineTo(binding.titleTextView.left - hMargin, h / 2)
path.addRoundRect(binding.titleTextView.left - hMargin, toPx(1, resources).toFloat(), binding.titleTextView.right + hMargin, h - toPx(1, resources).toFloat(), h / 2, h / 2, Path.Direction.CCW)
path.moveTo(binding.titleTextView.right + hMargin, h / 2)
path.lineTo(w, h / 2)
path.close()
c.drawPath(path, paint)
}
// endregion
}

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

@ -2,10 +2,10 @@ package org.thoughtcrime.securesms.components
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageView
import android.widget.RelativeLayout
import androidx.annotation.DimenRes
import com.bumptech.glide.load.engine.DiskCacheStrategy
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewProfilePictureBinding
@ -18,27 +18,32 @@ import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
class ProfilePictureView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : RelativeLayout(context, attrs) {
private val binding: ViewProfilePictureBinding by lazy { ViewProfilePictureBinding.bind(this) }
lateinit var glide: GlideRequests
private val binding = ViewProfilePictureBinding.inflate(LayoutInflater.from(context), this)
private val glide: GlideRequests = GlideApp.with(this)
var publicKey: String? = null
var displayName: String? = null
var additionalPublicKey: String? = null
var additionalDisplayName: String? = null
var isLarge = false
private val profilePicturesCache = mutableMapOf<String, String?>()
private val unknownRecipientDrawable = ResourceContactPhoto(R.drawable.ic_profile_default)
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false)
private val unknownOpenGroupDrawable = ResourceContactPhoto(R.drawable.ic_notification)
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false)
private val profilePicturesCache = mutableMapOf<View, Recipient>()
private val unknownRecipientDrawable by lazy { ResourceContactPhoto(R.drawable.ic_profile_default)
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) }
private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification)
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) }
// endregion
constructor(context: Context, sender: Recipient): this(context) {
update(sender)
}
// region Updating
fun update(recipient: Recipient) {
fun getUserDisplayName(publicKey: String): String {
@ -52,14 +57,21 @@ class ProfilePictureView @JvmOverloads constructor(
.sorted()
.take(2)
.toMutableList()
val pk = members.getOrNull(0)?.serialize() ?: ""
publicKey = pk
displayName = getUserDisplayName(pk)
val apk = members.getOrNull(1)?.serialize() ?: ""
additionalPublicKey = apk
additionalDisplayName = getUserDisplayName(apk)
if (members.size <= 1) {
publicKey = ""
displayName = ""
additionalPublicKey = ""
additionalDisplayName = ""
} else {
val pk = members.getOrNull(0)?.serialize() ?: ""
publicKey = pk
displayName = getUserDisplayName(pk)
val apk = members.getOrNull(1)?.serialize() ?: ""
additionalPublicKey = apk
additionalDisplayName = getUserDisplayName(apk)
}
} else if(recipient.isOpenGroupInboxRecipient) {
val publicKey = GroupUtil.getDecodedOpenGroupInbox(recipient.address.serialize())
val publicKey = GroupUtil.getDecodedOpenGroupInboxSessionId(recipient.address.serialize())
this.publicKey = publicKey
displayName = getUserDisplayName(publicKey)
additionalPublicKey = null
@ -73,12 +85,11 @@ class ProfilePictureView @JvmOverloads constructor(
}
fun update() {
if (!this::glide.isInitialized) return
val publicKey = publicKey ?: return
val additionalPublicKey = additionalPublicKey
if (additionalPublicKey != null) {
setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName, R.dimen.small_profile_picture_size)
setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName, R.dimen.small_profile_picture_size)
setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName)
setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName)
binding.doubleModeImageViewContainer.visibility = View.VISIBLE
} else {
glide.clear(binding.doubleModeImageView1)
@ -86,14 +97,14 @@ class ProfilePictureView @JvmOverloads constructor(
binding.doubleModeImageViewContainer.visibility = View.INVISIBLE
}
if (additionalPublicKey == null && !isLarge) {
setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName, R.dimen.medium_profile_picture_size)
setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName)
binding.singleModeImageView.visibility = View.VISIBLE
} else {
glide.clear(binding.singleModeImageView)
binding.singleModeImageView.visibility = View.INVISIBLE
}
if (additionalPublicKey == null && isLarge) {
setProfilePictureIfNeeded(binding.largeSingleModeImageView, publicKey, displayName, R.dimen.large_profile_picture_size)
setProfilePictureIfNeeded(binding.largeSingleModeImageView, publicKey, displayName)
binding.largeSingleModeImageView.visibility = View.VISIBLE
} else {
glide.clear(binding.largeSingleModeImageView)
@ -101,37 +112,43 @@ class ProfilePictureView @JvmOverloads constructor(
}
}
private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?, @DimenRes sizeResId: Int) {
private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?) {
if (publicKey.isNotEmpty()) {
val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false)
if (profilePicturesCache.containsKey(publicKey) && profilePicturesCache[publicKey] == recipient.profileAvatar) return
if (profilePicturesCache[imageView] == recipient) return
profilePicturesCache[imageView] = recipient
val signalProfilePicture = recipient.contactPhoto
val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject
glide.clear(imageView)
val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
if (signalProfilePicture != null && avatar != "0" && avatar != "") {
glide.clear(imageView)
glide.load(signalProfilePicture)
.placeholder(unknownRecipientDrawable)
.centerCrop()
.error(unknownRecipientDrawable)
.error(glide.load(placeholder))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.circleCrop()
.into(imageView)
} else if (recipient.isOpenGroupRecipient && recipient.groupAvatarId == null) {
} else if (recipient.isCommunityRecipient && recipient.groupAvatarId == null) {
glide.clear(imageView)
imageView.setImageDrawable(unknownOpenGroupDrawable)
glide.load(unknownOpenGroupDrawable)
.centerCrop()
.circleCrop()
.into(imageView)
} else {
val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
glide.clear(imageView)
glide.load(placeholder)
.placeholder(unknownRecipientDrawable)
.centerCrop()
.circleCrop()
.diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView)
}
profilePicturesCache[publicKey] = recipient.profileAvatar
} else {
imageView.setImageDrawable(null)
glide.load(unknownRecipientDrawable)
.centerCrop()
.into(imageView)
}
}

View File

@ -0,0 +1,30 @@
package org.thoughtcrime.securesms.components
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.viewpager.widget.ViewPager
/**
* An extension of ViewPager to swallow erroneous multi-touch exceptions.
*
* @see https://stackoverflow.com/questions/6919292/pointerindex-out-of-range-android-multitouch
*/
class SafeViewPager @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : ViewPager(context, attrs) {
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean = try {
super.onTouchEvent(event)
} catch (e: IllegalArgumentException) {
false
}
override fun onInterceptTouchEvent(event: MotionEvent?): Boolean = try {
super.onInterceptTouchEvent(event)
} catch (e: IllegalArgumentException) {
false
}
}

View File

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

View File

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

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

View File

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

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> {
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 androidx.annotation.NonNull;
@ -24,7 +24,7 @@ public final class ContactUtil {
return SpanUtil.italic(context.getString(R.string.MessageNotifier_unknown_contact_message));
}
public static @NonNull String getDisplayName(@Nullable Contact contact) {
private static @NonNull String getDisplayName(@Nullable Contact contact) {
if (contact == null) {
return "";
}

View File

@ -7,6 +7,7 @@ import android.view.View
import android.widget.LinearLayout
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewUserBinding
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities
@ -47,15 +48,14 @@ class UserView : LinearLayout {
// region Updating
fun bind(user: Recipient, glide: GlideRequests, actionIndicator: ActionIndicator, isSelected: Boolean = false) {
val isLocalUser = user.isLocalNumber
fun getUserDisplayName(publicKey: String): String {
if (isLocalUser) return context.getString(R.string.MessageRecord_you)
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey)
return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey
}
val threadID = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(user)
MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(threadID, context) // FIXME: This is a bad place to do this
val address = user.address.serialize()
binding.profilePictureView.root.glide = glide
binding.profilePictureView.root.update(user)
binding.profilePictureView.update(user)
binding.actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24)
binding.nameTextView.text = if (user.isGroupRecipient) user.name else getUserDisplayName(address)
when (actionIndicator) {
@ -87,7 +87,7 @@ class UserView : LinearLayout {
}
fun unbind() {
binding.profilePictureView.root.recycle()
binding.profilePictureView.recycle()
}
// endregion
}

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_read_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

@ -32,14 +32,13 @@ class ContactListAdapter(
class ContactViewHolder(private val binding: ViewContactBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(contact: ContactListItem.Contact, glide: GlideRequests, listener: (Recipient) -> Unit) {
binding.profilePictureView.root.glide = glide
binding.profilePictureView.root.update(contact.recipient)
binding.profilePictureView.update(contact.recipient)
binding.nameTextView.text = contact.displayName
binding.root.setOnClickListener { listener(contact.recipient) }
}
fun unbind() {
binding.profilePictureView.root.recycle()
binding.profilePictureView.recycle()
}
}

View File

@ -55,7 +55,7 @@ class NewConversationHomeFragment : Fragment() {
val displayName = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionId
ContactListItem.Contact(it, displayName)
}.sortedBy { it.displayName }
.groupBy { if (PublicKeyValidation.isValid(it.displayName)) unknownSectionTitle else it.displayName.first().uppercase() }
.groupBy { if (PublicKeyValidation.isValid(it.displayName)) unknownSectionTitle else it.displayName.firstOrNull()?.uppercase() ?: unknownSectionTitle }
.toMutableMap()
contactGroups.remove(unknownSectionTitle)?.let { contactGroups.put(unknownSectionTitle, it) }
adapter.items = contactGroups.flatMap { entry -> listOf(ContactListItem.Header(entry.key)) + entry.value }

View File

@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.conversation.v2
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.database.Cursor
@ -23,18 +22,25 @@ import kotlinx.coroutines.launch
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageBinding
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter
import org.thoughtcrime.securesms.database.MmsSmsColumns
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.showSessionDialog
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.min
class ConversationAdapter(
context: Context,
cursor: Cursor,
originalLastSeen: Long,
private val isReversed: Boolean,
private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit,
private val onItemSwipeToReply: (MessageRecord, Int) -> Unit,
private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit,
@ -52,6 +58,9 @@ class ConversationAdapter(
private val updateQueue = Channel<String>(1024, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val contactCache = SparseArray<Contact>(100)
private val contactLoadedCache = SparseBooleanArray(100)
private val lastSeen = AtomicLong(originalLastSeen)
private var lastSentMessageId: Long = -1L
init {
lifecycleCoroutineScope.launch(IO) {
while (isActive) {
@ -128,8 +137,10 @@ class ConversationAdapter(
searchQuery,
contact,
senderId,
lastSeen.get(),
visibleMessageViewDelegate,
onAttachmentNeedsDownload
onAttachmentNeedsDownload,
lastSentMessageId
)
if (!message.isDeleted) {
@ -146,17 +157,15 @@ class ConversationAdapter(
viewHolder.view.bind(message, messageBefore)
if (message.isCallLog && message.isFirstMissedCall) {
viewHolder.view.setOnClickListener {
AlertDialog.Builder(context)
.setTitle(R.string.CallNotificationBuilder_first_call_title)
.setMessage(R.string.CallNotificationBuilder_first_call_message)
.setPositiveButton(R.string.activity_settings_title) { _, _ ->
val intent = Intent(context, PrivacySettingsActivity::class.java)
context.startActivity(intent)
context.showSessionDialog {
title(R.string.CallNotificationBuilder_first_call_title)
text(R.string.CallNotificationBuilder_first_call_message)
button(R.string.activity_settings_title) {
Intent(context, PrivacySettingsActivity::class.java)
.let(context::startActivity)
}
.setNeutralButton(R.string.cancel) { d, _ ->
d.dismiss()
}
.show()
cancelButton()
}
}
} else {
viewHolder.view.setOnClickListener(null)
@ -185,19 +194,38 @@ class ConversationAdapter(
private fun getMessageBefore(position: Int, cursor: Cursor): MessageRecord? {
// The message that's visually before the current one is actually after the current
// one for the cursor because the layout is reversed
if (!cursor.moveToPosition(position + 1)) { return null }
if (isReversed && !cursor.moveToPosition(position + 1)) { return null }
if (!isReversed && !cursor.moveToPosition(position - 1)) { return null }
return messageDB.readerFor(cursor).current
}
private fun getMessageAfter(position: Int, cursor: Cursor): MessageRecord? {
// The message that's visually after the current one is actually before the current
// one for the cursor because the layout is reversed
if (!cursor.moveToPosition(position - 1)) { return null }
if (isReversed && !cursor.moveToPosition(position - 1)) { return null }
if (!isReversed && !cursor.moveToPosition(position + 1)) { return null }
return messageDB.readerFor(cursor).current
}
private fun getLastSentMessageId(cursor: Cursor): Long {
// If we don't move to first (or at least step backwards) we can step off the end of the
// cursor and any query will return an "Index = -1" error.
val cursorHasContent = cursor.moveToFirst()
if (cursorHasContent) {
val thisThreadId = cursor.getLong(4) // Column index 4 is "thread_id"
if (thisThreadId != -1L) {
val thisUsersSessionId = TextSecurePreferences.getLocalNumber(context)
return messageDB.getLastSentMessageFromSender(thisThreadId, thisUsersSessionId)
}
}
return -1L
}
override fun changeCursor(cursor: Cursor?) {
super.changeCursor(cursor)
val toRemove = mutableSetOf<MessageRecord>()
val toDeselect = mutableSetOf<Pair<Int, MessageRecord>>()
for (selected in selectedItems) {
@ -215,15 +243,39 @@ class ConversationAdapter(
toDeselect.iterator().forEach { (pos, record) ->
onDeselect(record, pos)
}
// This value gets updated here ONLY when the cursor changes, and the value is then passed
// through to `VisibleMessageView.bind` each time we bind via `onBindItemViewHolder`, above.
// If there are no messages then lastSentMessageId is assigned the value -1L.
if (cursor != null) { lastSentMessageId = getLastSentMessageId(cursor) }
}
fun findLastSeenItemPosition(lastSeenTimestamp: Long): Int? {
val cursor = this.cursor
if (lastSeenTimestamp <= 0L || cursor == null || !isActiveCursor) return null
if (cursor == null || !isActiveCursor) return null
if (lastSeenTimestamp == 0L) {
if (isReversed && cursor.moveToLast()) { return cursor.position }
if (!isReversed && cursor.moveToFirst()) { return cursor.position }
}
// Loop from the newest message to the oldest until we find one older (or equal to)
// the lastSeenTimestamp, then return that message index
for (i in 0 until itemCount) {
cursor.moveToPosition(i)
val message = messageDB.readerFor(cursor).current
if (message.isOutgoing || message.dateReceived <= lastSeenTimestamp) { return i }
if (isReversed) {
cursor.moveToPosition(i)
val (outgoing, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor)
if (outgoing || dateSent <= lastSeenTimestamp) {
return i
}
}
else {
val index = ((itemCount - 1) - i)
cursor.moveToPosition(index)
val (outgoing, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor)
if (outgoing || dateSent <= lastSeenTimestamp) {
return min(itemCount - 1, (index + 1))
}
}
}
return null
}
@ -233,8 +285,8 @@ class ConversationAdapter(
if (timestamp <= 0L || cursor == null || !isActiveCursor) return null
for (i in 0 until itemCount) {
cursor.moveToPosition(i)
val message = messageDB.readerFor(cursor).current
if (message.dateSent == timestamp) { return i }
val (_, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor)
if (dateSent == timestamp) { return i }
}
return null
}
@ -243,4 +295,11 @@ class ConversationAdapter(
this.searchQuery = query
notifyDataSetChanged()
}
fun getTimestampForItemAt(firstVisiblePosition: Int): Long? {
val cursor = this.cursor ?: return null
if (!cursor.moveToPosition(firstVisiblePosition)) return null
val message = messageDB.readerFor(cursor).current ?: return null
return message.timestamp
}
}

View File

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

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) {
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,27 +1,38 @@
package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.goterl.lazysodium.utils.KeyPair
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository
import java.util.UUID
class ConversationViewModel(
@ -31,14 +42,35 @@ class ConversationViewModel(
private val storage: Storage
) : ViewModel() {
private val _uiState = MutableStateFlow(ConversationUiState())
val showSendAfterApprovalText: Boolean
get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false
private val _uiState = MutableStateFlow(ConversationUiState(conversationExists = true))
val uiState: StateFlow<ConversationUiState> = _uiState
val recipient: Recipient?
get() = repository.maybeGetRecipientForThreadId(threadId)
private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce {
repository.maybeGetRecipientForThreadId(threadId)
}
val expirationConfiguration: ExpirationConfiguration?
get() = storage.getExpirationConfiguration(threadId)
val recipient: Recipient?
get() = _recipient.value
val blindedRecipient: Recipient?
get() = _recipient.value?.let { recipient ->
when {
recipient.isOpenGroupOutboxRecipient -> recipient
recipient.isOpenGroupInboxRecipient -> repository.maybeGetBlindedRecipient(recipient)
else -> null
}
}
private var _openGroup: RetrieveOnce<OpenGroup> = RetrieveOnce {
storage.getOpenGroup(threadId)
}
val openGroup: OpenGroup?
get() = storage.getOpenGroup(threadId)
get() = _openGroup.value
val serverCapabilities: List<String>
get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf()
@ -49,6 +81,28 @@ class ConversationViewModel(
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
}
val isMessageRequestThread : Boolean
get() {
val recipient = recipient ?: return false
return !recipient.isLocalNumber && !recipient.isGroupRecipient && !recipient.isApproved
}
val canReactToMessages: Boolean
// allow reactions if the open group is null (normal conversations) or the open group's capabilities include reactions
get() = (openGroup == null || OpenGroupApi.Capability.REACTIONS.name.lowercase() in serverCapabilities)
init {
viewModelScope.launch(Dispatchers.IO) {
repository.recipientUpdateFlow(threadId)
.collect { recipient ->
if (recipient == null && _uiState.value.conversationExists) {
_uiState.update { it.copy(conversationExists = false) }
}
}
}
}
fun saveDraft(text: String) {
GlobalScope.launch(Dispatchers.IO) {
repository.saveDraft(threadId, text)
@ -98,9 +152,14 @@ class ConversationViewModel(
}
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.")
repository.deleteForEveryone(threadId, recipient, message)
.onSuccess {
Log.d("Loki", "Deleted message ${message.id} ")
}
.onFailure {
Log.w("Loki", "FAILED TO delete message ${message.id} ")
showMessage("Couldn't delete message due to error: $it")
}
}
@ -122,10 +181,15 @@ class ConversationViewModel(
}
}
fun banAndDeleteAll(recipient: Recipient) = viewModelScope.launch {
repository.banAndDeleteAll(threadId, recipient)
fun banAndDeleteAll(messageRecord: MessageRecord) = viewModelScope.launch {
repository.banAndDeleteAll(threadId, messageRecord.individualRecipient)
.onSuccess {
// At this point the server side messages have been successfully deleted..
showMessage("Successfully banned user and deleted all their messages")
// ..so we can now delete all their messages in this thread from local storage & remove the views.
repository.deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord)
}
.onFailure {
showMessage("Couldn't execute request due to error: $it")
@ -170,6 +234,17 @@ class ConversationViewModel(
return repository.hasReceived(threadId)
}
fun updateRecipient() {
_recipient.updateTo(repository.maybeGetRecipientForThreadId(threadId))
}
fun hidesInputBar(): Boolean = openGroup?.canWrite != true &&
blindedRecipient?.blocksCommunityMessageRequests == true
fun legacyBannerRecipient(context: Context): Recipient? = recipient?.run {
storage.getLastLegacyRecipient(address.serialize())?.let { Recipient.from(context, Address.fromSerialized(it), false) }
}
@dagger.assisted.AssistedFactory
interface AssistedFactory {
fun create(threadId: Long, edKeyPair: KeyPair?): Factory
@ -193,5 +268,22 @@ data class UiMessage(val id: Long, val message: String)
data class ConversationUiState(
val uiMessages: List<UiMessage> = emptyList(),
val isMessageRequestAccepted: Boolean? = null
val isMessageRequestAccepted: Boolean? = null,
val conversationExists: Boolean
)
data class RetrieveOnce<T>(val retrieval: () -> T?) {
private var triedToRetrieve: Boolean = false
private var _value: T? = null
val value: T?
get() {
if (triedToRetrieve) { return _value }
triedToRetrieve = true
_value = retrieval()
return _value
}
fun updateTo(value: T?) { _value = value }
}

View File

@ -69,7 +69,6 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen
override fun onStart() {
super.onStart()
val window = dialog?.window ?: return
val isLightMode = UiModeUtilities.isDayUiMode(requireContext())
window.setDimAmount(if (isLightMode) 0.1f else 0.75f)
window.setDimAmount(0.6f)
}
}

View File

@ -1,98 +1,401 @@
package org.thoughtcrime.securesms.conversation.v2
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.core.view.isVisible
import android.view.LayoutInflater
import android.view.MotionEvent.ACTION_UP
import androidx.activity.viewModels
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityMessageDetailBinding
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.ExpirationUtil
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.IdPrefix
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.DateUtils
import java.text.SimpleDateFormat
import java.util.*
import org.thoughtcrime.securesms.ui.AppTheme
import org.thoughtcrime.securesms.ui.Avatar
import org.thoughtcrime.securesms.ui.CarouselNextButton
import org.thoughtcrime.securesms.ui.CarouselPrevButton
import org.thoughtcrime.securesms.ui.Cell
import org.thoughtcrime.securesms.ui.CellNoMargin
import org.thoughtcrime.securesms.ui.CellWithPaddingAndMargin
import org.thoughtcrime.securesms.ui.Divider
import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.HorizontalPagerIndicator
import org.thoughtcrime.securesms.ui.ItemButton
import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider
import org.thoughtcrime.securesms.ui.TitledText
import org.thoughtcrime.securesms.ui.blackAlpha40
import org.thoughtcrime.securesms.ui.colorDestructive
import org.thoughtcrime.securesms.ui.destructiveButtonColors
import javax.inject.Inject
@AndroidEntryPoint
class MessageDetailActivity: PassphraseRequiredActionBarActivity() {
private lateinit var binding: ActivityMessageDetailBinding
var messageRecord: MessageRecord? = null
class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
@Inject
lateinit var storage: Storage
// region Settings
private val viewModel: MessageDetailsViewModel by viewModels()
companion object {
// Extras
const val MESSAGE_TIMESTAMP = "message_timestamp"
const val ON_REPLY = 1
const val ON_RESEND = 2
const val ON_DELETE = 3
}
// endregion
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
binding = ActivityMessageDetailBinding.inflate(layoutInflater)
setContentView(binding.root)
title = resources.getString(R.string.conversation_context__menu_message_details)
val timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L)
// We only show this screen for messages fail to send,
// so the author of the messages must be the current user.
val author = Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!)
messageRecord = DatabaseComponent.get(this).mmsSmsDatabase().getMessageFor(timestamp, author) ?: run {
finish()
return
}
val threadId = messageRecord!!.threadId
val openGroup = storage.getOpenGroup(threadId)
val blindedKey = openGroup?.let { group ->
val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return@let null
val blindingEnabled = storage.getServerCapabilities(group.server).contains(OpenGroupApi.Capability.BLIND.name.lowercase())
if (blindingEnabled) {
SodiumUtilities.blindedKeyPair(group.publicKey, userEdKeyPair)?.publicKey?.asBytes
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
} else null
}
updateContent()
binding.resendButton.setOnClickListener {
ResendMessageUtilities.resend(this, messageRecord!!, blindedKey)
finish()
viewModel.timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L)
ComposeView(this)
.apply { setContent { MessageDetailsScreen() } }
.let(::setContentView)
lifecycleScope.launch {
viewModel.eventFlow.collect {
when (it) {
Event.Finish -> finish()
is Event.StartMediaPreview -> startActivity(
getPreviewIntent(this@MessageDetailActivity, it.args)
)
}
}
}
}
fun updateContent() {
val dateLocale = Locale.getDefault()
val dateFormatter: SimpleDateFormat = DateUtils.getDetailedDateFormatter(this, dateLocale)
binding.sentTime.text = dateFormatter.format(Date(messageRecord!!.dateSent))
val errorMessage = DatabaseComponent.get(this).lokiMessageDatabase().getErrorMessage(messageRecord!!.getId())
if (errorMessage != null) {
binding.errorMessage.text = errorMessage
binding.resendContainer.isVisible = true
binding.errorContainer.isVisible = true
} else {
binding.errorContainer.isVisible = false
binding.resendContainer.isVisible = false
}
if (messageRecord!!.expiresIn <= 0 || messageRecord!!.expireStarted <= 0) {
binding.expiresContainer.visibility = View.GONE
} else {
binding.expiresContainer.visibility = View.VISIBLE
val elapsed = System.currentTimeMillis() - messageRecord!!.expireStarted
val remaining = messageRecord!!.expiresIn - elapsed
val duration = ExpirationUtil.getExpirationDisplayValue(this, Math.max((remaining / 1000).toInt(), 1))
binding.expiresIn.text = duration
@Composable
private fun MessageDetailsScreen() {
val state by viewModel.stateFlow.collectAsState()
AppTheme {
MessageDetails(
state = state,
onReply = { setResultAndFinish(ON_REPLY) },
onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } },
onDelete = { setResultAndFinish(ON_DELETE) },
onClickImage = { viewModel.onClickImage(it) },
onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload,
)
}
}
}
private fun setResultAndFinish(code: Int) {
Bundle().apply { putLong(MESSAGE_TIMESTAMP, viewModel.timestamp) }
.let(Intent()::putExtras)
.let { setResult(code, it) }
finish()
}
}
@SuppressLint("ClickableViewAccessibility")
@Composable
fun MessageDetails(
state: MessageDetailsState,
onReply: () -> Unit = {},
onResend: (() -> Unit)? = null,
onDelete: () -> Unit = {},
onClickImage: (Int) -> Unit = {},
onAttachmentNeedsDownload: (Long, Long) -> Unit = { _, _ -> }
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
state.record?.let { message ->
AndroidView(
modifier = Modifier.padding(horizontal = 32.dp),
factory = {
ViewVisibleMessageContentBinding.inflate(LayoutInflater.from(it)).mainContainerConstraint.apply {
bind(
message,
thread = state.thread!!,
onAttachmentNeedsDownload = onAttachmentNeedsDownload,
suppressThumbnails = true
)
setOnTouchListener { _, event ->
if (event.actionMasked == ACTION_UP) onContentClick(event)
true
}
}
}
)
}
Carousel(state.imageAttachments) { onClickImage(it) }
state.nonImageAttachmentFileDetails?.let { FileDetails(it) }
CellMetadata(state)
CellButtons(
onReply,
onResend,
onDelete,
)
}
}
@Composable
fun CellMetadata(
state: MessageDetailsState,
) {
state.apply {
if (listOfNotNull(sent, received, error, senderInfo).isEmpty()) return
CellWithPaddingAndMargin {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
TitledText(sent)
TitledText(received)
TitledErrorText(error)
senderInfo?.let {
TitledView(state.fromTitle) {
Row {
sender?.let { Avatar(it) }
TitledMonospaceText(it)
}
}
}
}
}
}
}
@Composable
fun CellButtons(
onReply: () -> Unit = {},
onResend: (() -> Unit)? = null,
onDelete: () -> Unit = {},
) {
Cell {
Column {
ItemButton(
stringResource(R.string.reply),
R.drawable.ic_message_details__reply,
onClick = onReply
)
Divider()
onResend?.let {
ItemButton(
stringResource(R.string.resend),
R.drawable.ic_message_details__refresh,
onClick = it
)
Divider()
}
ItemButton(
stringResource(R.string.delete),
R.drawable.ic_message_details__trash,
colors = destructiveButtonColors(),
onClick = onDelete
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Carousel(attachments: List<Attachment>, onClick: (Int) -> Unit) {
if (attachments.isEmpty()) return
val pagerState = rememberPagerState { attachments.size }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row {
CarouselPrevButton(pagerState)
Box(modifier = Modifier.weight(1f)) {
CellCarousel(pagerState, attachments, onClick)
HorizontalPagerIndicator(pagerState)
ExpandButton(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(8.dp)
) { onClick(pagerState.currentPage) }
}
CarouselNextButton(pagerState)
}
attachments.getOrNull(pagerState.currentPage)?.fileDetails?.let { FileDetails(it) }
}
}
@OptIn(
ExperimentalFoundationApi::class,
ExperimentalGlideComposeApi::class
)
@Composable
private fun CellCarousel(
pagerState: PagerState,
attachments: List<Attachment>,
onClick: (Int) -> Unit
) {
CellNoMargin {
HorizontalPager(state = pagerState) { i ->
GlideImage(
contentScale = ContentScale.Crop,
modifier = Modifier
.aspectRatio(1f)
.clickable { onClick(i) },
model = attachments[i].uri,
contentDescription = attachments[i].fileName ?: stringResource(id = R.string.image)
)
}
}
}
@Composable
fun ExpandButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
Surface(
shape = CircleShape,
color = blackAlpha40,
modifier = modifier,
contentColor = Color.White,
) {
Icon(
painter = painterResource(id = R.drawable.ic_expand),
contentDescription = stringResource(id = R.string.expand),
modifier = Modifier.clickable { onClick() },
)
}
}
@Preview
@Composable
fun PreviewMessageDetails(
@PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int
) {
PreviewTheme(themeResId) {
MessageDetails(
state = MessageDetailsState(
nonImageAttachmentFileDetails = listOf(
TitledText(R.string.message_details_header__file_id, "Screen Shot 2023-07-06 at 11.35.50 am.png"),
TitledText(R.string.message_details_header__file_type, "image/png"),
TitledText(R.string.message_details_header__file_size, "195.6kB"),
TitledText(R.string.message_details_header__resolution, "342x312"),
),
sent = TitledText(R.string.message_details_header__sent, "6:12 AM Tue, 09/08/2022"),
received = TitledText(R.string.message_details_header__received, "6:12 AM Tue, 09/08/2022"),
error = TitledText(R.string.message_details_header__error, "Message failed to send"),
senderInfo = TitledText("Connor", "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54"),
)
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun FileDetails(fileDetails: List<TitledText>) {
if (fileDetails.isEmpty()) return
CellWithPaddingAndMargin(padding = 0.dp) {
FlowRow(
modifier = Modifier.padding(vertical = 24.dp, horizontal = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
fileDetails.forEach {
BoxWithConstraints {
TitledText(
it,
modifier = Modifier
.widthIn(min = maxWidth.div(2))
.padding(horizontal = 12.dp)
.width(IntrinsicSize.Max)
)
}
}
}
}
}
@Composable
fun TitledErrorText(titledText: TitledText?) {
TitledText(
titledText,
valueStyle = LocalTextStyle.current.copy(color = colorDestructive)
)
}
@Composable
fun TitledMonospaceText(titledText: TitledText?) {
TitledText(
titledText,
valueStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace)
)
}
@Composable
fun TitledText(
titledText: TitledText?,
modifier: Modifier = Modifier,
valueStyle: TextStyle = LocalTextStyle.current,
) {
titledText?.apply {
TitledView(title, modifier) {
Text(text, style = valueStyle, modifier = Modifier.fillMaxWidth())
}
}
}
@Composable
fun TitledView(title: GetString, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Title(title)
content()
}
}
@Composable
fun Title(title: GetString) {
Text(title.string(), fontWeight = FontWeight.Bold)
}

View File

@ -0,0 +1,173 @@
package org.thoughtcrime.securesms.conversation.v2
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import network.loki.messenger.R
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.Util
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.MediaPreviewArgs
import org.thoughtcrime.securesms.database.AttachmentDatabase
import org.thoughtcrime.securesms.database.LokiMessageDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.mms.ImageSlide
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.TitledText
import java.util.Date
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltViewModel
class MessageDetailsViewModel @Inject constructor(
private val attachmentDb: AttachmentDatabase,
private val lokiMessageDatabase: LokiMessageDatabase,
private val mmsSmsDatabase: MmsSmsDatabase,
private val threadDb: ThreadDatabase,
private val repository: ConversationRepository,
) : ViewModel() {
private var job: Job? = null
private val state = MutableStateFlow(MessageDetailsState())
val stateFlow = state.asStateFlow()
private val event = Channel<Event>()
val eventFlow = event.receiveAsFlow()
var timestamp: Long = 0L
set(value) {
job?.cancel()
field = value
val record = mmsSmsDatabase.getMessageForTimestamp(timestamp)
if (record == null) {
viewModelScope.launch { event.send(Event.Finish) }
return
}
val mmsRecord = record as? MmsMessageRecord
job = viewModelScope.launch {
repository.changes(record.threadId)
.filter { mmsSmsDatabase.getMessageForTimestamp(value) == null }
.collect { event.send(Event.Finish) }
}
state.value = record.run {
val slides = mmsRecord?.slideDeck?.slides ?: emptyList()
MessageDetailsState(
attachments = slides.map(::Attachment),
record = record,
sent = dateSent.let(::Date).toString().let { TitledText(R.string.message_details_header__sent, it) },
received = dateReceived.let(::Date).toString().let { TitledText(R.string.message_details_header__received, it) },
error = lokiMessageDatabase.getErrorMessage(id)?.let { TitledText(R.string.message_details_header__error, it) },
senderInfo = individualRecipient.run { name?.let { TitledText(it, address.serialize()) } },
sender = individualRecipient,
thread = threadDb.getRecipientForThreadId(threadId)!!,
)
}
}
private val Slide.details: List<TitledText>
get() = listOfNotNull(
fileName.orNull()?.let { TitledText(R.string.message_details_header__file_id, it) },
TitledText(R.string.message_details_header__file_type, asAttachment().contentType),
TitledText(R.string.message_details_header__file_size, Util.getPrettyFileSize(fileSize)),
takeIf { it is ImageSlide }
?.let(Slide::asAttachment)
?.run { "${width}x$height" }
?.let { TitledText(R.string.message_details_header__resolution, it) },
attachmentDb.duration(this)?.let { TitledText(R.string.message_details_header__duration, it) },
)
private fun AttachmentDatabase.duration(slide: Slide): String? =
slide.takeIf { it.hasAudio() }
?.run { asAttachment() as? DatabaseAttachment }
?.run { getAttachmentAudioExtras(attachmentId)?.durationMs }
?.takeIf { it > 0 }
?.let {
String.format(
"%01d:%02d",
TimeUnit.MILLISECONDS.toMinutes(it),
TimeUnit.MILLISECONDS.toSeconds(it) % 60
)
}
fun Attachment(slide: Slide): Attachment =
Attachment(slide.details, slide.fileName.orNull(), slide.uri, slide is ImageSlide)
fun onClickImage(index: Int) {
val state = state.value ?: return
val mmsRecord = state.mmsRecord ?: return
val slide = mmsRecord.slideDeck.slides[index] ?: return
// only open to downloaded images
if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) {
// Restart download here (on IO thread)
(slide.asAttachment() as? DatabaseAttachment)?.let { attachment ->
onAttachmentNeedsDownload(attachment.attachmentId.rowId, state.mmsRecord.getId())
}
}
if (slide.isInProgress) return
viewModelScope.launch {
MediaPreviewArgs(slide, state.mmsRecord, state.thread)
.let(Event::StartMediaPreview)
.let { event.send(it) }
}
}
fun onAttachmentNeedsDownload(attachmentId: Long, mmsId: Long) {
viewModelScope.launch(Dispatchers.IO) {
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId))
}
}
}
data class MessageDetailsState(
val attachments: List<Attachment> = emptyList(),
val imageAttachments: List<Attachment> = attachments.filter { it.hasImage },
val nonImageAttachmentFileDetails: List<TitledText>? = attachments.firstOrNull { !it.hasImage }?.fileDetails,
val record: MessageRecord? = null,
val mmsRecord: MmsMessageRecord? = record as? MmsMessageRecord,
val sent: TitledText? = null,
val received: TitledText? = null,
val error: TitledText? = null,
val senderInfo: TitledText? = null,
val sender: Recipient? = null,
val thread: Recipient? = null,
) {
val fromTitle = GetString(R.string.message_details_header__from)
}
data class Attachment(
val fileDetails: List<TitledText>,
val fileName: String?,
val uri: Uri?,
val hasImage: Boolean
)
sealed class Event {
object Finish: Event()
data class StartMediaPreview(val args: MediaPreviewArgs): Event()
}

View File

@ -60,8 +60,7 @@ class ModalUrlBottomSheet(private val url: String): BottomSheetDialogFragment(),
override fun onStart() {
super.onStart()
val window = dialog?.window ?: return
val isLightMode = UiModeUtilities.isDayUiMode(requireContext())
window.setDimAmount(if (isLightMode) 0.1f else 0.75f)
window.setDimAmount(0.6f)
}
override fun onClick(v: View?) {

View File

@ -38,14 +38,10 @@ public final class WindowUtil {
}
public static void setNavigationBarColor(@NonNull Window window, @ColorInt int color) {
if (Build.VERSION.SDK_INT < 21) return;
window.setNavigationBarColor(color);
}
public static void setLightStatusBarFromTheme(@NonNull Activity activity) {
if (Build.VERSION.SDK_INT < 23) return;
final boolean isLightStatusBar = ThemeUtil.getThemedBoolean(activity, android.R.attr.windowLightStatusBar);
if (isLightStatusBar) setLightStatusBar(activity.getWindow());
@ -53,20 +49,14 @@ public final class WindowUtil {
}
public static void clearLightStatusBar(@NonNull Window window) {
if (Build.VERSION.SDK_INT < 23) return;
clearSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
}
public static void setLightStatusBar(@NonNull Window window) {
if (Build.VERSION.SDK_INT < 23) return;
setSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
}
public static void setStatusBarColor(@NonNull Window window, @ColorInt int color) {
if (Build.VERSION.SDK_INT < 21) return;
window.setStatusBarColor(color);
}

View File

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

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

@ -28,11 +28,10 @@ class MentionCandidateView : LinearLayout {
private fun update() = with(binding) {
mentionCandidateNameTextView.text = mentionCandidate.displayName
profilePictureView.root.publicKey = mentionCandidate.publicKey
profilePictureView.root.displayName = mentionCandidate.displayName
profilePictureView.root.additionalPublicKey = null
profilePictureView.root.glide = glide!!
profilePictureView.root.update()
profilePictureView.publicKey = mentionCandidate.publicKey
profilePictureView.displayName = mentionCandidate.displayName
profilePictureView.additionalPublicKey = null
profilePictureView.update()
if (openGroupServer != null && openGroupRoom != null) {
val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", mentionCandidate.publicKey)
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE

View File

@ -1,41 +1,42 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.app.Dialog
import android.content.Context
import android.graphics.Typeface
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.StyleSpan
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import network.loki.messenger.R
import network.loki.messenger.databinding.DialogBlockedBinding
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
/** Shown upon sending a message to a user that's blocked. */
class BlockedDialog(private val recipient: Recipient) : BaseDialog() {
class BlockedDialog(private val recipient: Recipient, private val context: Context) : DialogFragment() {
override fun setContentView(builder: AlertDialog.Builder) {
val binding = DialogBlockedBinding.inflate(LayoutInflater.from(requireContext()))
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase()
val sessionID = recipient.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
val title = resources.getString(R.string.dialog_blocked_title, name)
binding.blockedTitleTextView.text = title
val explanation = resources.getString(R.string.dialog_blocked_explanation, name)
val spannable = SpannableStringBuilder(explanation)
val startIndex = explanation.indexOf(name)
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
binding.blockedExplanationTextView.text = spannable
binding.cancelButton.setOnClickListener { dismiss() }
binding.unblockButton.setOnClickListener { unblock() }
builder.setView(binding.root)
title(resources.getString(R.string.dialog_blocked_title, name))
text(spannable)
button(R.string.ConversationActivity_unblock) { unblock() }
cancelButton { dismiss() }
}
private fun unblock() {
DatabaseComponent.get(requireContext()).recipientDatabase().setBlocked(recipient, false)
MessagingModuleConfiguration.shared.storage.setBlocked(listOf(recipient), false)
dismiss()
}
}
}

View File

@ -1,19 +1,19 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.app.Dialog
import android.graphics.Typeface
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.StyleSpan
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.DialogDownloadBinding
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import javax.inject.Inject
@ -21,25 +21,24 @@ import javax.inject.Inject
/** Shown when receiving media from a contact for the first time, to confirm that
* they are to be trusted and files sent by them are to be downloaded. */
@AndroidEntryPoint
class DownloadDialog(private val recipient: Recipient) : BaseDialog() {
class DownloadDialog(private val recipient: Recipient) : DialogFragment() {
@Inject lateinit var contactDB: SessionContactDatabase
override fun setContentView(builder: AlertDialog.Builder) {
val binding = DialogDownloadBinding.inflate(LayoutInflater.from(requireContext()))
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val sessionID = recipient.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
val title = resources.getString(R.string.dialog_download_title, name)
binding.downloadTitleTextView.text = title
title(resources.getString(R.string.dialog_download_title, name))
val explanation = resources.getString(R.string.dialog_download_explanation, name)
val spannable = SpannableStringBuilder(explanation)
val startIndex = explanation.indexOf(name)
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
binding.downloadExplanationTextView.text = spannable
binding.cancelButton.setOnClickListener { dismiss() }
binding.downloadButton.setOnClickListener { trust() }
builder.setView(binding.root)
text(spannable)
button(R.string.dialog_download_button_title, R.string.AccessibilityId_download_media) { trust() }
cancelButton { dismiss() }
}
private fun trust() {
@ -50,4 +49,4 @@ class DownloadDialog(private val recipient: Recipient) : BaseDialog() {
JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY)
dismiss()
}
}
}

View File

@ -1,46 +1,42 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.app.Dialog
import android.graphics.Typeface
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.StyleSpan
import android.view.LayoutInflater
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.DialogFragment
import network.loki.messenger.R
import network.loki.messenger.databinding.DialogJoinOpenGroupBinding
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.utilities.OpenGroupUrlParser
import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
/** Shown upon tapping an open group invitation. */
class JoinOpenGroupDialog(private val name: String, private val url: String) : BaseDialog() {
class JoinOpenGroupDialog(private val name: String, private val url: String) : DialogFragment() {
override fun setContentView(builder: AlertDialog.Builder) {
val binding = DialogJoinOpenGroupBinding.inflate(LayoutInflater.from(requireContext()))
val title = resources.getString(R.string.dialog_join_open_group_title, name)
binding.joinOpenGroupTitleTextView.text = title
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
title(resources.getString(R.string.dialog_join_open_group_title, name))
val explanation = resources.getString(R.string.dialog_join_open_group_explanation, name)
val spannable = SpannableStringBuilder(explanation)
val startIndex = explanation.indexOf(name)
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
binding.joinOpenGroupExplanationTextView.text = spannable
binding.cancelButton.setOnClickListener { dismiss() }
binding.joinButton.setOnClickListener { join() }
builder.setView(binding.root)
text(spannable)
cancelButton { dismiss() }
button(R.string.open_group_invitation_view__join_accessibility_description) { join() }
}
private fun join() {
val openGroup = OpenGroupUrlParser.parseUrl(url)
val activity = requireContext() as AppCompatActivity
val activity = requireActivity()
ThreadUtils.queue {
try {
OpenGroupManager.add(openGroup.server, openGroup.room, openGroup.serverPublicKey, activity)
MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server)
openGroup.apply { OpenGroupManager.add(server, room, serverPublicKey, activity) }
MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server, openGroup.room)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(activity)
} catch (e: Exception) {
Toast.makeText(activity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show()
@ -48,4 +44,4 @@ class JoinOpenGroupDialog(private val name: String, private val url: String) : B
}
dismiss()
}
}
}

View File

@ -1,20 +1,21 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import network.loki.messenger.databinding.DialogLinkPreviewBinding
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
import org.thoughtcrime.securesms.createSessionDialog
/** Shown the first time the user inputs a URL that could generate a link preview, to
* let them know that Session offers the ability to send and receive link previews. */
class LinkPreviewDialog(private val onEnabled: () -> Unit) : BaseDialog() {
class LinkPreviewDialog(private val onEnabled: () -> Unit) : DialogFragment() {
override fun setContentView(builder: AlertDialog.Builder) {
val binding = DialogLinkPreviewBinding.inflate(LayoutInflater.from(requireContext()))
binding.cancelButton.setOnClickListener { dismiss() }
binding.enableLinkPreviewsButton.setOnClickListener { enable() }
builder.setView(binding.root)
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
title(R.string.dialog_link_preview_title)
text(R.string.dialog_link_preview_explanation)
button(R.string.dialog_link_preview_enable_button_title) { enable() }
cancelButton { dismiss() }
}
private fun enable() {
@ -22,4 +23,4 @@ class LinkPreviewDialog(private val onEnabled: () -> Unit) : BaseDialog() {
dismiss()
onEnabled()
}
}
}

View File

@ -1,22 +1,23 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import network.loki.messenger.databinding.DialogSendSeedBinding
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import network.loki.messenger.R
import org.thoughtcrime.securesms.createSessionDialog
/** Shown if the user is about to send their recovery phrase to someone. */
class SendSeedDialog(private val proceed: (() -> Unit)? = null) : BaseDialog() {
class SendSeedDialog(private val proceed: (() -> Unit)? = null) : DialogFragment() {
override fun setContentView(builder: AlertDialog.Builder) {
val binding = DialogSendSeedBinding.inflate(LayoutInflater.from(requireContext()))
binding.cancelButton.setOnClickListener { dismiss() }
binding.sendSeedButton.setOnClickListener { send() }
builder.setView(binding.root)
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
title(R.string.dialog_send_seed_title)
text(R.string.dialog_send_seed_explanation)
button(R.string.dialog_send_seed_send_button_title) { send() }
cancelButton()
}
private fun send() {
proceed?.invoke()
dismiss()
}
}
}

View File

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

View File

@ -28,11 +28,10 @@ class MentionCandidateView : RelativeLayout {
private fun update() = with(binding) {
mentionCandidateNameTextView.text = candidate.displayName
profilePictureView.root.publicKey = candidate.publicKey
profilePictureView.root.displayName = candidate.displayName
profilePictureView.root.additionalPublicKey = null
profilePictureView.root.glide = glide!!
profilePictureView.root.update()
profilePictureView.publicKey = candidate.publicKey
profilePictureView.displayName = candidate.displayName
profilePictureView.additionalPublicKey = null
profilePictureView.update()
if (openGroupServer != null && openGroupRoom != null) {
val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", candidate.publicKey)
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE

View File

@ -7,6 +7,7 @@ import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ListView
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import org.session.libsession.messaging.mentions.Mention
import org.thoughtcrime.securesms.database.LokiThreadDatabase
import org.thoughtcrime.securesms.mms.GlideRequests
@ -41,7 +42,9 @@ class MentionCandidatesView(context: Context, attrs: AttributeSet?, defStyleAttr
override fun getItem(position: Int): Mention { return candidates[position] }
override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View {
val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context)
val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context).apply {
contentDescription = context.getString(R.string.AccessibilityId_contact)
}
val mentionCandidate = getItem(position)
cell.glide = glide
cell.candidate = mentionCandidate

View File

@ -65,11 +65,13 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
menu.findItem(R.id.menu_context_copy).isVisible = !containsControlMessage && hasText
// Copy Session ID
menu.findItem(R.id.menu_context_copy_public_key).isVisible =
(thread.isGroupRecipient && !thread.isOpenGroupRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey)
(thread.isGroupRecipient && !thread.isCommunityRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey)
// Message detail
menu.findItem(R.id.menu_message_details).isVisible = (selectedItems.size == 1 && firstMessage.isOutgoing)
menu.findItem(R.id.menu_message_details).isVisible = selectedItems.size == 1
// Resend
menu.findItem(R.id.menu_context_resend).isVisible = (selectedItems.size == 1 && firstMessage.isFailed)
// Resync
menu.findItem(R.id.menu_context_resync).isVisible = (selectedItems.size == 1 && firstMessage.isSyncFailed)
// Save media
menu.findItem(R.id.menu_context_save_attachment).isVisible = (selectedItems.size == 1
&& firstMessage.isMms && (firstMessage as MediaMmsMessageRecord).containsMediaSlide())
@ -90,6 +92,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
R.id.menu_context_ban_and_delete_all -> delegate?.banAndDeleteAll(selectedItems)
R.id.menu_context_copy -> delegate?.copyMessages(selectedItems)
R.id.menu_context_copy_public_key -> delegate?.copySessionID(selectedItems)
R.id.menu_context_resync -> delegate?.resyncMessage(selectedItems)
R.id.menu_context_resend -> delegate?.resendMessage(selectedItems)
R.id.menu_message_details -> delegate?.showMessageDetail(selectedItems)
R.id.menu_context_save_attachment -> delegate?.saveAttachment(selectedItems)
@ -113,6 +116,7 @@ interface ConversationActionModeCallbackDelegate {
fun banAndDeleteAll(messages: Set<MessageRecord>)
fun copyMessages(messages: Set<MessageRecord>)
fun copySessionID(messages: Set<MessageRecord>)
fun resyncMessage(messages: Set<MessageRecord>)
fun resendMessage(messages: Set<MessageRecord>)
fun showMessageDetail(messages: Set<MessageRecord>)
fun saveAttachment(messages: Set<MessageRecord>)

View File

@ -4,17 +4,11 @@ import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.os.AsyncTask
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.ColorInt
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ContextThemeWrapper
import androidx.appcompat.widget.SearchView
@ -25,15 +19,12 @@ import androidx.core.graphics.drawable.IconCompat
import network.loki.messenger.R
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.leave
import org.session.libsession.utilities.ExpirationUtil
import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.MediaOverviewActivity
import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.ShortcutLauncherActivity
import org.thoughtcrime.securesms.calls.WebRtcCallActivity
import org.thoughtcrime.securesms.contacts.SelectContactsActivity
@ -44,6 +35,8 @@ import org.thoughtcrime.securesms.groups.EditClosedGroupActivity
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.service.WebRtcCallService
import org.thoughtcrime.securesms.showMuteDialog
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.BitmapUtil
import java.io.IOException
@ -53,30 +46,16 @@ object ConversationMenuHelper {
menu: Menu,
inflater: MenuInflater,
thread: Recipient,
threadId: Long,
context: Context,
onOptionsItemSelected: (MenuItem) -> Unit
context: Context
) {
// Prepare
menu.clear()
val isOpenGroup = thread.isOpenGroupRecipient
val isOpenGroup = thread.isCommunityRecipient
// Base menu (options that should always be present)
inflater.inflate(R.menu.menu_conversation, menu)
// Expiring messages
if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient)) {
if (thread.expireMessages > 0) {
inflater.inflate(R.menu.menu_conversation_expiration_on, menu)
val item = menu.findItem(R.id.menu_expiring_messages)
val actionView = item.actionView
val iconView = actionView.findViewById<ImageView>(R.id.menu_badge_icon)
val badgeView = actionView.findViewById<TextView>(R.id.expiration_badge)
@ColorInt val color = context.getColorFromAttr(android.R.attr.textColorPrimary)
iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY)
badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages)
actionView.setOnClickListener { onOptionsItemSelected(item) }
} else {
inflater.inflate(R.menu.menu_conversation_expiration_off, menu)
}
if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) {
inflater.inflate(R.menu.menu_conversation_expiration, menu)
}
// One-on-one chat menu allows copying the session id
if (thread.isContactRecipient) {
@ -86,7 +65,7 @@ object ConversationMenuHelper {
if (thread.isContactRecipient) {
if (thread.isBlocked) {
inflater.inflate(R.menu.menu_conversation_unblock, menu)
} else {
} else if (!thread.isLocalNumber) {
inflater.inflate(R.menu.menu_conversation_block, menu)
}
}
@ -109,7 +88,7 @@ object ConversationMenuHelper {
inflater.inflate(R.menu.menu_conversation_notification_settings, menu)
}
if (!thread.isGroupRecipient && thread.hasApprovedMe()) {
if (thread.showCallMenu()) {
inflater.inflate(R.menu.menu_conversation_call, menu)
}
@ -152,8 +131,7 @@ object ConversationMenuHelper {
R.id.menu_view_all_media -> { showAllMedia(context, thread) }
R.id.menu_search -> { search(context) }
R.id.menu_add_shortcut -> { addShortcut(context, thread) }
R.id.menu_expiring_messages -> { showExpiringMessagesDialog(context, thread) }
R.id.menu_expiring_messages_off -> { showExpiringMessagesDialog(context, thread) }
R.id.menu_expiring_messages -> { showDisappearingMessages(context, thread) }
R.id.menu_unblock -> { unblock(context, thread) }
R.id.menu_block -> { block(context, thread, deleteThread = false) }
R.id.menu_block_delete -> { blockAndDelete(context, thread) }
@ -185,26 +163,23 @@ object ConversationMenuHelper {
private fun call(context: Context, thread: Recipient) {
if (!TextSecurePreferences.isCallNotificationsEnabled(context)) {
AlertDialog.Builder(context)
.setTitle(R.string.ConversationActivity_call_title)
.setMessage(R.string.ConversationActivity_call_prompt)
.setPositiveButton(R.string.activity_settings_title) { _, _ ->
val intent = Intent(context, PrivacySettingsActivity::class.java)
context.startActivity(intent)
context.showSessionDialog {
title(R.string.ConversationActivity_call_title)
text(R.string.ConversationActivity_call_prompt)
button(R.string.activity_settings_title, R.string.AccessibilityId_settings) {
Intent(context, PrivacySettingsActivity::class.java).let(context::startActivity)
}
.setNeutralButton(R.string.cancel) { d, _ ->
d.dismiss()
}.show()
cancelButton()
}
return
}
val service = WebRtcCallService.createCall(context, thread)
context.startService(service)
WebRtcCallService.createCall(context, thread)
.let(context::startService)
val activity = Intent(context, WebRtcCallActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(activity)
Intent(context, WebRtcCallActivity::class.java)
.apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK }
.let(context::startActivity)
}
@ -212,6 +187,7 @@ object ConversationMenuHelper {
private fun addShortcut(context: Context, thread: Recipient) {
object : AsyncTask<Void?, Void?, IconCompat?>() {
@Deprecated("Deprecated in Java")
override fun doInBackground(vararg params: Void?): IconCompat? {
var icon: IconCompat? = null
val contactPhoto = thread.contactPhoto
@ -230,6 +206,7 @@ object ConversationMenuHelper {
return icon
}
@Deprecated("Deprecated in Java")
override fun onPostExecute(icon: IconCompat?) {
val name = Optional.fromNullable<String>(thread.name)
.or(Optional.fromNullable<String>(thread.profileName))
@ -246,9 +223,9 @@ object ConversationMenuHelper {
}.execute()
}
private fun showExpiringMessagesDialog(context: Context, thread: Recipient) {
private fun showDisappearingMessages(context: Context, thread: Recipient) {
val listener = context as? ConversationMenuListener ?: return
listener.showExpiringMessagesDialog(thread)
listener.showDisappearingMessages(thread)
}
private fun unblock(context: Context, thread: Recipient) {
@ -276,7 +253,7 @@ object ConversationMenuHelper {
}
private fun copyOpenGroupUrl(context: Context, thread: Recipient) {
if (!thread.isOpenGroupRecipient) { return }
if (!thread.isCommunityRecipient) { return }
val listener = context as? ConversationMenuListener ?: return
listener.copyOpenGroupUrl(thread)
}
@ -291,9 +268,7 @@ object ConversationMenuHelper {
private fun leaveClosedGroup(context: Context, thread: Recipient) {
if (!thread.isClosedGroupRecipient) { return }
val builder = AlertDialog.Builder(context)
builder.setTitle(context.resources.getString(R.string.ConversationActivity_leave_group))
builder.setCancelable(true)
val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull()
val admins = group.admins
val sessionID = TextSecurePreferences.getLocalNumber(context)
@ -303,33 +278,29 @@ object ConversationMenuHelper {
} else {
context.resources.getString(R.string.ConversationActivity_are_you_sure_you_want_to_leave_this_group)
}
builder.setMessage(message)
builder.setPositiveButton(R.string.yes) { _, _ ->
var groupPublicKey: String?
var isClosedGroup: Boolean
try {
groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString()
isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey)
} catch (e: IOException) {
groupPublicKey = null
isClosedGroup = false
}
try {
if (isClosedGroup) {
MessageSender.leave(groupPublicKey!!, true)
} else {
Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show()
fun onLeaveFailed() = Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show()
context.showSessionDialog {
title(R.string.ConversationActivity_leave_group)
text(message)
button(R.string.yes) {
try {
val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString()
val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey)
if (isClosedGroup) MessageSender.leave(groupPublicKey, notifyUser = false)
else onLeaveFailed()
} catch (e: Exception) {
onLeaveFailed()
}
} catch (e: Exception) {
Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show()
}
button(R.string.no)
}
builder.setNegativeButton(R.string.no, null)
builder.show()
}
private fun inviteContacts(context: Context, thread: Recipient) {
if (!thread.isOpenGroupRecipient) { return }
if (!thread.isCommunityRecipient) { return }
val intent = Intent(context, SelectContactsActivity::class.java)
val activity = context as AppCompatActivity
activity.startActivityForResult(intent, ConversationActivityV2.INVITE_CONTACTS)
@ -340,7 +311,7 @@ object ConversationMenuHelper {
}
private fun mute(context: Context, thread: Recipient) {
MuteDialog.show(ContextThemeWrapper(context, context.theme)) { until: Long ->
showMuteDialog(ContextThemeWrapper(context, context.theme)) { until ->
DatabaseComponent.get(context).recipientDatabase().setMuted(thread, until)
}
}
@ -356,7 +327,7 @@ object ConversationMenuHelper {
fun unblock()
fun copySessionID(sessionId: String)
fun copyOpenGroupUrl(thread: Recipient)
fun showExpiringMessagesDialog(thread: Recipient)
fun showDisappearingMessages(thread: Recipient)
}
}

View File

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

View File

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

View File

@ -3,25 +3,21 @@ package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context
import android.graphics.Color
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.text.Spannable
import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan
import android.text.style.URLSpan
import android.text.util.Linkify
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.widget.LinearLayout
import androidx.annotation.ColorInt
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.BlendModeColorFilterCompat
import androidx.core.graphics.BlendModeCompat
import androidx.core.graphics.ColorUtils
import androidx.core.text.getSpans
import androidx.core.text.toSpannable
import androidx.core.view.children
import androidx.core.view.isVisible
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
@ -29,7 +25,9 @@ import okhttp3.HttpUrl
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.modifyLayoutParams
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet
@ -39,15 +37,16 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getInt
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.SmsMessageRecord
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.GlowViewUtilities
import org.thoughtcrime.securesms.util.SearchUtil
import org.thoughtcrime.securesms.util.getAccentColor
import java.util.*
import java.util.Locale
import kotlin.math.roundToInt
class VisibleMessageContentView : ConstraintLayout {
private val binding: ViewVisibleMessageContentBinding by lazy { ViewVisibleMessageContentBinding.bind(this) }
var onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf()
var onContentDoubleTap: (() -> Unit)? = null
var delegate: VisibleMessageViewDelegate? = null
var indexInAdapter: Int = -1
@ -61,21 +60,20 @@ class VisibleMessageContentView : ConstraintLayout {
// region Updating
fun bind(
message: MessageRecord,
isStartOfMessageCluster: Boolean,
isEndOfMessageCluster: Boolean,
glide: GlideRequests,
isStartOfMessageCluster: Boolean = true,
isEndOfMessageCluster: Boolean = true,
glide: GlideRequests = GlideApp.with(this),
thread: Recipient,
searchQuery: String?,
contactIsTrusted: Boolean,
onAttachmentNeedsDownload: (Long, Long) -> Unit
searchQuery: String? = null,
contactIsTrusted: Boolean = true,
onAttachmentNeedsDownload: (Long, Long) -> Unit,
suppressThumbnails: Boolean = false
) {
// Background
val background = getBackground(message.isOutgoing)
val color = if (message.isOutgoing) context.getAccentColor()
else context.getColorFromAttr(R.attr.message_received_background_color)
val filter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN)
background.colorFilter = filter
binding.contentParent.background = background
binding.contentParent.mainColor = color
binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius)
val onlyBodyMessage = message is SmsMessageRecord
val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null
@ -132,7 +130,6 @@ class VisibleMessageContentView : ConstraintLayout {
delegate?.scrollToMessageIfPossible(quote.id)
}
}
val hasMedia = message.slideDeck.asAttachments().isNotEmpty()
}
if (message is MmsMessageRecord) {
@ -189,7 +186,7 @@ class VisibleMessageContentView : ConstraintLayout {
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
}
}
message is MmsMessageRecord && message.slideDeck.asAttachments().isNotEmpty() -> {
message is MmsMessageRecord && !suppressThumbnails && message.slideDeck.asAttachments().isNotEmpty() -> {
/*
* Images / Video attachment
*/
@ -202,9 +199,9 @@ class VisibleMessageContentView : ConstraintLayout {
isStart = isStartOfMessageCluster,
isEnd = isEndOfMessageCluster
)
val layoutParams = binding.albumThumbnailView.root.layoutParams as ConstraintLayout.LayoutParams
layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
binding.albumThumbnailView.root.layoutParams = layoutParams
binding.albumThumbnailView.root.modifyLayoutParams<ConstraintLayout.LayoutParams> {
horizontalBias = if (message.isOutgoing) 1f else 0f
}
onContentClick.add { event ->
binding.albumThumbnailView.root.calculateHitObject(event, message, thread, onAttachmentNeedsDownload)
}
@ -223,6 +220,7 @@ class VisibleMessageContentView : ConstraintLayout {
}
binding.bodyTextView.isVisible = message.body.isNotEmpty() && !hideBody
binding.contentParent.apply { isVisible = children.any { it.isVisible } }
if (message.body.isNotEmpty() && !hideBody) {
val color = getTextColor(context, message)
@ -236,19 +234,20 @@ class VisibleMessageContentView : ConstraintLayout {
}
}
}
val layoutParams = binding.contentParent.layoutParams as ConstraintLayout.LayoutParams
layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
binding.contentParent.layoutParams = layoutParams
binding.contentParent.modifyLayoutParams<ConstraintLayout.LayoutParams> {
horizontalBias = if (message.isOutgoing) 1f else 0f
}
}
private val onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf()
fun onContentClick(event: MotionEvent) {
onContentClick.forEach { clickHandler -> clickHandler.invoke(event) }
}
private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean =
listOf<View>(albumThumbnailView.root, linkPreviewView.root, voiceMessageView.root, quoteView.root).none { it.isVisible }
private fun getBackground(isOutgoing: Boolean): Drawable {
val backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_alone else R.drawable.message_bubble_background_received_alone
return ResourcesCompat.getDrawable(resources, backgroundID, context.theme)!!
}
fun recycle() {
arrayOf(
binding.deletedMessageView.root,
@ -266,6 +265,15 @@ class VisibleMessageContentView : ConstraintLayout {
fun playVoiceMessage() {
binding.voiceMessageView.root.togglePlayback()
}
fun playHighlight() {
// Show the highlight colour immediately then slowly fade out
val targetColor = if (ThemeUtil.isDarkTheme(context)) context.getAccentColor() else resources.getColor(R.color.black, context.theme)
val clearTargetColor = ColorUtils.setAlphaComponent(targetColor, 0)
binding.contentParent.numShadowRenders = if (ThemeUtil.isDarkTheme(context)) 3 else 1
binding.contentParent.sessionShadowColor = targetColor
GlowViewUtilities.animateShadowColorChange(binding.contentParent, targetColor, clearTargetColor, 1600)
}
// endregion
// region Convenience
@ -299,16 +307,9 @@ class VisibleMessageContentView : ConstraintLayout {
}
@ColorInt
fun getTextColor(context: Context, message: MessageRecord): Int {
val colorAttribute = if (message.isOutgoing) {
// sent
R.attr.message_sent_text_color
} else {
// received
R.attr.message_received_text_color
}
return context.getColorFromAttr(colorAttribute)
}
fun getTextColor(context: Context, message: MessageRecord): Int = context.getColorFromAttr(
if (message.isOutgoing) R.attr.message_sent_text_color else R.attr.message_received_text_color
)
}
// endregion
}

View File

@ -1,23 +1,28 @@
package org.thoughtcrime.securesms.conversation.v2.messages
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.ColorDrawable
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.core.view.isInvisible
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.marginBottom
import dagger.hilt.android.AndroidEntryPoint
@ -27,11 +32,11 @@ import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.contacts.Contact.ContactContext
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.ViewUtil
import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.modifyLayoutParams
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.LokiAPIDatabase
import org.thoughtcrime.securesms.database.LokiThreadDatabase
@ -42,6 +47,7 @@ import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.home.UserDetailsBottomSheet
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.disableClipping
@ -55,9 +61,10 @@ import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sqrt
private const val TAG = "VisibleMessageView"
@AndroidEntryPoint
class VisibleMessageView : LinearLayout {
@Inject lateinit var threadDb: ThreadDatabase
@Inject lateinit var lokiThreadDb: LokiThreadDatabase
@Inject lateinit var lokiApiDb: LokiAPIDatabase
@ -66,7 +73,6 @@ class VisibleMessageView : LinearLayout {
@Inject lateinit var mmsDb: MmsDatabase
private val binding by lazy { ViewVisibleMessageBinding.bind(this) }
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate()
private val swipeToReplyIconRect = Rect()
private var dx = 0.0f
@ -107,7 +113,10 @@ class VisibleMessageView : LinearLayout {
private fun initialize() {
isHapticFeedbackEnabled = true
setWillNotDraw(false)
binding.root.disableClipping()
binding.mainContainer.disableClipping()
binding.messageInnerContainer.disableClipping()
binding.messageInnerLayout.disableClipping()
binding.messageContentView.root.disableClipping()
}
// endregion
@ -115,24 +124,25 @@ class VisibleMessageView : LinearLayout {
// region Updating
fun bind(
message: MessageRecord,
previous: MessageRecord?,
next: MessageRecord?,
glide: GlideRequests,
searchQuery: String?,
contact: Contact?,
previous: MessageRecord? = null,
next: MessageRecord? = null,
glide: GlideRequests = GlideApp.with(this),
searchQuery: String? = null,
contact: Contact? = null,
senderSessionID: String,
delegate: VisibleMessageViewDelegate?,
onAttachmentNeedsDownload: (Long, Long) -> Unit
lastSeen: Long,
delegate: VisibleMessageViewDelegate? = null,
onAttachmentNeedsDownload: (Long, Long) -> Unit,
lastSentMessageId: Long
) {
val threadID = message.threadId
val thread = threadDb.getRecipientForThreadId(threadID) ?: return
val isGroupThread = thread.isGroupRecipient
val isStartOfMessageCluster = isStartOfMessageCluster(message, previous, isGroupThread)
val isEndOfMessageCluster = isEndOfMessageCluster(message, next, isGroupThread)
// Show profile picture and sender name if this is a group thread AND
// the message is incoming
// Show profile picture and sender name if this is a group thread AND the message is incoming
binding.moderatorIconImageView.isVisible = false
binding.profilePictureView.root.visibility = when {
binding.profilePictureView.visibility = when {
thread.isGroupRecipient && !message.isOutgoing && isEndOfMessageCluster -> View.VISIBLE
thread.isGroupRecipient -> View.INVISIBLE
else -> View.GONE
@ -141,25 +151,25 @@ class VisibleMessageView : LinearLayout {
val bottomMargin = if (isEndOfMessageCluster) resources.getDimensionPixelSize(R.dimen.small_spacing)
else ViewUtil.dpToPx(context,2)
if (binding.profilePictureView.root.visibility == View.GONE) {
if (binding.profilePictureView.visibility == View.GONE) {
val expirationParams = binding.messageInnerContainer.layoutParams as MarginLayoutParams
expirationParams.bottomMargin = bottomMargin
binding.messageInnerContainer.layoutParams = expirationParams
} else {
val avatarLayoutParams = binding.profilePictureView.root.layoutParams as MarginLayoutParams
val avatarLayoutParams = binding.profilePictureView.layoutParams as MarginLayoutParams
avatarLayoutParams.bottomMargin = bottomMargin
binding.profilePictureView.root.layoutParams = avatarLayoutParams
binding.profilePictureView.layoutParams = avatarLayoutParams
}
if (isGroupThread && !message.isOutgoing) {
if (isEndOfMessageCluster) {
binding.profilePictureView.root.publicKey = senderSessionID
binding.profilePictureView.root.glide = glide
binding.profilePictureView.root.update(message.individualRecipient)
binding.profilePictureView.root.setOnClickListener {
if (thread.isOpenGroupRecipient) {
binding.profilePictureView.publicKey = senderSessionID
binding.profilePictureView.update(message.individualRecipient)
binding.profilePictureView.setOnClickListener {
if (thread.isCommunityRecipient) {
val openGroup = lokiThreadDb.getOpenGroupChat(threadID)
if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) {
// TODO: support v2 soon
val intent = Intent(context, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.FROM_GROUP_THREAD_ID, threadID)
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(senderSessionID))
@ -169,11 +179,11 @@ class VisibleMessageView : LinearLayout {
maybeShowUserDetails(senderSessionID, threadID)
}
}
if (thread.isOpenGroupRecipient) {
if (thread.isCommunityRecipient) {
val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return
var standardPublicKey = ""
var blindedPublicKey: String? = null
if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED) {
if (IdPrefix.fromValue(senderSessionID)?.isBlinded() == true) {
blindedPublicKey = senderSessionID
} else {
standardPublicKey = senderSessionID
@ -185,49 +195,20 @@ class VisibleMessageView : LinearLayout {
}
binding.senderNameTextView.isVisible = !message.isOutgoing && (isStartOfMessageCluster && (isGroupThread || snIsSelected))
val contactContext =
if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR
if (thread.isCommunityRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR
binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID
// Unread marker
binding.unreadMarkerContainer.isVisible = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing
// Date break
val showDateBreak = isStartOfMessageCluster || snIsSelected
binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null
binding.dateBreakTextView.isVisible = showDateBreak
// Message status indicator
if (message.isOutgoing) {
val (iconID, iconColor, textId) = getMessageStatusImage(message)
if (textId != null) {
binding.messageStatusTextView.setText(textId)
showStatusMessage(message)
if (iconColor != null) {
binding.messageStatusTextView.setTextColor(iconColor)
}
}
if (iconID != null) {
val drawable = ContextCompat.getDrawable(context, iconID)?.mutate()
if (iconColor != null) {
drawable?.setTint(iconColor)
}
binding.messageStatusImageView.setImageDrawable(drawable)
}
val lastMessageID = mmsSmsDb.getLastMessageID(message.threadId)
binding.messageStatusTextView.isVisible = (
textId != null && (
!message.isSent ||
message.id == lastMessageID
)
)
binding.messageStatusImageView.isVisible = (
iconID != null && (
!message.isSent ||
message.id == lastMessageID
)
)
} else {
binding.messageStatusTextView.isVisible = false
binding.messageStatusImageView.isVisible = false
}
// Expiration timer
updateExpirationTimer(message)
// Emoji Reactions
val emojiLayoutParams = binding.emojiReactionsView.root.layoutParams as ConstraintLayout.LayoutParams
emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
@ -262,95 +243,115 @@ class VisibleMessageView : LinearLayout {
onDoubleTap = { binding.messageContentView.root.onContentDoubleTap?.invoke() }
}
private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean {
return if (isGroupThread) {
previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp)
|| current.recipient.address != previous.recipient.address
private fun showStatusMessage(message: MessageRecord) {
val scheduledToDisappear = message.expiresIn > 0
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
}
binding.expirationTimerView.isGone = true
if (message.isOutgoing || scheduledToDisappear) {
val (iconID, iconColor, textId) = getMessageStatusImage(message)
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)
// Always show the delivery status of the last sent message
val thisUsersSessionId = TextSecurePreferences.getLocalNumber(context)
val lastSentMessageId = mmsSmsDb.getLastSentMessageFromSender(message.threadId, thisUsersSessionId)
val isLastSentMessage = lastSentMessageId == message.id
binding.messageStatusTextView.isVisible = textId != null && (isLastSentMessage || scheduledToDisappear)
val showTimer = scheduledToDisappear && !message.isPending
binding.messageStatusImageView.isVisible = iconID != null && !showTimer && (!message.isSent || isLastSentMessage)
binding.messageStatusImageView.bringToFront()
binding.expirationTimerView.bringToFront()
binding.expirationTimerView.isVisible = showTimer
if (showTimer) updateExpirationTimer(message)
} else {
previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp)
|| current.isOutgoing != previous.isOutgoing
binding.messageStatusTextView.isVisible = false
binding.messageStatusImageView.isVisible = false
}
}
private fun isEndOfMessageCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean {
return if (isGroupThread) {
next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp)
|| current.recipient.address != next.recipient.address
private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean =
previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp) || if (isGroupThread) {
current.recipient.address != previous.recipient.address
} else {
next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp)
|| current.isOutgoing != next.isOutgoing
current.isOutgoing != previous.isOutgoing
}
}
private fun getMessageStatusImage(message: MessageRecord): Triple<Int?,Int?,Int?> {
return when {
!message.isOutgoing -> Triple(null, null, null)
message.isFailed ->
Triple(R.drawable.ic_delivery_status_failed, resources.getColor(R.color.destructive, context.theme), R.string.delivery_status_failed)
message.isPending ->
Triple(R.drawable.ic_delivery_status_sending, context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending)
message.isRead ->
Triple(R.drawable.ic_delivery_status_read, context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read)
else ->
Triple(R.drawable.ic_delivery_status_sent, context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sent)
private fun isEndOfMessageCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean =
next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp) || if (isGroupThread) {
current.recipient.address != next.recipient.address
} else {
current.isOutgoing != next.isOutgoing
}
data class MessageStatusInfo(@DrawableRes val iconId: Int?,
@ColorInt val iconTint: Int?,
@StringRes val messageText: Int?)
private fun getMessageStatusImage(message: MessageRecord): MessageStatusInfo = when {
message.isFailed ->
MessageStatusInfo(
R.drawable.ic_delivery_status_failed,
resources.getColor(R.color.destructive, context.theme),
R.string.delivery_status_failed
)
message.isSyncFailed ->
MessageStatusInfo(
R.drawable.ic_delivery_status_failed,
context.getColor(R.color.accent_orange),
R.string.delivery_status_sync_failed
)
message.isPending ->
MessageStatusInfo(
R.drawable.ic_delivery_status_sending,
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending
)
message.isResyncing ->
MessageStatusInfo(
R.drawable.ic_delivery_status_sending,
context.getColor(R.color.accent_orange), R.string.delivery_status_syncing
)
message.isRead || !message.isOutgoing ->
MessageStatusInfo(
R.drawable.ic_delivery_status_read,
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read
)
else ->
MessageStatusInfo(
R.drawable.ic_delivery_status_sent,
context.getColorFromAttr(R.attr.message_status_color),
R.string.delivery_status_sent
)
}
private fun updateExpirationTimer(message: MessageRecord) {
val container = binding.messageInnerContainer
val content = binding.messageContentView.root
val expiration = binding.expirationTimerView
val spacing = binding.messageContentSpacing
container.removeAllViewsInLayout()
container.addView(if (message.isOutgoing) expiration else content)
container.addView(if (message.isOutgoing) content else expiration)
container.addView(spacing, if (message.isOutgoing) 0 else 2)
val containerParams = container.layoutParams as ConstraintLayout.LayoutParams
containerParams.horizontalBias = if (message.isOutgoing) 1f else 0f
container.layoutParams = containerParams
if (message.expiresIn > 0 && !message.isPending) {
binding.expirationTimerView.setColorFilter(context.getColorFromAttr(android.R.attr.textColorPrimary))
binding.expirationTimerView.isInvisible = false
binding.expirationTimerView.setPercentComplete(0.0f)
if (message.expireStarted > 0) {
binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
binding.expirationTimerView.startAnimation()
if (message.expireStarted + message.expiresIn <= System.currentTimeMillis()) {
ApplicationContext.getInstance(context).expiringMessageManager.checkSchedule()
}
} else if (!message.isMediaPending) {
binding.expirationTimerView.setPercentComplete(0.0f)
binding.expirationTimerView.stopAnimation()
ThreadUtils.queue {
val expirationManager = ApplicationContext.getInstance(context).expiringMessageManager
val id = message.getId()
val mms = message.isMms
if (mms) mmsDb.markExpireStarted(id) else smsDb.markExpireStarted(id)
expirationManager.scheduleDeletion(id, mms, message.expiresIn)
}
} else {
binding.expirationTimerView.stopAnimation()
binding.expirationTimerView.setPercentComplete(0.0f)
}
} else {
binding.expirationTimerView.isInvisible = true
}
container.requestLayout()
if (!message.isOutgoing) binding.messageStatusTextView.bringToFront()
binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
}
private fun handleIsSelectedChanged() {
background = if (snIsSelected) {
ColorDrawable(context.getColorFromAttr(R.attr.message_selected))
} else {
null
}
background = if (snIsSelected) ColorDrawable(context.getColorFromAttr(R.attr.message_selected)) else null
}
override fun onDraw(canvas: Canvas) {
val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing)
val iconSize = toPx(24, context.resources)
val left = binding.messageInnerContainer.left + binding.messageContentView.root.right + spacing
val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.root.marginBottom - (iconSize / 2)
val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.marginBottom - (iconSize / 2)
val right = left + iconSize
val bottom = top + iconSize
swipeToReplyIconRect.left = left
@ -370,12 +371,17 @@ class VisibleMessageView : LinearLayout {
}
fun recycle() {
binding.profilePictureView.root.recycle()
binding.profilePictureView.recycle()
binding.messageContentView.root.recycle()
}
fun playHighlight() {
binding.messageContentView.root.playHighlight()
}
// endregion
// region Interaction
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
if (onPress == null || onSwipeToReply == null || onLongPress == null) { return false }
when (event.action) {
@ -467,7 +473,7 @@ class VisibleMessageView : LinearLayout {
}
fun onContentClick(event: MotionEvent) {
binding.messageContentView.root.onContentClick.iterator().forEach { clickHandler -> clickHandler.invoke(event) }
binding.messageContentView.root.onContentClick(event)
}
private fun onPress(event: MotionEvent) {
@ -476,14 +482,13 @@ class VisibleMessageView : LinearLayout {
}
private fun maybeShowUserDetails(publicKey: String, threadID: Long) {
val userDetailsBottomSheet = UserDetailsBottomSheet()
val bundle = bundleOf(
UserDetailsBottomSheet().apply {
arguments = bundleOf(
UserDetailsBottomSheet.ARGUMENT_PUBLIC_KEY to publicKey,
UserDetailsBottomSheet.ARGUMENT_THREAD_ID to threadID
)
userDetailsBottomSheet.arguments = bundle
val activity = context as AppCompatActivity
userDetailsBottomSheet.show(activity.supportFragmentManager, userDetailsBottomSheet.tag)
)
show((this@VisibleMessageView.context as AppCompatActivity).supportFragmentManager, tag)
}
}
fun playVoiceMessage() {

View File

@ -92,7 +92,7 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener {
if (progress == 1.0) {
togglePlayback()
handleProgressChanged(0.0)
delegate?.playVoiceMessageAtIndexIfPossible(indexInAdapter - 1)
delegate?.playVoiceMessageAtIndexIfPossible(indexInAdapter + 1)
} else {
handleProgressChanged(progress)
}

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