mirror of
https://github.com/oxen-io/session-android.git
synced 2025-07-11 22:48:36 +00:00
Merge branch 'dev' into prefix-conversation
This commit is contained in:
commit
13fce6e562
88
.drone.jsonnet
Normal file
88
.drone.jsonnet
Normal 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'
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
45
.github/ISSUE_TEMPLATE.md
vendored
45
.github/ISSUE_TEMPLATE.md
vendored
@ -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
|
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -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
74
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
|
26
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
26
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
6
.gitignore
vendored
@ -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
|
@ -34,6 +34,12 @@ Setting up a development environment and building from Android Studio
|
||||
6. Project initialization and building should proceed.
|
||||
7. Clone submodules with `git submodule update --init --recursive`
|
||||
|
||||
If you would like to build the Huawei Flavor with Huawei HMS push notifications you will need to pass 'huawei' as a command line arg to include the required dependencies.
|
||||
|
||||
e.g. `./gradlew assembleHuaweiDebug -Phuawei`
|
||||
|
||||
If you are building in Android Studio then add `-Phuawei` to `Preferences > Build, Execution, Deployment > Gradle-Android Compiler > Command-line Options`
|
||||
|
||||
Contributing code
|
||||
-----------------
|
||||
|
||||
|
350
app/build.gradle
350
app/build.gradle
@ -24,160 +24,15 @@ apply plugin: 'kotlin-android'
|
||||
apply plugin: 'witness'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
apply plugin: 'dagger.hilt.android.plugin'
|
||||
|
||||
|
||||
configurations.all {
|
||||
exclude module: "commons-logging"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation("com.google.dagger:hilt-android:2.46.1")
|
||||
kapt("com.google.dagger:hilt-android-compiler:2.44")
|
||||
|
||||
implementation "androidx.appcompat:appcompat:$appcompatVersion"
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
implementation "com.google.android.material:material:$materialVersion"
|
||||
implementation 'com.google.android:flexbox:2.0.1'
|
||||
implementation 'androidx.legacy:legacy-support-v13:1.0.0'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation "androidx.preference:preference-ktx:$preferenceVersion"
|
||||
implementation 'androidx.legacy:legacy-preference-v14:1.0.0'
|
||||
implementation 'androidx.gridlayout:gridlayout:1.0.0'
|
||||
implementation 'androidx.exifinterface:exifinterface:1.3.4'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
|
||||
implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion"
|
||||
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
|
||||
implementation "androidx.paging:paging-runtime-ktx:$pagingVersion"
|
||||
implementation 'androidx.activity:activity-ktx:1.5.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.5.3'
|
||||
implementation "androidx.core:core-ktx:$coreVersion"
|
||||
implementation "androidx.work:work-runtime-ktx:2.7.1"
|
||||
implementation ("com.google.firebase:firebase-messaging:18.0.0") {
|
||||
exclude group: 'com.google.firebase', module: 'firebase-core'
|
||||
exclude group: 'com.google.firebase', module: 'firebase-analytics'
|
||||
exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
|
||||
}
|
||||
implementation 'com.google.android.exoplayer:exoplayer-core:2.9.1'
|
||||
implementation 'com.google.android.exoplayer:exoplayer-ui:2.9.1'
|
||||
implementation 'org.conscrypt:conscrypt-android:2.0.0'
|
||||
implementation 'org.signal:aesgcmprovider:0.0.3'
|
||||
implementation 'org.webrtc:google-webrtc:1.0.32006'
|
||||
implementation "me.leolin:ShortcutBadger:1.1.16"
|
||||
implementation 'se.emilsjolander:stickylistheaders:2.7.0'
|
||||
implementation 'com.jpardogo.materialtabstrip:library:1.0.9'
|
||||
implementation 'org.apache.httpcomponents:httpclient-android:4.3.5'
|
||||
implementation 'commons-net:commons-net:3.7.2'
|
||||
implementation 'com.github.chrisbanes:PhotoView:2.1.3'
|
||||
implementation "com.github.bumptech.glide:glide:$glideVersion"
|
||||
annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion"
|
||||
kapt "com.github.bumptech.glide:compiler:$glideVersion"
|
||||
implementation 'com.makeramen:roundedimageview:2.1.0'
|
||||
implementation 'com.pnikosis:materialish-progress:1.5'
|
||||
implementation 'org.greenrobot:eventbus:3.0.0'
|
||||
implementation 'pl.tajchert:waitingdots:0.1.0'
|
||||
implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0'
|
||||
implementation 'com.melnykov:floatingactionbutton:1.3.0'
|
||||
implementation 'com.google.zxing:android-integration:3.1.0'
|
||||
implementation "com.google.dagger:hilt-android:$daggerVersion"
|
||||
kapt "com.google.dagger:hilt-compiler:$daggerVersion"
|
||||
implementation 'mobi.upod:time-duration-picker:1.1.3'
|
||||
implementation 'com.google.zxing:core:3.2.1'
|
||||
implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.6.0') {
|
||||
exclude group: 'com.android.support', module: 'support-annotations'
|
||||
}
|
||||
implementation ('cn.carbswang.android:NumberPickerView:1.0.9') {
|
||||
exclude group: 'com.android.support', module: 'appcompat-v7'
|
||||
}
|
||||
implementation ('com.tomergoldst.android:tooltips:1.0.6') {
|
||||
exclude group: 'com.android.support', module: 'appcompat-v7'
|
||||
}
|
||||
implementation ('com.klinkerapps:android-smsmms:4.0.1') {
|
||||
exclude group: 'com.squareup.okhttp', module: 'okhttp'
|
||||
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
|
||||
}
|
||||
implementation 'com.annimon:stream:1.1.8'
|
||||
implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
|
||||
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
|
||||
implementation 'androidx.sqlite:sqlite-ktx:2.3.1'
|
||||
implementation 'net.zetetic:sqlcipher-android:4.5.4@aar'
|
||||
implementation project(":libsignal")
|
||||
implementation project(":libsession")
|
||||
implementation project(":libsession-util")
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion"
|
||||
implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version"
|
||||
implementation project(":liblazysodium")
|
||||
implementation "net.java.dev.jna:jna:5.8.0@aar"
|
||||
implementation "com.google.protobuf:protobuf-java:$protobufVersion"
|
||||
implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion"
|
||||
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
|
||||
implementation 'app.cash.copper:copper-flow:1.0.0'
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
|
||||
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
|
||||
implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion"
|
||||
implementation "com.jakewharton.rxbinding3:rxbinding:3.1.0"
|
||||
implementation "com.github.tbruyelle:rxpermissions:0.10.2"
|
||||
implementation "com.github.ybq:Android-SpinKit:1.4.0"
|
||||
implementation "com.opencsv:opencsv:4.6"
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
testImplementation 'org.assertj:assertj-core:3.11.1'
|
||||
testImplementation "org.mockito:mockito-inline:4.10.0"
|
||||
testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
|
||||
androidTestImplementation "org.mockito:mockito-android:4.10.0"
|
||||
androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
|
||||
testImplementation "androidx.test:core:$testCoreVersion"
|
||||
testImplementation "androidx.arch.core:core-testing:2.2.0"
|
||||
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
|
||||
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
|
||||
// Core library
|
||||
androidTestImplementation "androidx.test:core:$testCoreVersion"
|
||||
|
||||
androidTestImplementation('com.adevinta.android:barista:4.2.0') {
|
||||
exclude group: 'org.jetbrains.kotlin'
|
||||
}
|
||||
|
||||
// AndroidJUnitRunner and JUnit Rules
|
||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||
|
||||
// Assertions
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.ext:truth:1.5.0'
|
||||
androidTestImplementation 'com.google.truth:truth:1.1.3'
|
||||
|
||||
// Espresso dependencies
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.5.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1'
|
||||
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
|
||||
androidTestUtil 'androidx.test:orchestrator:1.4.2'
|
||||
|
||||
testImplementation 'org.robolectric:robolectric:4.4'
|
||||
testImplementation 'org.robolectric:shadows-multidex:4.4'
|
||||
|
||||
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.1'
|
||||
implementation 'androidx.compose.ui:ui:1.4.3'
|
||||
implementation 'androidx.compose.ui:ui-tooling:1.4.3'
|
||||
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.31.5-beta"
|
||||
implementation "com.google.accompanist:accompanist-pager-indicators:0.31.5-beta"
|
||||
implementation "androidx.compose.runtime:runtime-livedata:1.4.3"
|
||||
|
||||
implementation 'androidx.compose.foundation:foundation-layout:1.5.0-alpha02'
|
||||
implementation 'androidx.compose.material:material:1.5.0-alpha02'
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 354
|
||||
def canonicalVersionName = "1.17.0"
|
||||
def canonicalVersionCode = 369
|
||||
def canonicalVersionName = "1.18.1"
|
||||
|
||||
def postFixSize = 10
|
||||
def abiPostFix = ['armeabi-v7a' : 1,
|
||||
@ -267,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', '\"\"'
|
||||
}
|
||||
}
|
||||
|
||||
@ -312,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() {
|
||||
|
@ -158,6 +158,7 @@ class HomeActivityTests {
|
||||
|
||||
val dialogPromptText = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.dialog_open_url_explanation, amazonPuny)
|
||||
|
||||
onView(isRoot()).perform(waitFor(1000)) // no other way for this to work apparently
|
||||
onView(withText(dialogPromptText)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
|
@ -7,16 +7,25 @@ import androidx.test.filters.SmallTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import network.loki.messenger.libsession_util.ConfigBase
|
||||
import network.loki.messenger.libsession_util.Contacts
|
||||
import network.loki.messenger.libsession_util.ConversationVolatileConfig
|
||||
import network.loki.messenger.libsession_util.util.Contact
|
||||
import network.loki.messenger.libsession_util.util.Conversation
|
||||
import network.loki.messenger.libsession_util.util.ExpiryMode
|
||||
import org.hamcrest.CoreMatchers.equalTo
|
||||
import org.hamcrest.CoreMatchers.instanceOf
|
||||
import org.hamcrest.MatcherAssert.assertThat
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.argThat
|
||||
import org.mockito.kotlin.argWhere
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.spy
|
||||
import org.mockito.kotlin.verify
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsignal.utilities.KeyHelper
|
||||
import org.session.libsignal.utilities.hexEncodedPublicKey
|
||||
@ -50,13 +59,22 @@ class LibSessionTests {
|
||||
|
||||
private fun buildContactMessage(contactList: List<Contact>): ByteArray {
|
||||
val (key,_) = maybeGetUserInfo()!!
|
||||
val contacts = Contacts.Companion.newInstance(key)
|
||||
val contacts = Contacts.newInstance(key)
|
||||
contactList.forEach { contact ->
|
||||
contacts.set(contact)
|
||||
}
|
||||
return contacts.push().config
|
||||
}
|
||||
|
||||
private fun buildVolatileMessage(conversations: List<Conversation>): ByteArray {
|
||||
val (key, _) = maybeGetUserInfo()!!
|
||||
val volatile = ConversationVolatileConfig.newInstance(key)
|
||||
conversations.forEach { conversation ->
|
||||
volatile.set(conversation)
|
||||
}
|
||||
return volatile.push().config
|
||||
}
|
||||
|
||||
private fun fakePollNewConfig(configBase: ConfigBase, toMerge: ByteArray) {
|
||||
configBase.merge(nextFakeHash to toMerge)
|
||||
MessagingModuleConfiguration.shared.configFactory.persist(configBase, System.currentTimeMillis())
|
||||
@ -95,8 +113,83 @@ class LibSessionTests {
|
||||
fakePollNewConfig(contacts, newContactMerge)
|
||||
verify(storageSpy).addLibSessionContacts(argThat {
|
||||
first().let { it.id == newContactId && it.approved } && size == 1
|
||||
})
|
||||
}, any())
|
||||
verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_expected_configs() {
|
||||
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
|
||||
val storageSpy = spy(app.storage)
|
||||
app.storage = storageSpy
|
||||
|
||||
val randomRecipient = randomSessionId()
|
||||
val newContact = Contact(
|
||||
id = randomRecipient,
|
||||
approved = true,
|
||||
expiryMode = ExpiryMode.AfterSend(1000)
|
||||
)
|
||||
val newConvo = Conversation.OneToOne(
|
||||
randomRecipient,
|
||||
SnodeAPI.nowWithOffset,
|
||||
false
|
||||
)
|
||||
val volatiles = MessagingModuleConfiguration.shared.configFactory.convoVolatile!!
|
||||
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
|
||||
val newContactMerge = buildContactMessage(listOf(newContact))
|
||||
val newVolatileMerge = buildVolatileMessage(listOf(newConvo))
|
||||
fakePollNewConfig(contacts, newContactMerge)
|
||||
fakePollNewConfig(volatiles, newVolatileMerge)
|
||||
verify(storageSpy).setExpirationConfiguration(argWhere { config ->
|
||||
config.expiryMode is ExpiryMode.AfterSend
|
||||
&& config.expiryMode.expirySeconds == 1000L
|
||||
})
|
||||
val threadId = storageSpy.getThreadId(Address.fromSerialized(randomRecipient))!!
|
||||
val newExpiry = storageSpy.getExpirationConfiguration(threadId)!!
|
||||
assertThat(newExpiry.expiryMode, instanceOf(ExpiryMode.AfterSend::class.java))
|
||||
assertThat(newExpiry.expiryMode.expirySeconds, equalTo(1000))
|
||||
assertThat(newExpiry.expiryMode.expiryMillis, equalTo(1000000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_overwrite_config() {
|
||||
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
|
||||
val storageSpy = spy(app.storage)
|
||||
app.storage = storageSpy
|
||||
|
||||
// Initial state
|
||||
val randomRecipient = randomSessionId()
|
||||
val currentContact = Contact(
|
||||
id = randomRecipient,
|
||||
approved = true,
|
||||
expiryMode = ExpiryMode.NONE
|
||||
)
|
||||
val newConvo = Conversation.OneToOne(
|
||||
randomRecipient,
|
||||
SnodeAPI.nowWithOffset,
|
||||
false
|
||||
)
|
||||
val volatiles = MessagingModuleConfiguration.shared.configFactory.convoVolatile!!
|
||||
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
|
||||
val newContactMerge = buildContactMessage(listOf(currentContact))
|
||||
val newVolatileMerge = buildVolatileMessage(listOf(newConvo))
|
||||
fakePollNewConfig(contacts, newContactMerge)
|
||||
fakePollNewConfig(volatiles, newVolatileMerge)
|
||||
verify(storageSpy).setExpirationConfiguration(argWhere { config ->
|
||||
config.expiryMode == ExpiryMode.NONE
|
||||
})
|
||||
val threadId = storageSpy.getThreadId(Address.fromSerialized(randomRecipient))!!
|
||||
val currentExpiryConfig = storageSpy.getExpirationConfiguration(threadId)!!
|
||||
assertThat(currentExpiryConfig.expiryMode, equalTo(ExpiryMode.NONE))
|
||||
assertThat(currentExpiryConfig.expiryMode.expirySeconds, equalTo(0))
|
||||
assertThat(currentExpiryConfig.expiryMode.expiryMillis, equalTo(0))
|
||||
// Set new state and overwrite
|
||||
val updatedContact = currentContact.copy(expiryMode = ExpiryMode.AfterSend(1000))
|
||||
val updateContactMerge = buildContactMessage(listOf(updatedContact))
|
||||
fakePollNewConfig(contacts, updateContactMerge)
|
||||
val updatedExpiryConfig = storageSpy.getExpirationConfiguration(threadId)!!
|
||||
assertThat(updatedExpiryConfig.expiryMode, instanceOf(ExpiryMode.AfterSend::class.java))
|
||||
assertThat(updatedExpiryConfig.expiryMode.expirySeconds, equalTo(1000))
|
||||
}
|
||||
|
||||
}
|
26
app/src/huawei/AndroidManifest.xml
Normal file
26
app/src/huawei/AndroidManifest.xml
Normal 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>
|
96
app/src/huawei/agconnect-services.json
Normal file
96
app/src/huawei/agconnect-services.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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) }
|
||||
}
|
||||
}
|
5
app/src/huawei/res/values/strings.xml
Normal file
5
app/src/huawei/res/values/strings.xml
Normal 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 Huawei’s notification servers.</string>
|
||||
<string name="activity_pn_mode_fast_mode_explanation">You\'ll be notified of new messages reliably and immediately using Huawei’s notification servers.</string>
|
||||
</resources>
|
@ -34,6 +34,8 @@
|
||||
<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"/>
|
||||
@ -176,6 +178,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"
|
||||
@ -235,10 +240,6 @@
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.Session.DayNight">
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.thoughtcrime.securesms.longmessage.LongMessageActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.Session.DayNight" />
|
||||
<activity
|
||||
android:name="org.thoughtcrime.securesms.DatabaseUpgradeActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
@ -310,20 +311,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"
|
||||
|
@ -41,6 +41,7 @@ import org.session.libsession.messaging.sending_receiving.pollers.Poller;
|
||||
import org.session.libsession.snode.SnodeModule;
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.session.libsession.utilities.ConfigFactoryUpdateListener;
|
||||
import org.session.libsession.utilities.Device;
|
||||
import org.session.libsession.utilities.ProfilePictureUtilities;
|
||||
import org.session.libsession.utilities.SSKEnvironment;
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
@ -73,10 +74,9 @@ import org.thoughtcrime.securesms.logging.PersistentLogger;
|
||||
import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger;
|
||||
import org.thoughtcrime.securesms.notifications.BackgroundPollWorker;
|
||||
import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier;
|
||||
import org.thoughtcrime.securesms.notifications.FcmUtils;
|
||||
import org.thoughtcrime.securesms.notifications.LokiPushNotificationManager;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier;
|
||||
import org.thoughtcrime.securesms.notifications.PushRegistry;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
@ -109,6 +109,7 @@ import dagger.hilt.EntryPoints;
|
||||
import dagger.hilt.android.HiltAndroidApp;
|
||||
import kotlin.Unit;
|
||||
import kotlinx.coroutines.Job;
|
||||
import network.loki.messenger.BuildConfig;
|
||||
import network.loki.messenger.libsession_util.ConfigBase;
|
||||
import network.loki.messenger.libsession_util.UserProfile;
|
||||
|
||||
@ -143,8 +144,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
|
||||
@Inject LokiAPIDatabase lokiAPIDatabase;
|
||||
@Inject public Storage storage;
|
||||
@Inject Device device;
|
||||
@Inject MessageDataProvider messageDataProvider;
|
||||
@Inject TextSecurePreferences textSecurePreferences;
|
||||
@Inject PushRegistry pushRegistry;
|
||||
@Inject ConfigFactory configFactory;
|
||||
CallMessageProcessor callMessageProcessor;
|
||||
MessagingModuleConfiguration messagingModuleConfiguration;
|
||||
@ -194,21 +197,25 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyUpdates(@NonNull ConfigBase forConfigObject) {
|
||||
public void notifyUpdates(@NonNull ConfigBase forConfigObject, long messageTimestamp) {
|
||||
// forward to the config factory / storage ig
|
||||
if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) {
|
||||
textSecurePreferences.setConfigurationMessageSynced(true);
|
||||
}
|
||||
storage.notifyConfigUpdates(forConfigObject);
|
||||
storage.notifyConfigUpdates(forConfigObject, messageTimestamp);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
TextSecurePreferences.setPushSuffix(BuildConfig.PUSH_KEY_SUFFIX);
|
||||
|
||||
DatabaseModule.init(this);
|
||||
MessagingModuleConfiguration.configure(this);
|
||||
super.onCreate();
|
||||
messagingModuleConfiguration = new MessagingModuleConfiguration(this,
|
||||
messagingModuleConfiguration = new MessagingModuleConfiguration(
|
||||
this,
|
||||
storage,
|
||||
device,
|
||||
messageDataProvider,
|
||||
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this),
|
||||
configFactory
|
||||
@ -226,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();
|
||||
@ -427,33 +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;
|
||||
|
||||
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
|
||||
if (TextSecurePreferences.isUsingFCM(this)) {
|
||||
LokiPushNotificationManager.register(token, userPublicKey, this, force);
|
||||
} else {
|
||||
LokiPushNotificationManager.unregister(token, this);
|
||||
}
|
||||
});
|
||||
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
private void setUpPollingIfNeeded() {
|
||||
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
|
||||
if (userPublicKey == null) return;
|
||||
@ -524,18 +500,14 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
||||
}
|
||||
|
||||
public void clearAllData(boolean isMigratingToV2KeyPair) {
|
||||
String token = TextSecurePreferences.getFCMToken(this);
|
||||
if (token != null && !token.isEmpty()) {
|
||||
LokiPushNotificationManager.unregister(token, this);
|
||||
}
|
||||
if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive()) {
|
||||
firebaseInstanceIdJob.cancel(null);
|
||||
}
|
||||
String displayName = TextSecurePreferences.getProfileName(this);
|
||||
boolean isUsingFCM = TextSecurePreferences.isUsingFCM(this);
|
||||
boolean isUsingFCM = TextSecurePreferences.isPushEnabled(this);
|
||||
TextSecurePreferences.clearAll(this);
|
||||
if (isMigratingToV2KeyPair) {
|
||||
TextSecurePreferences.setIsUsingFCM(this, isUsingFCM);
|
||||
TextSecurePreferences.setPushEnabled(this, isUsingFCM);
|
||||
TextSecurePreferences.setProfileName(this, displayName);
|
||||
}
|
||||
getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit();
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
16
app/src/main/java/org/thoughtcrime/securesms/DeviceModule.kt
Normal file
16
app/src/main/java/org/thoughtcrime/securesms/DeviceModule.kt
Normal 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
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import cn.carbswang.android.numberpickerview.library.NumberPickerView
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.utilities.ExpirationUtil
|
||||
|
||||
fun Context.showExpirationDialog(
|
||||
expiration: Int,
|
||||
onExpirationTime: (Int) -> Unit
|
||||
): AlertDialog {
|
||||
val view = LayoutInflater.from(this).inflate(R.layout.expiration_dialog, null)
|
||||
val numberPickerView = view.findViewById<NumberPickerView>(R.id.expiration_number_picker)
|
||||
|
||||
fun updateText(index: Int) {
|
||||
view.findViewById<TextView>(R.id.expiration_details).text = when (index) {
|
||||
0 -> getString(R.string.ExpirationDialog_your_messages_will_not_expire)
|
||||
else -> getString(
|
||||
R.string.ExpirationDialog_your_messages_will_disappear_s_after_they_have_been_seen,
|
||||
numberPickerView.displayedValues[index]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val expirationTimes = resources.getIntArray(R.array.expiration_times)
|
||||
val expirationDisplayValues = expirationTimes
|
||||
.map { ExpirationUtil.getExpirationDisplayValue(this, it) }
|
||||
.toTypedArray()
|
||||
|
||||
val selectedIndex = expirationTimes.run { indexOfFirst { it >= expiration }.coerceIn(indices) }
|
||||
|
||||
numberPickerView.apply {
|
||||
displayedValues = expirationDisplayValues
|
||||
minValue = 0
|
||||
maxValue = expirationTimes.lastIndex
|
||||
setOnValueChangedListener { _, _, index -> updateText(index) }
|
||||
value = selectedIndex
|
||||
}
|
||||
|
||||
updateText(selectedIndex)
|
||||
|
||||
return showSessionDialog {
|
||||
title(getString(R.string.ExpirationDialog_disappearing_messages))
|
||||
view(view)
|
||||
okButton { onExpirationTime(numberPickerView.let { expirationTimes[it.value] }) }
|
||||
cancelButton()
|
||||
}
|
||||
}
|
@ -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;
|
||||
@ -534,12 +533,16 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
|
||||
|
||||
viewModel.setCursor(this, data.first, leftIsRecent);
|
||||
|
||||
if (restartItem >= 0 || data.second >= 0) {
|
||||
int item = restartItem >= 0 ? restartItem : data.second;
|
||||
mediaPager.setCurrentItem(item);
|
||||
|
||||
if (item == 0) {
|
||||
viewPagerListener.onPageSelected(0);
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "one of restartItem "+restartItem+" and data.second "+data.second+" would cause OOB exception");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -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) {
|
||||
|
@ -105,22 +105,22 @@ class SessionDialogBuilder(val context: Context) {
|
||||
|
||||
fun destructiveButton(
|
||||
@StringRes text: Int,
|
||||
@StringRes contentDescription: Int,
|
||||
@StringRes contentDescription: Int = text,
|
||||
listener: () -> Unit = {}
|
||||
) = button(
|
||||
text,
|
||||
contentDescription,
|
||||
R.style.Widget_Session_Button_Dialog_DestructiveText,
|
||||
listener
|
||||
)
|
||||
) { listener() }
|
||||
|
||||
fun okButton(listener: (() -> Unit) = {}) = button(android.R.string.ok, listener = listener)
|
||||
fun cancelButton(listener: (() -> Unit) = {}) = button(android.R.string.cancel, R.string.AccessibilityId_cancel_button, listener = 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)
|
||||
@ -129,7 +129,7 @@ class SessionDialogBuilder(val context: Context) {
|
||||
.apply { setMargins(toPx(20, resources)) }
|
||||
setOnClickListener {
|
||||
listener.invoke()
|
||||
dismiss()
|
||||
if (dismiss) dismiss()
|
||||
}
|
||||
}.let(buttonLayout::addView)
|
||||
|
||||
|
@ -1,5 +0,0 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
public interface Unbindable {
|
||||
public void unbind();
|
||||
}
|
@ -186,7 +186,7 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
|
||||
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) {
|
||||
@ -195,7 +195,7 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
|
||||
|
||||
messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId)
|
||||
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 +212,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 {
|
||||
|
@ -1,149 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.os.AsyncTask;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.session.libsession.snode.SnodeAPI;
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
import org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
public class ConversationItemFooter extends LinearLayout {
|
||||
|
||||
private TextView dateView;
|
||||
private ExpirationTimerView timerView;
|
||||
private ImageView insecureIndicatorView;
|
||||
private DeliveryStatusView deliveryStatusView;
|
||||
|
||||
public ConversationItemFooter(Context context) {
|
||||
super(context);
|
||||
init(null);
|
||||
}
|
||||
|
||||
public ConversationItemFooter(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init(attrs);
|
||||
}
|
||||
|
||||
public ConversationItemFooter(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
init(attrs);
|
||||
}
|
||||
|
||||
private void init(@Nullable AttributeSet attrs) {
|
||||
inflate(getContext(), R.layout.conversation_item_footer, this);
|
||||
|
||||
dateView = findViewById(R.id.footer_date);
|
||||
timerView = findViewById(R.id.footer_expiration_timer);
|
||||
insecureIndicatorView = findViewById(R.id.footer_insecure_indicator);
|
||||
deliveryStatusView = findViewById(R.id.footer_delivery_status);
|
||||
|
||||
if (attrs != null) {
|
||||
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemFooter, 0, 0);
|
||||
setTextColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_text_color, getResources().getColor(R.color.core_white)));
|
||||
setIconColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_icon_color, getResources().getColor(R.color.core_white)));
|
||||
typedArray.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow();
|
||||
timerView.stopAnimation();
|
||||
}
|
||||
|
||||
public void setMessageRecord(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
|
||||
presentDate(messageRecord, locale);
|
||||
presentTimer(messageRecord);
|
||||
presentInsecureIndicator(messageRecord);
|
||||
presentDeliveryStatus(messageRecord);
|
||||
}
|
||||
|
||||
public void setTextColor(int color) {
|
||||
dateView.setTextColor(color);
|
||||
}
|
||||
|
||||
public void setIconColor(int color) {
|
||||
timerView.setColorFilter(color);
|
||||
insecureIndicatorView.setColorFilter(color);
|
||||
deliveryStatusView.setTint(color);
|
||||
}
|
||||
|
||||
private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
|
||||
dateView.forceLayout();
|
||||
|
||||
if (messageRecord.isFailed()) {
|
||||
dateView.setText(R.string.ConversationItem_error_not_delivered);
|
||||
} else {
|
||||
dateView.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private void presentTimer(@NonNull final MessageRecord messageRecord) {
|
||||
if (messageRecord.getExpiresIn() > 0 && !messageRecord.isPending()) {
|
||||
this.timerView.setVisibility(View.VISIBLE);
|
||||
this.timerView.setPercentComplete(0);
|
||||
|
||||
if (messageRecord.getExpireStarted() > 0) {
|
||||
this.timerView.setExpirationTime(messageRecord.getExpireStarted(),
|
||||
messageRecord.getExpiresIn());
|
||||
this.timerView.startAnimation();
|
||||
|
||||
if (messageRecord.getExpireStarted() + messageRecord.getExpiresIn() <= SnodeAPI.getNowWithOffset()) {
|
||||
ApplicationContext.getInstance(getContext()).getExpiringMessageManager().checkSchedule();
|
||||
}
|
||||
} else if (!messageRecord.isOutgoing() && !messageRecord.isMediaPending()) {
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
@Override
|
||||
protected Void doInBackground(Void... params) {
|
||||
ExpiringMessageManager expirationManager = ApplicationContext.getInstance(getContext()).getExpiringMessageManager();
|
||||
long id = messageRecord.getId();
|
||||
boolean mms = messageRecord.isMms();
|
||||
|
||||
if (mms) DatabaseComponent.get(getContext()).mmsDatabase().markExpireStarted(id);
|
||||
else DatabaseComponent.get(getContext()).smsDatabase().markExpireStarted(id);
|
||||
|
||||
expirationManager.scheduleDeletion(id, mms, messageRecord.getExpiresIn());
|
||||
return null;
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
}
|
||||
} else {
|
||||
this.timerView.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private void presentInsecureIndicator(@NonNull MessageRecord messageRecord) {
|
||||
insecureIndicatorView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void presentDeliveryStatus(@NonNull MessageRecord messageRecord) {
|
||||
if (!messageRecord.isFailed()) {
|
||||
if (!messageRecord.isOutgoing()) deliveryStatusView.setNone();
|
||||
else if (messageRecord.isPending()) deliveryStatusView.setPending();
|
||||
else if (messageRecord.isRead()) deliveryStatusView.setRead();
|
||||
else if (messageRecord.isDelivered()) deliveryStatusView.setDelivered();
|
||||
else deliveryStatusView.setSent();
|
||||
} else {
|
||||
deliveryStatusView.setNone();
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -10,7 +10,6 @@ import androidx.annotation.DimenRes
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ViewProfilePictureBinding
|
||||
import network.loki.messenger.databinding.ViewUserBinding
|
||||
import org.session.libsession.avatars.ContactColors
|
||||
import org.session.libsession.avatars.PlaceholderAvatarPhoto
|
||||
import org.session.libsession.avatars.ProfileContactPhoto
|
||||
@ -74,7 +73,7 @@ class ProfilePictureView @JvmOverloads constructor(
|
||||
additionalDisplayName = getUserDisplayName(apk)
|
||||
}
|
||||
} else if(recipient.isOpenGroupInboxRecipient) {
|
||||
val publicKey = GroupUtil.getDecodedOpenGroupInbox(recipient.address.serialize())
|
||||
val publicKey = GroupUtil.getDecodedOpenGroupInboxSessionId(recipient.address.serialize())
|
||||
this.publicKey = publicKey
|
||||
displayName = getUserDisplayName(publicKey)
|
||||
additionalPublicKey = null
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -1,13 +1,17 @@
|
||||
package org.thoughtcrime.securesms.components.menu
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorRes
|
||||
|
||||
/**
|
||||
* Represents an action to be rendered
|
||||
*/
|
||||
data class ActionItem @JvmOverloads constructor(
|
||||
data class ActionItem(
|
||||
@AttrRes val iconRes: Int,
|
||||
val title: CharSequence,
|
||||
val title: Int,
|
||||
val action: Runnable,
|
||||
val contentDescription: String? = null
|
||||
val contentDescription: Int? = null,
|
||||
val subtitle: ((Context) -> CharSequence?)? = null,
|
||||
@ColorRes val color: Int? = null,
|
||||
)
|
||||
|
@ -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,28 +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)
|
||||
}
|
||||
itemView.contentDescription = model.item.contentDescription
|
||||
title.text = model.item.title
|
||||
item.contentDescription?.let(context.resources::getString)?.let { itemView.contentDescription = it }
|
||||
title.setText(item.title)
|
||||
color?.let(title::setTextColor)
|
||||
color?.let(subtitle::setTextColor)
|
||||
subtitle.isGone = true
|
||||
item.subtitle?.let { startSubtitleJob(subtitle, it) }
|
||||
itemView.setOnClickListener {
|
||||
model.item.action.run()
|
||||
item.action.run()
|
||||
onItemClick()
|
||||
}
|
||||
|
||||
when (model.displayType) {
|
||||
DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_top)
|
||||
DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_bottom)
|
||||
DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_middle)
|
||||
DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_only)
|
||||
DisplayType.TOP -> R.drawable.context_menu_item_background_top
|
||||
DisplayType.BOTTOM -> R.drawable.context_menu_item_background_bottom
|
||||
DisplayType.MIDDLE -> R.drawable.context_menu_item_background_middle
|
||||
DisplayType.ONLY -> R.drawable.context_menu_item_background_only
|
||||
}.let(itemView::setBackgroundResource)
|
||||
}
|
||||
|
||||
private fun startSubtitleJob(textView: TextView, getSubtitle: (Context) -> CharSequence?) {
|
||||
fun updateText() = getSubtitle(context).let {
|
||||
textView.isGone = it == null
|
||||
textView.text = it
|
||||
}
|
||||
updateText()
|
||||
|
||||
subtitleJob?.cancel()
|
||||
subtitleJob = CoroutineScope(Dispatchers.Main).launch {
|
||||
while (true) {
|
||||
updateText()
|
||||
delay(200)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
// naive job cancellation, will break if many items are added to context menu.
|
||||
subtitleJob?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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 "";
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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.isOpenGroupRecipient) {
|
||||
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
|
||||
}
|
@ -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)
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
@ -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
|
||||
}
|
@ -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
|
||||
)
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
@ -30,21 +30,21 @@ import android.view.MenuItem
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.RelativeLayout
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.DimenRes
|
||||
import androidx.core.text.set
|
||||
import androidx.core.text.toSpannable
|
||||
import androidx.core.view.drawToBitmap
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.flowWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.loader.app.LoaderManager
|
||||
@ -59,11 +59,13 @@ import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ActivityConversationV2Binding
|
||||
import network.loki.messenger.databinding.ViewVisibleMessageBinding
|
||||
import network.loki.messenger.libsession_util.util.ExpiryMode
|
||||
import nl.komponents.kovenant.ui.successUi
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
@ -71,14 +73,14 @@ import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
||||
import org.session.libsession.messaging.jobs.JobQueue
|
||||
import org.session.libsession.messaging.mentions.Mention
|
||||
import org.session.libsession.messaging.mentions.MentionsManager
|
||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||
import org.session.libsession.messaging.messages.applyExpiryMode
|
||||
import org.session.libsession.messaging.messages.control.DataExtractionNotification
|
||||
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
|
||||
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
|
||||
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
|
||||
import org.session.libsession.messaging.messages.visible.Reaction
|
||||
import org.session.libsession.messaging.messages.visible.VisibleMessage
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability
|
||||
import org.session.libsession.messaging.sending_receiving.MessageSender
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.Attachment
|
||||
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
|
||||
@ -105,13 +107,14 @@ import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||
import org.thoughtcrime.securesms.attachments.ScreenshotObserver
|
||||
import org.thoughtcrime.securesms.audio.AudioRecorder
|
||||
import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey
|
||||
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher
|
||||
import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate
|
||||
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener
|
||||
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP
|
||||
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_DELETE
|
||||
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_REPLY
|
||||
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_RESEND
|
||||
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_DELETE
|
||||
import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog
|
||||
import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog
|
||||
import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog
|
||||
@ -127,19 +130,16 @@ import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDel
|
||||
import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar
|
||||
import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel
|
||||
import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager
|
||||
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities
|
||||
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities
|
||||
import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.MnemonicUtilities
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase
|
||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase
|
||||
import org.thoughtcrime.securesms.database.LokiMessageDatabase
|
||||
import org.thoughtcrime.securesms.database.LokiThreadDatabase
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase
|
||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase
|
||||
import org.thoughtcrime.securesms.database.ReactionDatabase
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.database.SessionContactDatabase
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
@ -167,17 +167,20 @@ import org.thoughtcrime.securesms.mms.VideoSlide
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment
|
||||
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment
|
||||
import org.thoughtcrime.securesms.showExpirationDialog
|
||||
import org.thoughtcrime.securesms.showSessionDialog
|
||||
import org.thoughtcrime.securesms.util.ActivityDispatcher
|
||||
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.SaveAttachmentTask
|
||||
import org.thoughtcrime.securesms.util.SimpleTextWatcher
|
||||
import org.thoughtcrime.securesms.util.isScrolledToBottom
|
||||
import org.thoughtcrime.securesms.util.push
|
||||
import org.thoughtcrime.securesms.util.show
|
||||
import org.thoughtcrime.securesms.util.toPx
|
||||
import java.lang.ref.WeakReference
|
||||
import java.time.Instant
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@ -188,6 +191,10 @@ import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
private const val TAG = "ConversationActivityV2"
|
||||
|
||||
// Some things that seemingly belong to the input bar (e.g. the voice message recording UI) are actually
|
||||
// part of the conversation activity layout. This is just because it makes the layout a lot simpler. The
|
||||
@ -196,7 +203,7 @@ import kotlin.math.sqrt
|
||||
class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate,
|
||||
InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher,
|
||||
ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener,
|
||||
SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>,
|
||||
SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>, ConversationActionBarDelegate,
|
||||
OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback,
|
||||
ConversationMenuHelper.ConversationMenuListener {
|
||||
|
||||
@ -208,8 +215,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
@Inject lateinit var lokiThreadDb: LokiThreadDatabase
|
||||
@Inject lateinit var sessionContactDb: SessionContactDatabase
|
||||
@Inject lateinit var groupDb: GroupDatabase
|
||||
@Inject lateinit var recipientDb: RecipientDatabase
|
||||
@Inject lateinit var lokiApiDb: LokiAPIDatabase
|
||||
@Inject lateinit var smsDb: SmsDatabase
|
||||
@Inject lateinit var mmsDb: MmsDatabase
|
||||
@Inject lateinit var lokiMessageDb: LokiMessageDatabase
|
||||
@ -240,11 +245,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
val address = if (sessionId.prefix == IdPrefix.BLINDED && openGroup != null) {
|
||||
storage.getOrCreateBlindedIdMapping(sessionId.hexString, openGroup.server, openGroup.publicKey).sessionId?.let {
|
||||
fromSerialized(it)
|
||||
} ?: run {
|
||||
val openGroupInboxId =
|
||||
"${openGroup.server}!${openGroup.publicKey}!${sessionId.hexString}".toByteArray()
|
||||
fromSerialized(GroupUtil.getEncodedOpenGroupInboxID(openGroupInboxId))
|
||||
}
|
||||
} ?: GroupUtil.getEncodedOpenGroupInboxID(openGroup, sessionId)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
@ -253,7 +254,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
} ?: finish()
|
||||
}
|
||||
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair(), contentResolver)
|
||||
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
|
||||
}
|
||||
private var actionMode: ActionMode? = null
|
||||
private var unreadCount = 0
|
||||
@ -312,8 +313,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
handleSwipeToReply(message)
|
||||
},
|
||||
onItemLongPress = { message, position, view ->
|
||||
if (!isMessageRequestThread() &&
|
||||
(viewModel.openGroup == null || Capability.REACTIONS.name.lowercase() in viewModel.serverCapabilities)
|
||||
if (!viewModel.isMessageRequestThread &&
|
||||
viewModel.canReactToMessages
|
||||
) {
|
||||
showEmojiPicker(message, view)
|
||||
} else {
|
||||
@ -414,7 +415,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
|
||||
updateUnreadCountIndicator()
|
||||
updateSubtitle()
|
||||
updatePlaceholder()
|
||||
setUpBlockedBanner()
|
||||
binding!!.searchBottomBar.setEventListener(this)
|
||||
@ -442,6 +442,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
setUpRecipientObserver()
|
||||
getLatestOpenGroupInfoIfNeeded()
|
||||
setUpSearchResultObserver()
|
||||
scrollToFirstUnreadMessageIfNeeded()
|
||||
setUpOutdatedClientBanner()
|
||||
|
||||
if (author != null && messageTimestamp >= 0 && targetPosition >= 0) {
|
||||
binding?.conversationRecyclerView?.scrollToPosition(targetPosition)
|
||||
@ -457,15 +459,18 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
reactionDelegate = ConversationReactionDelegate(reactionOverlayStub)
|
||||
reactionDelegate.setOnReactionSelectedListener(this)
|
||||
lifecycleScope.launch {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
// only update the conversation every 3 seconds maximum
|
||||
// channel is rendezvous and shouldn't block on try send calls as often as we want
|
||||
val bufferedFlow = bufferedLastSeenChannel.consumeAsFlow()
|
||||
bufferedFlow.filter {
|
||||
it > storage.getLastSeen(viewModel.threadId)
|
||||
}.collectLatest { latestMessageRead ->
|
||||
bufferedLastSeenChannel.receiveAsFlow()
|
||||
.flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
|
||||
.collectLatest {
|
||||
withContext(Dispatchers.IO) {
|
||||
storage.markConversationAsRead(viewModel.threadId, latestMessageRead)
|
||||
try {
|
||||
if (it > storage.getLastSeen(viewModel.threadId)) {
|
||||
storage.markConversationAsRead(viewModel.threadId, it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "bufferedLastSeenChannel collectLatest", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -481,6 +486,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
true,
|
||||
screenshotObserver
|
||||
)
|
||||
viewModel.run {
|
||||
binding?.toolbarContent?.update(recipient ?: return, openGroup, expirationConfiguration)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
@ -497,8 +505,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
|
||||
override fun dispatchIntent(body: (Context) -> Intent?) {
|
||||
val intent = body(this) ?: return
|
||||
push(intent, false)
|
||||
body(this)?.let { push(it, false) }
|
||||
}
|
||||
|
||||
override fun showDialog(dialogFragment: DialogFragment, tag: String?) {
|
||||
@ -530,16 +537,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
|
||||
if (author != null && messageTimestamp >= 0) {
|
||||
jumpToMessage(author, messageTimestamp, firstLoad.get(), null)
|
||||
}
|
||||
else if (firstLoad.getAndSet(false)) {
|
||||
scrollToFirstUnreadMessageIfNeeded(true)
|
||||
handleRecyclerViewScrolled()
|
||||
}
|
||||
else if (oldCount != newCount) {
|
||||
} else {
|
||||
if (firstLoad.getAndSet(false)) scrollToFirstUnreadMessageIfNeeded(true)
|
||||
handleRecyclerViewScrolled()
|
||||
}
|
||||
}
|
||||
updatePlaceholder()
|
||||
viewModel.recipient?.let {
|
||||
maybeUpdateToolbar(recipient = it)
|
||||
setUpOutdatedClientBanner()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoaderReset(cursor: Loader<Cursor>) {
|
||||
@ -578,44 +585,39 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
actionBar.title = ""
|
||||
actionBar.setDisplayHomeAsUpEnabled(true)
|
||||
actionBar.setHomeButtonEnabled(true)
|
||||
binding.toolbarContent.conversationTitleView.text = when {
|
||||
recipient.isLocalNumber -> getString(R.string.note_to_self)
|
||||
else -> recipient.toShortString()
|
||||
}
|
||||
@DimenRes val sizeID: Int = if (viewModel.recipient?.isClosedGroupRecipient == true) {
|
||||
R.dimen.medium_profile_picture_size
|
||||
} else {
|
||||
R.dimen.small_profile_picture_size
|
||||
}
|
||||
val size = resources.getDimension(sizeID).roundToInt()
|
||||
binding.toolbarContent.profilePictureView.layoutParams = LinearLayout.LayoutParams(size, size)
|
||||
MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(viewModel.threadId, this)
|
||||
val profilePictureView = binding.toolbarContent.profilePictureView
|
||||
viewModel.recipient?.let(profilePictureView::update)
|
||||
binding!!.toolbarContent.bind(
|
||||
this,
|
||||
viewModel.threadId,
|
||||
recipient,
|
||||
viewModel.expirationConfiguration,
|
||||
viewModel.openGroup
|
||||
)
|
||||
maybeUpdateToolbar(recipient)
|
||||
}
|
||||
|
||||
// called from onCreate
|
||||
private fun setUpInputBar() {
|
||||
binding!!.inputBar.isVisible = viewModel.openGroup == null || viewModel.openGroup?.canWrite == true
|
||||
binding!!.inputBar.delegate = this
|
||||
binding!!.inputBarRecordingView.delegate = this
|
||||
val binding = binding ?: return
|
||||
binding.inputBar.isGone = viewModel.hidesInputBar()
|
||||
binding.inputBar.delegate = this
|
||||
binding.inputBarRecordingView.delegate = this
|
||||
// GIF button
|
||||
binding!!.gifButtonContainer.addView(gifButton)
|
||||
binding.gifButtonContainer.addView(gifButton)
|
||||
gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
|
||||
gifButton.onUp = { showGIFPicker() }
|
||||
gifButton.snIsEnabled = false
|
||||
// Document button
|
||||
binding!!.documentButtonContainer.addView(documentButton)
|
||||
binding.documentButtonContainer.addView(documentButton)
|
||||
documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
|
||||
documentButton.onUp = { showDocumentPicker() }
|
||||
documentButton.snIsEnabled = false
|
||||
// Library button
|
||||
binding!!.libraryButtonContainer.addView(libraryButton)
|
||||
binding.libraryButtonContainer.addView(libraryButton)
|
||||
libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
|
||||
libraryButton.onUp = { pickFromLibrary() }
|
||||
libraryButton.snIsEnabled = false
|
||||
// Camera button
|
||||
binding!!.cameraButtonContainer.addView(cameraButton)
|
||||
binding.cameraButtonContainer.addView(cameraButton)
|
||||
cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
|
||||
cameraButton.onUp = { showCamera() }
|
||||
cameraButton.snIsEnabled = false
|
||||
@ -682,23 +684,36 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
|
||||
private fun getLatestOpenGroupInfoIfNeeded() {
|
||||
viewModel.openGroup?.let {
|
||||
OpenGroupApi.getMemberCount(it.room, it.server).successUi { updateSubtitle() }
|
||||
val openGroup = viewModel.openGroup ?: return
|
||||
OpenGroupApi.getMemberCount(openGroup.room, openGroup.server) successUi {
|
||||
binding?.toolbarContent?.updateSubtitle(viewModel.recipient!!, openGroup, viewModel.expirationConfiguration)
|
||||
maybeUpdateToolbar(viewModel.recipient!!)
|
||||
}
|
||||
}
|
||||
|
||||
// called from onCreate
|
||||
private fun setUpBlockedBanner() {
|
||||
val recipient = viewModel.recipient ?: return
|
||||
if (recipient.isGroupRecipient) { return }
|
||||
val recipient = viewModel.recipient?.takeUnless { it.isGroupRecipient } ?: return
|
||||
val sessionID = recipient.address.toString()
|
||||
val contact = sessionContactDb.getContactWithSessionID(sessionID)
|
||||
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
|
||||
val name = sessionContactDb.getContactWithSessionID(sessionID)?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
|
||||
binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name)
|
||||
binding?.blockedBanner?.isVisible = recipient.isBlocked
|
||||
binding?.blockedBanner?.setOnClickListener { viewModel.unblock() }
|
||||
}
|
||||
|
||||
private fun setUpOutdatedClientBanner() {
|
||||
val legacyRecipient = viewModel.legacyBannerRecipient(this)
|
||||
|
||||
val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled &&
|
||||
legacyRecipient != null
|
||||
|
||||
binding?.outdatedBanner?.isVisible = shouldShowLegacy
|
||||
if (shouldShowLegacy) {
|
||||
binding?.outdatedBannerTextView?.text =
|
||||
resources.getString(R.string.activity_conversation_outdated_client_banner_text, legacyRecipient!!.name)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setUpLinkPreviewObserver() {
|
||||
if (!textSecurePreferences.isLinkPreviewsEnabled()) {
|
||||
linkPreviewViewModel.onUserCancel(); return
|
||||
@ -764,15 +779,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
|
||||
val recipient = viewModel.recipient ?: return false
|
||||
if (!isMessageRequestThread()) {
|
||||
if (!viewModel.isMessageRequestThread) {
|
||||
ConversationMenuHelper.onPrepareOptionsMenu(
|
||||
menu,
|
||||
menuInflater,
|
||||
recipient,
|
||||
viewModel.threadId,
|
||||
this
|
||||
) { onOptionsItemSelected(it) }
|
||||
)
|
||||
}
|
||||
maybeUpdateToolbar(recipient)
|
||||
return true
|
||||
}
|
||||
|
||||
@ -781,7 +796,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
tearDownRecipientObserver()
|
||||
super.onDestroy()
|
||||
binding = null
|
||||
// actionBarBinding = null
|
||||
}
|
||||
// endregion
|
||||
|
||||
@ -796,16 +810,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
setUpMessageRequestsBar()
|
||||
invalidateOptionsMenu()
|
||||
updateSubtitle()
|
||||
updateSendAfterApprovalText()
|
||||
showOrHideInputIfNeeded()
|
||||
|
||||
binding?.toolbarContent?.profilePictureView?.update(threadRecipient)
|
||||
binding?.toolbarContent?.conversationTitleView?.text = when {
|
||||
threadRecipient.isLocalNumber -> getString(R.string.note_to_self)
|
||||
else -> threadRecipient.toShortString()
|
||||
maybeUpdateToolbar(threadRecipient)
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeUpdateToolbar(recipient: Recipient) {
|
||||
binding?.toolbarContent?.update(recipient, viewModel.openGroup, viewModel.expirationConfiguration)
|
||||
}
|
||||
|
||||
private fun updateSendAfterApprovalText() {
|
||||
@ -813,14 +826,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
|
||||
private fun showOrHideInputIfNeeded() {
|
||||
val recipient = viewModel.recipient
|
||||
if (recipient != null && recipient.isClosedGroupRecipient) {
|
||||
val group = groupDb.getGroup(recipient.address.toGroupString()).orNull()
|
||||
val isActive = (group?.isActive == true)
|
||||
binding?.inputBar?.showInput = isActive
|
||||
} else {
|
||||
binding?.inputBar?.showInput = true
|
||||
}
|
||||
binding?.inputBar?.showInput = viewModel.recipient?.takeIf { it.isClosedGroupRecipient }
|
||||
?.run { address.toGroupString().let(groupDb::getGroup).orNull()?.isActive == true }
|
||||
?: true
|
||||
}
|
||||
|
||||
private fun setUpMessageRequestsBar() {
|
||||
@ -850,26 +858,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
}
|
||||
}
|
||||
|
||||
private fun isMessageRequestThread(): Boolean {
|
||||
val recipient = viewModel.recipient ?: return false
|
||||
return !recipient.isGroupRecipient && !recipient.isApproved
|
||||
}
|
||||
private fun isOutgoingMessageRequestThread(): Boolean = viewModel.recipient?.run {
|
||||
!isGroupRecipient && !isLocalNumber &&
|
||||
!(hasApprovedMe() || viewModel.hasReceived())
|
||||
} ?: false
|
||||
|
||||
private fun isOutgoingMessageRequestThread(): Boolean {
|
||||
val recipient = viewModel.recipient ?: return false
|
||||
return !recipient.isGroupRecipient &&
|
||||
!recipient.isLocalNumber &&
|
||||
!(recipient.hasApprovedMe() || viewModel.hasReceived())
|
||||
}
|
||||
|
||||
private fun isIncomingMessageRequestThread(): Boolean {
|
||||
val recipient = viewModel.recipient ?: return false
|
||||
return !recipient.isGroupRecipient &&
|
||||
!recipient.isApproved &&
|
||||
!recipient.isLocalNumber &&
|
||||
!threadDb.getLastSeenAndHasSent(viewModel.threadId).second() &&
|
||||
threadDb.getMessageCount(viewModel.threadId) > 0
|
||||
}
|
||||
private fun isIncomingMessageRequestThread(): Boolean = viewModel.recipient?.run {
|
||||
!isGroupRecipient && !isApproved && !isLocalNumber &&
|
||||
!threadDb.getLastSeenAndHasSent(viewModel.threadId).second() && threadDb.getMessageCount(viewModel.threadId) > 0
|
||||
} ?: false
|
||||
|
||||
override fun inputBarEditTextContentChanged(newContent: CharSequence) {
|
||||
val inputBarText = binding?.inputBar?.text ?: return // TODO check if we should be referencing newContent here instead
|
||||
@ -1049,16 +1046,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
val maybeTargetVisiblePosition = if (reverseMessageList) layoutManager?.findFirstVisibleItemPosition() else layoutManager?.findLastVisibleItemPosition()
|
||||
val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView.NO_POSITION
|
||||
if (!firstLoad.get() && targetVisiblePosition != RecyclerView.NO_POSITION) {
|
||||
val visibleItemTimestamp = adapter.getTimestampForItemAt(targetVisiblePosition)
|
||||
if (visibleItemTimestamp != null) {
|
||||
bufferedLastSeenChannel.trySend(visibleItemTimestamp)
|
||||
adapter.getTimestampForItemAt(targetVisiblePosition)?.let { visibleItemTimestamp ->
|
||||
bufferedLastSeenChannel.trySend(visibleItemTimestamp).apply {
|
||||
if (isFailure) Log.e(TAG, "trySend failed", exceptionOrNull())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reverseMessageList) {
|
||||
unreadCount = min(unreadCount, targetVisiblePosition).coerceAtLeast(0)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
val layoutUnreadCount = layoutManager?.let { (it.itemCount - 1) - it.findLastVisibleItemPosition() }
|
||||
?: RecyclerView.NO_POSITION
|
||||
unreadCount = min(unreadCount, layoutUnreadCount).coerceAtLeast(0)
|
||||
@ -1069,11 +1066,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
private fun updatePlaceholder() {
|
||||
val recipient = viewModel.recipient
|
||||
?: return Log.w("Loki", "recipient was null in placeholder update")
|
||||
val blindedRecipient = viewModel.blindedRecipient
|
||||
val binding = binding ?: return
|
||||
val openGroup = viewModel.openGroup
|
||||
val (textResource, insertParam) = when {
|
||||
recipient.isLocalNumber -> R.string.activity_conversation_empty_state_note_to_self to null
|
||||
openGroup != null && !openGroup.canWrite -> R.string.activity_conversation_empty_state_read_only to recipient.toShortString()
|
||||
blindedRecipient?.blocksCommunityMessageRequests == true -> R.string.activity_conversation_empty_state_blocks_community_requests to recipient.toShortString()
|
||||
else -> R.string.activity_conversation_empty_state_default to recipient.toShortString()
|
||||
}
|
||||
val showPlaceholder = adapter.itemCount == 0
|
||||
@ -1110,33 +1109,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
binding.unreadCountIndicator.isVisible = (unreadCount != 0)
|
||||
}
|
||||
|
||||
private fun updateSubtitle() {
|
||||
val actionBarBinding = binding?.toolbarContent ?: return
|
||||
val recipient = viewModel.recipient ?: return
|
||||
actionBarBinding.muteIconImageView.isVisible = recipient.isMuted
|
||||
actionBarBinding.conversationSubtitleView.isVisible = true
|
||||
if (recipient.isMuted) {
|
||||
if (recipient.mutedUntil != Long.MAX_VALUE) {
|
||||
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_until_date, DateUtils.getFormattedDateTime(recipient.mutedUntil, "EEE, MMM d, yyyy HH:mm", Locale.getDefault()))
|
||||
} else {
|
||||
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_forever)
|
||||
}
|
||||
} else if (recipient.isGroupRecipient) {
|
||||
viewModel.openGroup?.let { openGroup ->
|
||||
val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0
|
||||
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_active_member_count, userCount)
|
||||
} ?: run {
|
||||
val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size
|
||||
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount)
|
||||
}
|
||||
viewModel
|
||||
} else {
|
||||
actionBarBinding.conversationSubtitleView.isVisible = false
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Interaction
|
||||
override fun onDisappearingMessagesClicked() {
|
||||
viewModel.recipient?.let { showDisappearingMessages(it) }
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == android.R.id.home) {
|
||||
return false
|
||||
@ -1180,20 +1159,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun showExpiringMessagesDialog(thread: Recipient) {
|
||||
override fun showDisappearingMessages(thread: Recipient) {
|
||||
if (thread.isClosedGroupRecipient) {
|
||||
val group = groupDb.getGroup(thread.address.toGroupString()).orNull()
|
||||
if (group?.isActive == false) { return }
|
||||
}
|
||||
showExpirationDialog(thread.expireMessages) { expirationTime ->
|
||||
storage.setExpirationTimer(thread.address.serialize(), expirationTime)
|
||||
val message = ExpirationTimerUpdate(expirationTime)
|
||||
message.recipient = thread.address.serialize()
|
||||
message.sentTimestamp = SnodeAPI.nowWithOffset
|
||||
ApplicationContext.getInstance(this).expiringMessageManager.setExpirationTimer(message)
|
||||
MessageSender.send(message, thread.address)
|
||||
invalidateOptionsMenu()
|
||||
groupDb.getGroup(thread.address.toGroupString()).orNull()?.run { if (!isActive) return }
|
||||
}
|
||||
Intent(this, DisappearingMessagesActivity::class.java)
|
||||
.apply { putExtra(DisappearingMessagesActivity.THREAD_ID, viewModel.threadId) }
|
||||
.also { show(it, true) }
|
||||
}
|
||||
|
||||
override fun unblock() {
|
||||
@ -1590,10 +1562,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
return null
|
||||
}
|
||||
// Create the message
|
||||
val message = VisibleMessage()
|
||||
val message = VisibleMessage().applyExpiryMode(viewModel.threadId)
|
||||
message.sentTimestamp = sentTimestamp
|
||||
message.text = text
|
||||
val outgoingTextMessage = OutgoingTextMessage.from(message, recipient)
|
||||
val expiresInMillis = viewModel.expirationConfiguration?.expiryMode?.expiryMillis ?: 0
|
||||
val expireStartedAt = if (viewModel.expirationConfiguration?.expiryMode is ExpiryMode.AfterSend) {
|
||||
message.sentTimestamp!!
|
||||
} else 0
|
||||
val outgoingTextMessage = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt)
|
||||
// Clear the input bar
|
||||
binding?.inputBar?.text = ""
|
||||
binding?.inputBar?.cancelQuoteDraft()
|
||||
@ -1611,12 +1587,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
return Pair(recipient.address, sentTimestamp)
|
||||
}
|
||||
|
||||
private fun sendAttachments(attachments: List<Attachment>, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null): Pair<Address, Long>? {
|
||||
private fun sendAttachments(
|
||||
attachments: List<Attachment>,
|
||||
body: String?,
|
||||
quotedMessage: MessageRecord? = binding?.inputBar?.quote,
|
||||
linkPreview: LinkPreview? = null
|
||||
): Pair<Address, Long>? {
|
||||
val recipient = viewModel.recipient ?: return null
|
||||
val sentTimestamp = SnodeAPI.nowWithOffset
|
||||
processMessageRequestApproval()
|
||||
// Create the message
|
||||
val message = VisibleMessage()
|
||||
val message = VisibleMessage().applyExpiryMode(viewModel.threadId)
|
||||
message.sentTimestamp = sentTimestamp
|
||||
message.text = body
|
||||
val quote = quotedMessage?.let {
|
||||
@ -1632,7 +1613,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
else it.individualRecipient.address
|
||||
quote?.copy(author = sender)
|
||||
}
|
||||
val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, localQuote, linkPreview)
|
||||
val expiresInMs = viewModel.expirationConfiguration?.expiryMode?.expiryMillis ?: 0
|
||||
val expireStartedAtMs = if (viewModel.expirationConfiguration?.expiryMode is ExpiryMode.AfterSend) {
|
||||
sentTimestamp
|
||||
} else 0
|
||||
val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, localQuote, linkPreview, expiresInMs, expireStartedAtMs)
|
||||
// Clear the input bar
|
||||
binding?.inputBar?.text = ""
|
||||
binding?.inputBar?.cancelQuoteDraft()
|
||||
@ -1697,6 +1682,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, intent)
|
||||
val mediaPreppedListener = object : ListenableFuture.Listener<Boolean> {
|
||||
@ -1816,7 +1802,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
override fun deleteMessages(messages: Set<MessageRecord>) {
|
||||
val recipient = viewModel.recipient ?: return
|
||||
val allSentByCurrentUser = messages.all { it.isOutgoing }
|
||||
val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id) != null }
|
||||
val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id, it.isMms) != null }
|
||||
if (recipient.isOpenGroupRecipient) {
|
||||
val messageCount = 1
|
||||
|
||||
@ -1949,6 +1935,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
|
||||
|
||||
override fun saveAttachment(messages: Set<MessageRecord>) {
|
||||
val message = messages.first() as MmsMessageRecord
|
||||
|
||||
// Do not allow the user to download a file attachment before it has finished downloading
|
||||
// TODO: Localise the msg in this toast!
|
||||
if (message.isMediaPending) {
|
||||
Toast.makeText(this, resources.getString(R.string.conversation_activity__wait_until_attachment_has_finished_downloading), Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
|
||||
SaveAttachmentTask.showWarningDialog(this) {
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
|
@ -1,902 +0,0 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorSet;
|
||||
import android.animation.ObjectAnimator;
|
||||
import android.animation.ValueAnimator;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.PointF;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.HapticFeedbackConstants;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.Window;
|
||||
import android.view.animation.DecelerateInterpolator;
|
||||
import android.view.animation.Interpolator;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.view.ViewKt;
|
||||
import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.session.libsession.messaging.open_groups.OpenGroup;
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
import org.session.libsession.utilities.ThemeUtil;
|
||||
import org.session.libsession.utilities.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
|
||||
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel;
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem;
|
||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
import org.thoughtcrime.securesms.util.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import kotlin.Unit;
|
||||
import network.loki.messenger.R;
|
||||
|
||||
public final class ConversationReactionOverlay extends FrameLayout {
|
||||
|
||||
public static final float LONG_PRESS_SCALE_FACTOR = 0.95f;
|
||||
private static final Interpolator INTERPOLATOR = new DecelerateInterpolator();
|
||||
|
||||
private final Rect emojiViewGlobalRect = new Rect();
|
||||
private final Rect emojiStripViewBounds = new Rect();
|
||||
private float segmentSize;
|
||||
|
||||
private final Boundary horizontalEmojiBoundary = new Boundary();
|
||||
private final Boundary verticalScrubBoundary = new Boundary();
|
||||
private final PointF deadzoneTouchPoint = new PointF();
|
||||
|
||||
private Activity activity;
|
||||
private MessageRecord messageRecord;
|
||||
private SelectedConversationModel selectedConversationModel;
|
||||
private String blindedPublicKey;
|
||||
private OverlayState overlayState = OverlayState.HIDDEN;
|
||||
private RecentEmojiPageModel recentEmojiPageModel;
|
||||
|
||||
private boolean downIsOurs;
|
||||
private int selected = -1;
|
||||
private int customEmojiIndex;
|
||||
private int originalStatusBarColor;
|
||||
private int originalNavigationBarColor;
|
||||
|
||||
private View dropdownAnchor;
|
||||
private LinearLayout conversationItem;
|
||||
private View conversationBubble;
|
||||
private TextView conversationTimestamp;
|
||||
private View backgroundView;
|
||||
private ConstraintLayout foregroundView;
|
||||
private EmojiImageView[] emojiViews;
|
||||
|
||||
private ConversationContextMenu contextMenu;
|
||||
|
||||
private float touchDownDeadZoneSize;
|
||||
private float distanceFromTouchDownPointToBottomOfScrubberDeadZone;
|
||||
private int scrubberWidth;
|
||||
private int selectedVerticalTranslation;
|
||||
private int scrubberHorizontalMargin;
|
||||
private int animationEmojiStartDelayFactor;
|
||||
private int statusBarHeight;
|
||||
|
||||
private OnReactionSelectedListener onReactionSelectedListener;
|
||||
private OnActionSelectedListener onActionSelectedListener;
|
||||
private OnHideListener onHideListener;
|
||||
|
||||
private AnimatorSet revealAnimatorSet = new AnimatorSet();
|
||||
private AnimatorSet hideAnimatorSet = new AnimatorSet();
|
||||
|
||||
public ConversationReactionOverlay(@NonNull Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public ConversationReactionOverlay(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onFinishInflate() {
|
||||
super.onFinishInflate();
|
||||
|
||||
dropdownAnchor = findViewById(R.id.dropdown_anchor);
|
||||
conversationItem = findViewById(R.id.conversation_item);
|
||||
conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble);
|
||||
conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp);
|
||||
backgroundView = findViewById(R.id.conversation_reaction_scrubber_background);
|
||||
foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground);
|
||||
|
||||
emojiViews = new EmojiImageView[] { findViewById(R.id.reaction_1),
|
||||
findViewById(R.id.reaction_2),
|
||||
findViewById(R.id.reaction_3),
|
||||
findViewById(R.id.reaction_4),
|
||||
findViewById(R.id.reaction_5),
|
||||
findViewById(R.id.reaction_6),
|
||||
findViewById(R.id.reaction_7) };
|
||||
|
||||
customEmojiIndex = emojiViews.length - 1;
|
||||
|
||||
distanceFromTouchDownPointToBottomOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom);
|
||||
|
||||
touchDownDeadZoneSize = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size);
|
||||
scrubberWidth = getResources().getDimensionPixelOffset(R.dimen.reaction_scrubber_width);
|
||||
selectedVerticalTranslation = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation);
|
||||
scrubberHorizontalMargin = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin);
|
||||
|
||||
animationEmojiStartDelayFactor = getResources().getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor);
|
||||
|
||||
initAnimators();
|
||||
}
|
||||
|
||||
public void show(@NonNull Activity activity,
|
||||
@NonNull MessageRecord messageRecord,
|
||||
@NonNull PointF lastSeenDownPoint,
|
||||
@NonNull SelectedConversationModel selectedConversationModel,
|
||||
@Nullable String blindedPublicKey)
|
||||
{
|
||||
if (overlayState != OverlayState.HIDDEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.messageRecord = messageRecord;
|
||||
this.selectedConversationModel = selectedConversationModel;
|
||||
this.blindedPublicKey = blindedPublicKey;
|
||||
overlayState = OverlayState.UNINITAILIZED;
|
||||
selected = -1;
|
||||
recentEmojiPageModel = new RecentEmojiPageModel(activity);
|
||||
|
||||
setupSelectedEmoji();
|
||||
|
||||
View statusBarBackground = activity.findViewById(android.R.id.statusBarBackground);
|
||||
statusBarHeight = statusBarBackground == null ? 0 : statusBarBackground.getHeight();
|
||||
|
||||
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
|
||||
|
||||
conversationBubble.setLayoutParams(new LinearLayout.LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight()));
|
||||
conversationBubble.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot));
|
||||
conversationTimestamp.setText(DateUtils.getDisplayFormattedTimeSpanString(getContext(), Locale.getDefault(), messageRecord.getTimestamp()));
|
||||
|
||||
updateConversationTimestamp(messageRecord);
|
||||
|
||||
boolean isMessageOnLeft = selectedConversationModel.isOutgoing() ^ ViewUtil.isLtr(this);
|
||||
|
||||
conversationItem.setScaleX(LONG_PRESS_SCALE_FACTOR);
|
||||
conversationItem.setScaleY(LONG_PRESS_SCALE_FACTOR);
|
||||
|
||||
setVisibility(View.INVISIBLE);
|
||||
|
||||
this.activity = activity;
|
||||
updateSystemUiOnShow(activity);
|
||||
|
||||
ViewKt.doOnLayout(this, v -> {
|
||||
showAfterLayout(messageRecord, lastSeenDownPoint, isMessageOnLeft);
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
private void updateConversationTimestamp(MessageRecord message) {
|
||||
if (message.isOutgoing()) conversationBubble.bringToFront();
|
||||
else conversationTimestamp.bringToFront();
|
||||
}
|
||||
|
||||
private void showAfterLayout(@NonNull MessageRecord messageRecord,
|
||||
@NonNull PointF lastSeenDownPoint,
|
||||
boolean isMessageOnLeft) {
|
||||
contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord));
|
||||
|
||||
float endX = isMessageOnLeft ? scrubberHorizontalMargin :
|
||||
selectedConversationModel.getBubbleX() - conversationItem.getWidth() + selectedConversationModel.getBubbleWidth();
|
||||
float endY = selectedConversationModel.getBubbleY() - statusBarHeight;
|
||||
conversationItem.setX(endX);
|
||||
conversationItem.setY(endY);
|
||||
|
||||
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
|
||||
boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth();
|
||||
|
||||
int overlayHeight = getHeight();
|
||||
int bubbleWidth = selectedConversationModel.getBubbleWidth();
|
||||
|
||||
float endApparentTop = endY;
|
||||
float endScale = 1f;
|
||||
|
||||
float menuPadding = DimensionUnit.DP.toPixels(12f);
|
||||
float reactionBarTopPadding = DimensionUnit.DP.toPixels(32f);
|
||||
int reactionBarHeight = backgroundView.getHeight();
|
||||
|
||||
float reactionBarBackgroundY;
|
||||
|
||||
if (isWideLayout) {
|
||||
boolean everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + conversationItemSnapshot.getHeight() < overlayHeight;
|
||||
if (everythingFitsVertically) {
|
||||
boolean reactionBarFitsAboveItem = conversationItem.getY() > reactionBarHeight + menuPadding + reactionBarTopPadding;
|
||||
|
||||
if (reactionBarFitsAboveItem) {
|
||||
reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
|
||||
} else {
|
||||
endY = reactionBarHeight + menuPadding + reactionBarTopPadding;
|
||||
reactionBarBackgroundY = reactionBarTopPadding;
|
||||
}
|
||||
} else {
|
||||
float spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding;
|
||||
|
||||
endScale = spaceAvailableForItem / conversationItem.getHeight();
|
||||
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
|
||||
endY = reactionBarHeight + menuPadding + reactionBarTopPadding - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
|
||||
reactionBarBackgroundY = reactionBarTopPadding;
|
||||
}
|
||||
} else {
|
||||
float reactionBarOffset = DimensionUnit.DP.toPixels(48);
|
||||
float spaceForReactionBar = Math.max(reactionBarHeight + reactionBarOffset, 0);
|
||||
boolean everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.getHeight() + menuPadding + spaceForReactionBar < overlayHeight;
|
||||
|
||||
if (everythingFitsVertically) {
|
||||
float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
|
||||
boolean menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight;
|
||||
|
||||
if (menuFitsBelowItem) {
|
||||
if (conversationItem.getY() < 0) {
|
||||
endY = 0;
|
||||
}
|
||||
float contextMenuTop = endY + conversationItemSnapshot.getHeight();
|
||||
reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
|
||||
|
||||
if (reactionBarBackgroundY <= reactionBarTopPadding) {
|
||||
endY = backgroundView.getHeight() + menuPadding + reactionBarTopPadding;
|
||||
}
|
||||
} else {
|
||||
endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.getHeight();
|
||||
reactionBarBackgroundY = endY - reactionBarHeight - menuPadding;
|
||||
}
|
||||
|
||||
endApparentTop = endY;
|
||||
} else if (reactionBarOffset + reactionBarHeight + contextMenu.getMaxHeight() + menuPadding < overlayHeight) {
|
||||
float spaceAvailableForItem = (float) overlayHeight - contextMenu.getMaxHeight() - menuPadding - spaceForReactionBar;
|
||||
|
||||
endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
|
||||
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
|
||||
endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
|
||||
|
||||
float contextMenuTop = endY + (conversationItemSnapshot.getHeight() * endScale);
|
||||
reactionBarBackgroundY = reactionBarTopPadding;//getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
|
||||
endApparentTop = endY + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
|
||||
} else {
|
||||
contextMenu.setHeight(contextMenu.getMaxHeight() / 2);
|
||||
|
||||
int menuHeight = contextMenu.getHeight();
|
||||
boolean fitsVertically = menuHeight + conversationItem.getHeight() + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight;
|
||||
|
||||
if (fitsVertically) {
|
||||
float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
|
||||
boolean menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + statusBarHeight;
|
||||
|
||||
if (menuFitsBelowItem) {
|
||||
reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
|
||||
|
||||
if (reactionBarBackgroundY < reactionBarTopPadding) {
|
||||
endY = reactionBarTopPadding + reactionBarHeight + menuPadding;
|
||||
reactionBarBackgroundY = reactionBarTopPadding;
|
||||
}
|
||||
} else {
|
||||
endY = overlayHeight - menuHeight - menuPadding - conversationItemSnapshot.getHeight();
|
||||
reactionBarBackgroundY = endY - reactionBarHeight - menuPadding;
|
||||
}
|
||||
endApparentTop = endY;
|
||||
} else {
|
||||
float spaceAvailableForItem = (float) overlayHeight - menuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding;
|
||||
|
||||
endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
|
||||
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
|
||||
endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale) + menuPadding + reactionBarTopPadding;
|
||||
reactionBarBackgroundY = reactionBarTopPadding;
|
||||
endApparentTop = reactionBarHeight + menuPadding + reactionBarTopPadding;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reactionBarBackgroundY = Math.max(reactionBarBackgroundY, -statusBarHeight);
|
||||
|
||||
hideAnimatorSet.end();
|
||||
setVisibility(View.VISIBLE);
|
||||
|
||||
float scrubberX;
|
||||
if (isMessageOnLeft) {
|
||||
scrubberX = scrubberHorizontalMargin;
|
||||
} else {
|
||||
scrubberX = getWidth() - scrubberWidth - scrubberHorizontalMargin;
|
||||
}
|
||||
|
||||
foregroundView.setX(scrubberX);
|
||||
foregroundView.setY(reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.getHeight() / 2f);
|
||||
|
||||
backgroundView.setX(scrubberX);
|
||||
backgroundView.setY(reactionBarBackgroundY);
|
||||
|
||||
verticalScrubBoundary.update(reactionBarBackgroundY,
|
||||
lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone);
|
||||
|
||||
updateBoundsOnLayoutChanged();
|
||||
|
||||
revealAnimatorSet.start();
|
||||
|
||||
if (isWideLayout) {
|
||||
float scrubberRight = scrubberX + scrubberWidth;
|
||||
float offsetX = isMessageOnLeft ? scrubberRight + menuPadding : scrubberX - contextMenu.getMaxWidth() - menuPadding;
|
||||
contextMenu.show((int) offsetX, (int) Math.min(backgroundView.getY(), overlayHeight - contextMenu.getMaxHeight()));
|
||||
} else {
|
||||
float contentX = isMessageOnLeft ? scrubberHorizontalMargin : selectedConversationModel.getBubbleX();
|
||||
float offsetX = isMessageOnLeft ? contentX : -contextMenu.getMaxWidth() + contentX + bubbleWidth;
|
||||
|
||||
float menuTop = endApparentTop + (conversationItemSnapshot.getHeight() * endScale);
|
||||
contextMenu.show((int) offsetX, (int) (menuTop + menuPadding));
|
||||
}
|
||||
|
||||
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
|
||||
|
||||
conversationBubble.animate()
|
||||
.scaleX(endScale)
|
||||
.scaleY(endScale)
|
||||
.setDuration(revealDuration);
|
||||
|
||||
conversationItem.animate()
|
||||
.x(endX)
|
||||
.y(endY)
|
||||
.setDuration(revealDuration);
|
||||
}
|
||||
|
||||
private float getReactionBarOffsetForTouch(float itemY,
|
||||
float contextMenuTop,
|
||||
float contextMenuPadding,
|
||||
float reactionBarOffset,
|
||||
int reactionBarHeight,
|
||||
float spaceNeededBetweenTopOfScreenAndTopOfReactionBar,
|
||||
float messageTop)
|
||||
{
|
||||
float adjustedTouchY = itemY - statusBarHeight;
|
||||
float reactionStartingPoint = Math.min(adjustedTouchY, contextMenuTop);
|
||||
|
||||
float spaceBetweenTopOfMessageAndTopOfContextMenu = Math.abs(messageTop - contextMenuTop);
|
||||
|
||||
if (spaceBetweenTopOfMessageAndTopOfContextMenu < DimensionUnit.DP.toPixels(150)) {
|
||||
float offsetToMakeReactionBarOffsetMatchMenuPadding = reactionBarOffset - contextMenuPadding;
|
||||
reactionStartingPoint = messageTop + offsetToMakeReactionBarOffsetMatchMenuPadding;
|
||||
}
|
||||
|
||||
return Math.max(reactionStartingPoint - reactionBarOffset - reactionBarHeight, spaceNeededBetweenTopOfScreenAndTopOfReactionBar);
|
||||
}
|
||||
|
||||
private void updateSystemUiOnShow(@NonNull Activity activity) {
|
||||
Window window = activity.getWindow();
|
||||
int barColor = ContextCompat.getColor(getContext(), R.color.reactions_screen_dark_shade_color);
|
||||
|
||||
originalStatusBarColor = window.getStatusBarColor();
|
||||
WindowUtil.setStatusBarColor(window, barColor);
|
||||
|
||||
originalNavigationBarColor = window.getNavigationBarColor();
|
||||
WindowUtil.setNavigationBarColor(window, barColor);
|
||||
|
||||
if (!ThemeUtil.isDarkTheme(getContext())) {
|
||||
WindowUtil.clearLightStatusBar(window);
|
||||
WindowUtil.clearLightNavigationBar(window);
|
||||
}
|
||||
}
|
||||
|
||||
public void hide() {
|
||||
hideInternal(onHideListener);
|
||||
}
|
||||
|
||||
public void hideForReactWithAny() {
|
||||
hideInternal(onHideListener);
|
||||
}
|
||||
|
||||
private void hideInternal(@Nullable OnHideListener onHideListener) {
|
||||
overlayState = OverlayState.HIDDEN;
|
||||
|
||||
AnimatorSet animatorSet = newHideAnimatorSet();
|
||||
hideAnimatorSet = animatorSet;
|
||||
|
||||
revealAnimatorSet.end();
|
||||
animatorSet.start();
|
||||
|
||||
if (onHideListener != null) {
|
||||
onHideListener.startHide();
|
||||
}
|
||||
|
||||
if (selectedConversationModel.getFocusedView() != null) {
|
||||
ViewUtil.focusAndShowKeyboard(selectedConversationModel.getFocusedView());
|
||||
}
|
||||
|
||||
animatorSet.addListener(new AnimationCompleteListener() {
|
||||
@Override public void onAnimationEnd(Animator animation) {
|
||||
animatorSet.removeListener(this);
|
||||
|
||||
if (onHideListener != null) {
|
||||
onHideListener.onHide();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (contextMenu != null) {
|
||||
contextMenu.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isShowing() {
|
||||
return overlayState != OverlayState.HIDDEN;
|
||||
}
|
||||
|
||||
public @NonNull MessageRecord getMessageRecord() {
|
||||
return messageRecord;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onLayout(boolean changed, int l, int t, int r, int b) {
|
||||
super.onLayout(changed, l, t, r, b);
|
||||
|
||||
updateBoundsOnLayoutChanged();
|
||||
}
|
||||
|
||||
private void updateBoundsOnLayoutChanged() {
|
||||
backgroundView.getGlobalVisibleRect(emojiStripViewBounds);
|
||||
emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect);
|
||||
emojiStripViewBounds.left = getStart(emojiViewGlobalRect);
|
||||
emojiViews[emojiViews.length - 1].getGlobalVisibleRect(emojiViewGlobalRect);
|
||||
emojiStripViewBounds.right = getEnd(emojiViewGlobalRect);
|
||||
|
||||
segmentSize = emojiStripViewBounds.width() / (float) emojiViews.length;
|
||||
}
|
||||
|
||||
private int getStart(@NonNull Rect rect) {
|
||||
if (ViewUtil.isLtr(this)) {
|
||||
return rect.left;
|
||||
} else {
|
||||
return rect.right;
|
||||
}
|
||||
}
|
||||
|
||||
private int getEnd(@NonNull Rect rect) {
|
||||
if (ViewUtil.isLtr(this)) {
|
||||
return rect.right;
|
||||
} else {
|
||||
return rect.left;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean applyTouchEvent(@NonNull MotionEvent motionEvent) {
|
||||
if (!isShowing()) {
|
||||
throw new IllegalStateException("Touch events should only be propagated to this method if we are displaying the scrubber.");
|
||||
}
|
||||
|
||||
if ((motionEvent.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) != 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (overlayState == OverlayState.UNINITAILIZED) {
|
||||
downIsOurs = false;
|
||||
|
||||
deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY());
|
||||
|
||||
overlayState = OverlayState.DEADZONE;
|
||||
}
|
||||
|
||||
if (overlayState == OverlayState.DEADZONE) {
|
||||
float deltaX = Math.abs(deadzoneTouchPoint.x - motionEvent.getX());
|
||||
float deltaY = Math.abs(deadzoneTouchPoint.y - motionEvent.getY());
|
||||
|
||||
if (deltaX > touchDownDeadZoneSize || deltaY > touchDownDeadZoneSize) {
|
||||
overlayState = OverlayState.SCRUB;
|
||||
} else {
|
||||
if (motionEvent.getAction() == MotionEvent.ACTION_UP) {
|
||||
overlayState = OverlayState.TAP;
|
||||
|
||||
if (downIsOurs) {
|
||||
handleUpEvent();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return MotionEvent.ACTION_MOVE == motionEvent.getAction();
|
||||
}
|
||||
}
|
||||
|
||||
switch (motionEvent.getAction()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
selected = getSelectedIndexViaDownEvent(motionEvent);
|
||||
|
||||
deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY());
|
||||
overlayState = OverlayState.DEADZONE;
|
||||
downIsOurs = true;
|
||||
return true;
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
selected = getSelectedIndexViaMoveEvent(motionEvent);
|
||||
return true;
|
||||
case MotionEvent.ACTION_UP:
|
||||
handleUpEvent();
|
||||
return downIsOurs;
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
hide();
|
||||
return downIsOurs;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void setupSelectedEmoji() {
|
||||
final List<String> emojis = recentEmojiPageModel.getEmoji();
|
||||
|
||||
for (int i = 0; i < emojiViews.length; i++) {
|
||||
final EmojiImageView view = emojiViews[i];
|
||||
|
||||
view.setScaleX(1.0f);
|
||||
view.setScaleY(1.0f);
|
||||
view.setTranslationY(0);
|
||||
|
||||
boolean isAtCustomIndex = i == customEmojiIndex;
|
||||
|
||||
if (isAtCustomIndex) {
|
||||
view.setImageDrawable(ContextCompat.getDrawable(getContext(), R.drawable.ic_baseline_add_24));
|
||||
view.setTag(null);
|
||||
} else {
|
||||
view.setImageEmoji(emojis.get(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int getSelectedIndexViaDownEvent(@NonNull MotionEvent motionEvent) {
|
||||
return getSelectedIndexViaMotionEvent(motionEvent, new Boundary(emojiStripViewBounds.top, emojiStripViewBounds.bottom));
|
||||
}
|
||||
|
||||
private int getSelectedIndexViaMoveEvent(@NonNull MotionEvent motionEvent) {
|
||||
return getSelectedIndexViaMotionEvent(motionEvent, verticalScrubBoundary);
|
||||
}
|
||||
|
||||
private int getSelectedIndexViaMotionEvent(@NonNull MotionEvent motionEvent, @NonNull Boundary boundary) {
|
||||
int selected = -1;
|
||||
|
||||
if (backgroundView.getVisibility() != View.VISIBLE) {
|
||||
return selected;
|
||||
}
|
||||
|
||||
for (int i = 0; i < emojiViews.length; i++) {
|
||||
final float emojiLeft = (segmentSize * i) + emojiStripViewBounds.left;
|
||||
horizontalEmojiBoundary.update(emojiLeft, emojiLeft + segmentSize);
|
||||
|
||||
if (horizontalEmojiBoundary.contains(motionEvent.getX()) && boundary.contains(motionEvent.getY())) {
|
||||
selected = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.selected != -1 && this.selected != selected) {
|
||||
shrinkView(emojiViews[this.selected]);
|
||||
}
|
||||
|
||||
if (this.selected != selected && selected != -1) {
|
||||
growView(emojiViews[selected]);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
private void growView(@NonNull View view) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
|
||||
view.animate()
|
||||
.scaleY(1.5f)
|
||||
.scaleX(1.5f)
|
||||
.translationY(-selectedVerticalTranslation)
|
||||
.setDuration(200)
|
||||
.setInterpolator(INTERPOLATOR)
|
||||
.start();
|
||||
}
|
||||
|
||||
private void shrinkView(@NonNull View view) {
|
||||
view.animate()
|
||||
.scaleX(1.0f)
|
||||
.scaleY(1.0f)
|
||||
.translationY(0)
|
||||
.setDuration(200)
|
||||
.setInterpolator(INTERPOLATOR)
|
||||
.start();
|
||||
}
|
||||
|
||||
private void handleUpEvent() {
|
||||
if (selected != -1 && onReactionSelectedListener != null && backgroundView.getVisibility() == View.VISIBLE) {
|
||||
if (selected == customEmojiIndex) {
|
||||
onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].getTag() != null);
|
||||
} else {
|
||||
onReactionSelectedListener.onReactionSelected(messageRecord, recentEmojiPageModel.getEmoji().get(selected));
|
||||
}
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
|
||||
public void setOnReactionSelectedListener(@Nullable OnReactionSelectedListener onReactionSelectedListener) {
|
||||
this.onReactionSelectedListener = onReactionSelectedListener;
|
||||
}
|
||||
|
||||
public void setOnActionSelectedListener(@Nullable OnActionSelectedListener onActionSelectedListener) {
|
||||
this.onActionSelectedListener = onActionSelectedListener;
|
||||
}
|
||||
|
||||
public void setOnHideListener(@Nullable OnHideListener onHideListener) {
|
||||
this.onHideListener = onHideListener;
|
||||
}
|
||||
|
||||
private @Nullable String getOldEmoji(@NonNull MessageRecord messageRecord) {
|
||||
return Stream.of(messageRecord.getReactions())
|
||||
.filter(record -> record.getAuthor().equals(TextSecurePreferences.getLocalNumber(getContext())))
|
||||
.findFirst()
|
||||
.map(ReactionRecord::getEmoji)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private @NonNull List<ActionItem> getMenuActionItems(@NonNull MessageRecord message) {
|
||||
List<ActionItem> items = new ArrayList<>();
|
||||
|
||||
// Prepare
|
||||
boolean containsControlMessage = message.isUpdate();
|
||||
boolean hasText = !message.getBody().isEmpty();
|
||||
OpenGroup openGroup = DatabaseComponent.get(getContext()).lokiThreadDatabase().getOpenGroupChat(message.getThreadId());
|
||||
Recipient recipient = DatabaseComponent.get(getContext()).threadDatabase().getRecipientForThreadId(message.getThreadId());
|
||||
if (recipient == null) return Collections.emptyList();
|
||||
|
||||
String userPublicKey = TextSecurePreferences.getLocalNumber(getContext());
|
||||
// Select message
|
||||
items.add(new ActionItem(R.attr.menu_select_icon, getContext().getResources().getString(R.string.conversation_context__menu_select), () -> handleActionItemClicked(Action.SELECT),
|
||||
getContext().getResources().getString(R.string.AccessibilityId_select)));
|
||||
// Reply
|
||||
boolean canWrite = openGroup == null || openGroup.getCanWrite();
|
||||
if (canWrite && !message.isPending() && !message.isFailed()) {
|
||||
items.add(
|
||||
new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_reply), () -> handleActionItemClicked(Action.REPLY),
|
||||
getContext().getResources().getString(R.string.AccessibilityId_reply_message))
|
||||
);
|
||||
}
|
||||
// Copy message text
|
||||
if (!containsControlMessage && hasText) {
|
||||
items.add(new ActionItem(R.attr.menu_copy_icon, getContext().getResources().getString(R.string.copy), () -> handleActionItemClicked(Action.COPY_MESSAGE)));
|
||||
}
|
||||
// Copy Session ID
|
||||
if (recipient.isGroupRecipient() && !recipient.isOpenGroupRecipient() && !message.getRecipient().getAddress().toString().equals(userPublicKey)) {
|
||||
items.add(new ActionItem(
|
||||
R.attr.menu_copy_icon, getContext().getResources().getString(R.string.activity_conversation_menu_copy_session_id), () -> handleActionItemClicked(Action.COPY_SESSION_ID))
|
||||
);
|
||||
}
|
||||
// Delete message
|
||||
if (ConversationMenuItemHelper.userCanDeleteSelectedItems(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) {
|
||||
items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.delete),
|
||||
() -> handleActionItemClicked(Action.DELETE),
|
||||
getContext().getResources().getString(R.string.AccessibilityId_delete_message)
|
||||
)
|
||||
);
|
||||
}
|
||||
// Ban user
|
||||
if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) {
|
||||
items.add(new ActionItem(R.attr.menu_block_icon, getContext().getResources().getString(R.string.conversation_context__menu_ban_user), () -> handleActionItemClicked(Action.BAN_USER)));
|
||||
}
|
||||
// Ban and delete all
|
||||
if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) {
|
||||
items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.conversation_context__menu_ban_and_delete_all), () -> handleActionItemClicked(Action.BAN_AND_DELETE_ALL)));
|
||||
}
|
||||
// Message detail
|
||||
items.add(new ActionItem(R.attr.menu_info_icon, getContext().getResources().getString(R.string.conversation_context__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO)));
|
||||
// Resend
|
||||
if (message.isFailed()) {
|
||||
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resend_message), () -> handleActionItemClicked(Action.RESEND)));
|
||||
}
|
||||
// Resync
|
||||
if (message.isSyncFailed()) {
|
||||
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resync_message), () -> handleActionItemClicked(Action.RESYNC)));
|
||||
}
|
||||
// Save media
|
||||
if (message.isMms() && ((MediaMmsMessageRecord)message).containsMediaSlide()) {
|
||||
items.add(new ActionItem(R.attr.menu_save_icon, getContext().getResources().getString(R.string.conversation_context_image__save_attachment), () -> handleActionItemClicked(Action.DOWNLOAD),
|
||||
getContext().getResources().getString(R.string.AccessibilityId_save_attachment))
|
||||
);
|
||||
}
|
||||
|
||||
backgroundView.setVisibility(View.VISIBLE);
|
||||
foregroundView.setVisibility(View.VISIBLE);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private void handleActionItemClicked(@NonNull Action action) {
|
||||
hideInternal(new OnHideListener() {
|
||||
@Override public void startHide() {
|
||||
if (onHideListener != null) {
|
||||
onHideListener.startHide();
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void onHide() {
|
||||
if (onHideListener != null) {
|
||||
onHideListener.onHide();
|
||||
}
|
||||
|
||||
if (onActionSelectedListener != null) {
|
||||
onActionSelectedListener.onActionSelected(action);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void initAnimators() {
|
||||
|
||||
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
|
||||
int revealOffset = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_offset);
|
||||
|
||||
List<Animator> reveals = Stream.of(emojiViews)
|
||||
.mapIndexed((idx, v) -> {
|
||||
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_reveal);
|
||||
anim.setTarget(v);
|
||||
anim.setStartDelay(idx * animationEmojiStartDelayFactor);
|
||||
return anim;
|
||||
})
|
||||
.toList();
|
||||
|
||||
Animator backgroundRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
|
||||
backgroundRevealAnim.setTarget(backgroundView);
|
||||
backgroundRevealAnim.setDuration(revealDuration);
|
||||
backgroundRevealAnim.setStartDelay(revealOffset);
|
||||
reveals.add(backgroundRevealAnim);
|
||||
|
||||
revealAnimatorSet.setInterpolator(INTERPOLATOR);
|
||||
revealAnimatorSet.playTogether(reveals);
|
||||
}
|
||||
|
||||
private @NonNull AnimatorSet newHideAnimatorSet() {
|
||||
AnimatorSet set = new AnimatorSet();
|
||||
|
||||
set.addListener(new AnimationCompleteListener() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
setVisibility(View.GONE);
|
||||
}
|
||||
});
|
||||
set.setInterpolator(INTERPOLATOR);
|
||||
|
||||
set.playTogether(newHideAnimators());
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
private @NonNull List<Animator> newHideAnimators() {
|
||||
int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_hide_duration);
|
||||
|
||||
List<Animator> animators = new ArrayList<>(Stream.of(emojiViews)
|
||||
.mapIndexed((idx, v) -> {
|
||||
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);
|
||||
anim.setTarget(v);
|
||||
return anim;
|
||||
})
|
||||
.toList());
|
||||
|
||||
Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
|
||||
backgroundHideAnim.setTarget(backgroundView);
|
||||
backgroundHideAnim.setDuration(duration);
|
||||
animators.add(backgroundHideAnim);
|
||||
|
||||
ObjectAnimator itemScaleXAnim = new ObjectAnimator();
|
||||
itemScaleXAnim.setProperty(View.SCALE_X);
|
||||
itemScaleXAnim.setFloatValues(1f);
|
||||
itemScaleXAnim.setTarget(conversationItem);
|
||||
itemScaleXAnim.setDuration(duration);
|
||||
animators.add(itemScaleXAnim);
|
||||
|
||||
ObjectAnimator itemScaleYAnim = new ObjectAnimator();
|
||||
itemScaleYAnim.setProperty(View.SCALE_Y);
|
||||
itemScaleYAnim.setFloatValues(1f);
|
||||
itemScaleYAnim.setTarget(conversationItem);
|
||||
itemScaleYAnim.setDuration(duration);
|
||||
animators.add(itemScaleYAnim);
|
||||
|
||||
ObjectAnimator itemXAnim = new ObjectAnimator();
|
||||
itemXAnim.setProperty(View.X);
|
||||
itemXAnim.setFloatValues(selectedConversationModel.getBubbleX());
|
||||
itemXAnim.setTarget(conversationItem);
|
||||
itemXAnim.setDuration(duration);
|
||||
animators.add(itemXAnim);
|
||||
|
||||
ObjectAnimator itemYAnim = new ObjectAnimator();
|
||||
itemYAnim.setProperty(View.Y);
|
||||
itemYAnim.setFloatValues(selectedConversationModel.getBubbleY() - statusBarHeight);
|
||||
itemYAnim.setTarget(conversationItem);
|
||||
itemYAnim.setDuration(duration);
|
||||
animators.add(itemYAnim);
|
||||
|
||||
if (activity != null) {
|
||||
ValueAnimator statusBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalStatusBarColor);
|
||||
statusBarAnim.setDuration(duration);
|
||||
statusBarAnim.addUpdateListener(animation -> {
|
||||
WindowUtil.setStatusBarColor(activity.getWindow(), (int) animation.getAnimatedValue());
|
||||
});
|
||||
animators.add(statusBarAnim);
|
||||
|
||||
ValueAnimator navigationBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalNavigationBarColor);
|
||||
navigationBarAnim.setDuration(duration);
|
||||
navigationBarAnim.addUpdateListener(animation -> {
|
||||
WindowUtil.setNavigationBarColor(activity.getWindow(), (int) animation.getAnimatedValue());
|
||||
});
|
||||
animators.add(navigationBarAnim);
|
||||
}
|
||||
|
||||
return animators;
|
||||
}
|
||||
|
||||
public interface OnHideListener {
|
||||
void startHide();
|
||||
void onHide();
|
||||
}
|
||||
|
||||
public interface OnReactionSelectedListener {
|
||||
void onReactionSelected(@NonNull MessageRecord messageRecord, String emoji);
|
||||
void onCustomReactionSelected(@NonNull MessageRecord messageRecord, boolean hasAddedCustomEmoji);
|
||||
}
|
||||
|
||||
public interface OnActionSelectedListener {
|
||||
void onActionSelected(@NonNull Action action);
|
||||
}
|
||||
|
||||
private static class Boundary {
|
||||
private float min;
|
||||
private float max;
|
||||
|
||||
Boundary() {}
|
||||
|
||||
Boundary(float min, float max) {
|
||||
update(min, max);
|
||||
}
|
||||
|
||||
private void update(float min, float max) {
|
||||
this.min = min;
|
||||
this.max = max;
|
||||
}
|
||||
|
||||
public boolean contains(float value) {
|
||||
if (min < max) {
|
||||
return this.min < value && this.max > value;
|
||||
} else {
|
||||
return this.min > value && this.max < value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum OverlayState {
|
||||
HIDDEN,
|
||||
UNINITAILIZED,
|
||||
DEADZONE,
|
||||
SCRUB,
|
||||
TAP
|
||||
}
|
||||
|
||||
public enum Action {
|
||||
REPLY,
|
||||
RESEND,
|
||||
RESYNC,
|
||||
DOWNLOAD,
|
||||
COPY_MESSAGE,
|
||||
COPY_SESSION_ID,
|
||||
VIEW_INFO,
|
||||
SELECT,
|
||||
DELETE,
|
||||
BAN_USER,
|
||||
BAN_AND_DELETE_ALL,
|
||||
}
|
||||
}
|
@ -0,0 +1,720 @@
|
||||
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.SessionContactDatabase
|
||||
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.isOpenGroupRecipient && 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) }
|
||||
}
|
@ -1,10 +1,9 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.cash.copper.flow.observeQuery
|
||||
import com.goterl.lazysodium.utils.KeyPair
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
@ -14,14 +13,15 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||
import org.session.libsession.messaging.open_groups.OpenGroup
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||
import org.session.libsession.messaging.utilities.SessionId
|
||||
import org.session.libsession.messaging.utilities.SodiumUtilities
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsignal.utilities.IdPrefix
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.database.DatabaseContentProviders
|
||||
import org.thoughtcrime.securesms.database.Storage
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.repository.ConversationRepository
|
||||
@ -30,7 +30,6 @@ import java.util.UUID
|
||||
class ConversationViewModel(
|
||||
val threadId: Long,
|
||||
val edKeyPair: KeyPair?,
|
||||
private val contentResolver: ContentResolver,
|
||||
private val repository: ConversationRepository,
|
||||
private val storage: Storage
|
||||
) : ViewModel() {
|
||||
@ -44,9 +43,21 @@ class ConversationViewModel(
|
||||
private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce {
|
||||
repository.maybeGetRecipientForThreadId(threadId)
|
||||
}
|
||||
val expirationConfiguration: ExpirationConfiguration?
|
||||
get() = storage.getExpirationConfiguration(threadId)
|
||||
|
||||
val recipient: Recipient?
|
||||
get() = _recipient.value
|
||||
|
||||
val blindedRecipient: Recipient?
|
||||
get() = _recipient.value?.let { recipient ->
|
||||
when {
|
||||
recipient.isOpenGroupOutboxRecipient -> recipient
|
||||
recipient.isOpenGroupInboxRecipient -> repository.maybeGetBlindedRecipient(recipient)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private var _openGroup: RetrieveOnce<OpenGroup> = RetrieveOnce {
|
||||
storage.getOpenGroup(threadId)
|
||||
}
|
||||
@ -62,12 +73,22 @@ class ConversationViewModel(
|
||||
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
|
||||
}
|
||||
|
||||
val isMessageRequestThread : Boolean
|
||||
get() {
|
||||
val recipient = recipient ?: return false
|
||||
return !recipient.isLocalNumber && !recipient.isGroupRecipient && !recipient.isApproved
|
||||
}
|
||||
|
||||
val canReactToMessages: Boolean
|
||||
// allow reactions if the open group is null (normal conversations) or the open group's capabilities include reactions
|
||||
get() = (openGroup == null || OpenGroupApi.Capability.REACTIONS.name.lowercase() in serverCapabilities)
|
||||
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
contentResolver.observeQuery(DatabaseContentProviders.Conversation.getUriForThread(threadId))
|
||||
.collect {
|
||||
val recipientExists = storage.getRecipientForThread(threadId) != null
|
||||
if (!recipientExists && _uiState.value.conversationExists) {
|
||||
repository.recipientUpdateFlow(threadId)
|
||||
.collect { recipient ->
|
||||
if (recipient == null && _uiState.value.conversationExists) {
|
||||
_uiState.update { it.copy(conversationExists = false) }
|
||||
}
|
||||
}
|
||||
@ -199,22 +220,28 @@ class ConversationViewModel(
|
||||
_recipient.updateTo(repository.maybeGetRecipientForThreadId(threadId))
|
||||
}
|
||||
|
||||
fun hidesInputBar(): Boolean = openGroup?.canWrite != true &&
|
||||
blindedRecipient?.blocksCommunityMessageRequests == true
|
||||
|
||||
fun legacyBannerRecipient(context: Context): Recipient? = recipient?.run {
|
||||
storage.getLastLegacyRecipient(address.serialize())?.let { Recipient.from(context, Address.fromSerialized(it), false) }
|
||||
}
|
||||
|
||||
@dagger.assisted.AssistedFactory
|
||||
interface AssistedFactory {
|
||||
fun create(threadId: Long, edKeyPair: KeyPair?, contentResolver: ContentResolver): Factory
|
||||
fun create(threadId: Long, edKeyPair: KeyPair?): Factory
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
class Factory @AssistedInject constructor(
|
||||
@Assisted private val threadId: Long,
|
||||
@Assisted private val edKeyPair: KeyPair?,
|
||||
@Assisted private val contentResolver: ContentResolver,
|
||||
private val repository: ConversationRepository,
|
||||
private val storage: Storage
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return ConversationViewModel(threadId, edKeyPair, contentResolver, repository, storage) as T
|
||||
return ConversationViewModel(threadId, edKeyPair, repository, storage) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,9 +5,11 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import network.loki.messenger.R
|
||||
@ -26,6 +28,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.mms.ImageSlide
|
||||
import org.thoughtcrime.securesms.mms.Slide
|
||||
import org.thoughtcrime.securesms.repository.ConversationRepository
|
||||
import org.thoughtcrime.securesms.ui.GetString
|
||||
import org.thoughtcrime.securesms.ui.TitledText
|
||||
import java.util.Date
|
||||
@ -38,8 +41,11 @@ class MessageDetailsViewModel @Inject constructor(
|
||||
private val lokiMessageDatabase: LokiMessageDatabase,
|
||||
private val mmsSmsDatabase: MmsSmsDatabase,
|
||||
private val threadDb: ThreadDatabase,
|
||||
private val repository: ConversationRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
private val state = MutableStateFlow(MessageDetailsState())
|
||||
val stateFlow = state.asStateFlow()
|
||||
|
||||
@ -48,6 +54,8 @@ class MessageDetailsViewModel @Inject constructor(
|
||||
|
||||
var timestamp: Long = 0L
|
||||
set(value) {
|
||||
job?.cancel()
|
||||
|
||||
field = value
|
||||
val record = mmsSmsDatabase.getMessageForTimestamp(timestamp)
|
||||
|
||||
@ -58,6 +66,12 @@ class MessageDetailsViewModel @Inject constructor(
|
||||
|
||||
val mmsRecord = record as? MmsMessageRecord
|
||||
|
||||
job = viewModelScope.launch {
|
||||
repository.changes(record.threadId)
|
||||
.filter { mmsSmsDatabase.getMessageForTimestamp(value) == null }
|
||||
.collect { event.send(Event.Finish) }
|
||||
}
|
||||
|
||||
state.value = record.run {
|
||||
val slides = mmsRecord?.slideDeck?.slides ?: emptyList()
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
@ -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
|
||||
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()
|
||||
quoteView.bind(sender, message.body, attachments,
|
||||
thread, true, message.isOpenGroupInvitation, message.threadId, false, glide)
|
||||
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()
|
||||
}
|
||||
|
||||
|
@ -4,16 +4,11 @@ import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.os.AsyncTask
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ContextThemeWrapper
|
||||
import androidx.appcompat.widget.SearchView
|
||||
@ -24,10 +19,8 @@ import androidx.core.graphics.drawable.IconCompat
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.messaging.sending_receiving.MessageSender
|
||||
import org.session.libsession.messaging.sending_receiving.leave
|
||||
import org.session.libsession.utilities.ExpirationUtil
|
||||
import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.getColorFromAttr
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsignal.utilities.guava.Optional
|
||||
import org.session.libsignal.utilities.toHexString
|
||||
@ -42,8 +35,8 @@ import org.thoughtcrime.securesms.groups.EditClosedGroupActivity
|
||||
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey
|
||||
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
|
||||
import org.thoughtcrime.securesms.service.WebRtcCallService
|
||||
import org.thoughtcrime.securesms.showSessionDialog
|
||||
import org.thoughtcrime.securesms.showMuteDialog
|
||||
import org.thoughtcrime.securesms.showSessionDialog
|
||||
import org.thoughtcrime.securesms.util.BitmapUtil
|
||||
import java.io.IOException
|
||||
|
||||
@ -53,9 +46,7 @@ object ConversationMenuHelper {
|
||||
menu: Menu,
|
||||
inflater: MenuInflater,
|
||||
thread: Recipient,
|
||||
threadId: Long,
|
||||
context: Context,
|
||||
onOptionsItemSelected: (MenuItem) -> Unit
|
||||
context: Context
|
||||
) {
|
||||
// Prepare
|
||||
menu.clear()
|
||||
@ -63,21 +54,8 @@ object ConversationMenuHelper {
|
||||
// Base menu (options that should always be present)
|
||||
inflater.inflate(R.menu.menu_conversation, menu)
|
||||
// Expiring messages
|
||||
if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient) && !thread.isBlocked) {
|
||||
if (thread.expireMessages > 0) {
|
||||
inflater.inflate(R.menu.menu_conversation_expiration_on, menu)
|
||||
val item = menu.findItem(R.id.menu_expiring_messages)
|
||||
item.actionView?.let { actionView ->
|
||||
val iconView = actionView.findViewById<ImageView>(R.id.menu_badge_icon)
|
||||
val badgeView = actionView.findViewById<TextView>(R.id.expiration_badge)
|
||||
@ColorInt val color = context.getColorFromAttr(android.R.attr.textColorPrimary)
|
||||
iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY)
|
||||
badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages)
|
||||
actionView.setOnClickListener { onOptionsItemSelected(item) }
|
||||
}
|
||||
} else {
|
||||
inflater.inflate(R.menu.menu_conversation_expiration_off, menu)
|
||||
}
|
||||
if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) {
|
||||
inflater.inflate(R.menu.menu_conversation_expiration, menu)
|
||||
}
|
||||
// One-on-one chat menu allows copying the session id
|
||||
if (thread.isContactRecipient) {
|
||||
@ -110,7 +88,7 @@ object ConversationMenuHelper {
|
||||
inflater.inflate(R.menu.menu_conversation_notification_settings, menu)
|
||||
}
|
||||
|
||||
if (!thread.isGroupRecipient && thread.hasApprovedMe()) {
|
||||
if (thread.showCallMenu()) {
|
||||
inflater.inflate(R.menu.menu_conversation_call, menu)
|
||||
}
|
||||
|
||||
@ -153,8 +131,7 @@ object ConversationMenuHelper {
|
||||
R.id.menu_view_all_media -> { showAllMedia(context, thread) }
|
||||
R.id.menu_search -> { search(context) }
|
||||
R.id.menu_add_shortcut -> { addShortcut(context, thread) }
|
||||
R.id.menu_expiring_messages -> { showExpiringMessagesDialog(context, thread) }
|
||||
R.id.menu_expiring_messages_off -> { showExpiringMessagesDialog(context, thread) }
|
||||
R.id.menu_expiring_messages -> { showDisappearingMessages(context, thread) }
|
||||
R.id.menu_unblock -> { unblock(context, thread) }
|
||||
R.id.menu_block -> { block(context, thread, deleteThread = false) }
|
||||
R.id.menu_block_delete -> { blockAndDelete(context, thread) }
|
||||
@ -210,6 +187,7 @@ object ConversationMenuHelper {
|
||||
private fun addShortcut(context: Context, thread: Recipient) {
|
||||
object : AsyncTask<Void?, Void?, IconCompat?>() {
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun doInBackground(vararg params: Void?): IconCompat? {
|
||||
var icon: IconCompat? = null
|
||||
val contactPhoto = thread.contactPhoto
|
||||
@ -228,6 +206,7 @@ object ConversationMenuHelper {
|
||||
return icon
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onPostExecute(icon: IconCompat?) {
|
||||
val name = Optional.fromNullable<String>(thread.name)
|
||||
.or(Optional.fromNullable<String>(thread.profileName))
|
||||
@ -244,9 +223,9 @@ object ConversationMenuHelper {
|
||||
}.execute()
|
||||
}
|
||||
|
||||
private fun showExpiringMessagesDialog(context: Context, thread: Recipient) {
|
||||
private fun showDisappearingMessages(context: Context, thread: Recipient) {
|
||||
val listener = context as? ConversationMenuListener ?: return
|
||||
listener.showExpiringMessagesDialog(thread)
|
||||
listener.showDisappearingMessages(thread)
|
||||
}
|
||||
|
||||
private fun unblock(context: Context, thread: Recipient) {
|
||||
@ -348,7 +327,7 @@ object ConversationMenuHelper {
|
||||
fun unblock()
|
||||
fun copySessionID(sessionId: String)
|
||||
fun copyOpenGroupUrl(thread: Recipient)
|
||||
fun showExpiringMessagesDialog(thread: Recipient)
|
||||
fun showDisappearingMessages(thread: Recipient)
|
||||
}
|
||||
|
||||
}
|
@ -3,50 +3,80 @@ package org.thoughtcrime.securesms.conversation.v2.messages
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import network.loki.messenger.R
|
||||
import network.loki.messenger.databinding.ViewControlMessageBinding
|
||||
import network.loki.messenger.libsession_util.util.ExpiryMode
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages
|
||||
import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ControlMessageView : LinearLayout {
|
||||
|
||||
private 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.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(
|
||||
binding.iconImageView.apply {
|
||||
setImageDrawable(
|
||||
ResourcesCompat.getDrawable(resources, R.drawable.ic_file_download_white_36dp, context.theme)
|
||||
)
|
||||
binding.iconImageView.visibility = View.VISIBLE
|
||||
isVisible = true
|
||||
}
|
||||
}
|
||||
message.isMessageRequestResponse -> {
|
||||
messageBody = context.getString(R.string.message_requests_accepted)
|
||||
binding.textView.text = context.getString(R.string.message_requests_accepted)
|
||||
binding.root.contentDescription=context.getString(R.string.AccessibilityId_message_request_config_message)
|
||||
}
|
||||
message.isCallLog -> {
|
||||
@ -56,16 +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
|
||||
}
|
@ -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
|
||||
|
||||
}
|
@ -27,6 +27,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||
import org.session.libsession.utilities.ThemeUtil
|
||||
import org.session.libsession.utilities.getColorFromAttr
|
||||
import org.session.libsession.utilities.modifyLayoutParams
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
||||
import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet
|
||||
@ -198,9 +199,9 @@ class VisibleMessageContentView : ConstraintLayout {
|
||||
isStart = isStartOfMessageCluster,
|
||||
isEnd = isEndOfMessageCluster
|
||||
)
|
||||
val layoutParams = binding.albumThumbnailView.root.layoutParams as ConstraintLayout.LayoutParams
|
||||
layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
|
||||
binding.albumThumbnailView.root.layoutParams = layoutParams
|
||||
binding.albumThumbnailView.root.modifyLayoutParams<ConstraintLayout.LayoutParams> {
|
||||
horizontalBias = if (message.isOutgoing) 1f else 0f
|
||||
}
|
||||
onContentClick.add { event ->
|
||||
binding.albumThumbnailView.root.calculateHitObject(event, message, thread, onAttachmentNeedsDownload)
|
||||
}
|
||||
@ -233,9 +234,9 @@ class VisibleMessageContentView : ConstraintLayout {
|
||||
}
|
||||
}
|
||||
}
|
||||
val layoutParams = binding.contentParent.layoutParams as ConstraintLayout.LayoutParams
|
||||
layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
|
||||
binding.contentParent.layoutParams = layoutParams
|
||||
binding.contentParent.modifyLayoutParams<ConstraintLayout.LayoutParams> {
|
||||
horizontalBias = if (message.isOutgoing) 1f else 0f
|
||||
}
|
||||
}
|
||||
|
||||
private val onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf()
|
||||
@ -306,16 +307,9 @@ class VisibleMessageContentView : ConstraintLayout {
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun getTextColor(context: Context, message: MessageRecord): Int {
|
||||
val colorAttribute = if (message.isOutgoing) {
|
||||
// sent
|
||||
R.attr.message_sent_text_color
|
||||
} else {
|
||||
// received
|
||||
R.attr.message_received_text_color
|
||||
}
|
||||
return context.getColorFromAttr(colorAttribute)
|
||||
}
|
||||
fun getTextColor(context: Context, message: MessageRecord): Int = context.getColorFromAttr(
|
||||
if (message.isOutgoing) R.attr.message_sent_text_color else R.attr.message_received_text_color
|
||||
)
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2.messages
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Canvas
|
||||
@ -21,7 +22,7 @@ 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
|
||||
@ -30,13 +31,11 @@ import network.loki.messenger.databinding.ViewVisibleMessageBinding
|
||||
import org.session.libsession.messaging.contacts.Contact
|
||||
import org.session.libsession.messaging.contacts.Contact.ContactContext
|
||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.ViewUtil
|
||||
import org.session.libsession.utilities.getColorFromAttr
|
||||
import org.session.libsession.utilities.modifyLayoutParams
|
||||
import org.session.libsignal.utilities.IdPrefix
|
||||
import org.session.libsignal.utilities.ThreadUtils
|
||||
import org.thoughtcrime.securesms.ApplicationContext
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase
|
||||
import org.thoughtcrime.securesms.database.LokiThreadDatabase
|
||||
@ -61,9 +60,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
|
||||
@ -138,8 +138,7 @@ class VisibleMessageView : LinearLayout {
|
||||
val isGroupThread = thread.isGroupRecipient
|
||||
val isStartOfMessageCluster = isStartOfMessageCluster(message, previous, isGroupThread)
|
||||
val isEndOfMessageCluster = isEndOfMessageCluster(message, next, isGroupThread)
|
||||
// Show profile picture and sender name if this is a group thread AND
|
||||
// the message is incoming
|
||||
// Show profile picture and sender name if this is a group thread AND the message is incoming
|
||||
binding.moderatorIconImageView.isVisible = false
|
||||
binding.profilePictureView.visibility = when {
|
||||
thread.isGroupRecipient && !message.isOutgoing && isEndOfMessageCluster -> View.VISIBLE
|
||||
@ -203,43 +202,7 @@ class VisibleMessageView : LinearLayout {
|
||||
binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null
|
||||
binding.dateBreakTextView.isVisible = showDateBreak
|
||||
// Message status indicator
|
||||
if (message.isOutgoing) {
|
||||
val (iconID, iconColor, textId, contentDescription) = getMessageStatusImage(message)
|
||||
if (textId != null) {
|
||||
binding.messageStatusTextView.setText(textId)
|
||||
|
||||
if (iconColor != null) {
|
||||
binding.messageStatusTextView.setTextColor(iconColor)
|
||||
}
|
||||
}
|
||||
if (iconID != null) {
|
||||
val drawable = ContextCompat.getDrawable(context, iconID)?.mutate()
|
||||
if (iconColor != null) {
|
||||
drawable?.setTint(iconColor)
|
||||
}
|
||||
binding.messageStatusImageView.setImageDrawable(drawable)
|
||||
}
|
||||
binding.messageStatusImageView.contentDescription = contentDescription
|
||||
|
||||
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)
|
||||
showStatusMessage(message)
|
||||
// Emoji Reactions
|
||||
val emojiLayoutParams = binding.emojiReactionsView.root.layoutParams as ConstraintLayout.LayoutParams
|
||||
emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
|
||||
@ -274,122 +237,106 @@ 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 disappearing = 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 || disappearing) {
|
||||
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)
|
||||
|
||||
val lastMessageID = mmsSmsDb.getLastMessageID(message.threadId)
|
||||
val isLastMessage = message.id == lastMessageID
|
||||
binding.messageStatusTextView.isVisible =
|
||||
textId != null && (!message.isSent || isLastMessage || disappearing)
|
||||
val showTimer = disappearing && !message.isPending
|
||||
binding.messageStatusImageView.isVisible =
|
||||
iconID != null && !showTimer && (!message.isSent || isLastMessage)
|
||||
|
||||
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 isEndOfMessageCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean =
|
||||
next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp) || if (isGroupThread) {
|
||||
current.recipient.address != next.recipient.address
|
||||
} else {
|
||||
current.isOutgoing != next.isOutgoing
|
||||
}
|
||||
|
||||
data class MessageStatusInfo(@DrawableRes val iconId: Int?,
|
||||
@ColorInt val iconTint: Int?,
|
||||
@StringRes val messageText: Int?,
|
||||
val contentDescription: String?)
|
||||
@StringRes val messageText: Int?)
|
||||
|
||||
private fun getMessageStatusImage(message: MessageRecord): MessageStatusInfo = when {
|
||||
message.isFailed ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_failed,
|
||||
resources.getColor(R.color.destructive, context.theme),
|
||||
R.string.delivery_status_failed,
|
||||
null
|
||||
R.string.delivery_status_failed
|
||||
)
|
||||
message.isSyncFailed ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_failed,
|
||||
context.getColor(R.color.accent_orange),
|
||||
R.string.delivery_status_sync_failed,
|
||||
null
|
||||
R.string.delivery_status_sync_failed
|
||||
)
|
||||
message.isPending ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sending,
|
||||
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending,
|
||||
context.getString(R.string.AccessibilityId_message_sent_status_pending)
|
||||
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending
|
||||
)
|
||||
message.isResyncing ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sending,
|
||||
context.getColor(R.color.accent_orange), R.string.delivery_status_syncing,
|
||||
context.getString(R.string.AccessibilityId_message_sent_status_syncing)
|
||||
context.getColor(R.color.accent_orange), R.string.delivery_status_syncing
|
||||
)
|
||||
message.isRead ->
|
||||
message.isRead || !message.isOutgoing ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_read,
|
||||
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read,
|
||||
null
|
||||
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read
|
||||
)
|
||||
else ->
|
||||
MessageStatusInfo(
|
||||
R.drawable.ic_delivery_status_sent,
|
||||
context.getColorFromAttr(R.attr.message_status_color),
|
||||
R.string.delivery_status_sent,
|
||||
context.getString(R.string.AccessibilityId_message_sent_status_tick)
|
||||
R.string.delivery_status_sent
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateExpirationTimer(message: MessageRecord) {
|
||||
val container = binding.messageInnerContainer
|
||||
val layout = binding.messageInnerLayout
|
||||
|
||||
if (message.isOutgoing) binding.messageContentView.root.bringToFront()
|
||||
else binding.expirationTimerView.bringToFront()
|
||||
|
||||
layout.layoutParams = layout.layoutParams.let { it as FrameLayout.LayoutParams }
|
||||
.apply { gravity = if (message.isOutgoing) Gravity.END else Gravity.START }
|
||||
|
||||
val containerParams = container.layoutParams as ConstraintLayout.LayoutParams
|
||||
containerParams.horizontalBias = if (message.isOutgoing) 1f else 0f
|
||||
container.layoutParams = containerParams
|
||||
if (message.expiresIn > 0 && !message.isPending) {
|
||||
binding.expirationTimerView.setColorFilter(context.getColorFromAttr(android.R.attr.textColorPrimary))
|
||||
binding.expirationTimerView.isInvisible = false
|
||||
binding.expirationTimerView.setPercentComplete(0.0f)
|
||||
if (message.expireStarted > 0) {
|
||||
if (!message.isOutgoing) binding.messageStatusTextView.bringToFront()
|
||||
binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
|
||||
binding.expirationTimerView.startAnimation()
|
||||
if (message.expireStarted + message.expiresIn <= SnodeAPI.nowWithOffset) {
|
||||
ApplicationContext.getInstance(context).expiringMessageManager.checkSchedule()
|
||||
}
|
||||
} else if (!message.isMediaPending) {
|
||||
binding.expirationTimerView.setPercentComplete(0.0f)
|
||||
binding.expirationTimerView.stopAnimation()
|
||||
ThreadUtils.queue {
|
||||
val expirationManager = ApplicationContext.getInstance(context).expiringMessageManager
|
||||
val id = message.getId()
|
||||
val mms = message.isMms
|
||||
if (mms) mmsDb.markExpireStarted(id) else smsDb.markExpireStarted(id)
|
||||
expirationManager.scheduleDeletion(id, mms, message.expiresIn)
|
||||
}
|
||||
} else {
|
||||
binding.expirationTimerView.stopAnimation()
|
||||
binding.expirationTimerView.setPercentComplete(0.0f)
|
||||
}
|
||||
} else {
|
||||
binding.expirationTimerView.isInvisible = true
|
||||
}
|
||||
container.requestLayout()
|
||||
}
|
||||
|
||||
private fun handleIsSelectedChanged() {
|
||||
background = if (snIsSelected) {
|
||||
ColorDrawable(context.getColorFromAttr(R.attr.message_selected))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
background = if (snIsSelected) ColorDrawable(context.getColorFromAttr(R.attr.message_selected)) else null
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
@ -426,6 +373,7 @@ class VisibleMessageView : LinearLayout {
|
||||
// endregion
|
||||
|
||||
// region Interaction
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (onPress == null || onSwipeToReply == null || onLongPress == null) { return false }
|
||||
when (event.action) {
|
||||
@ -526,14 +474,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() {
|
||||
|
@ -30,9 +30,7 @@ class ThumbnailProgressBar: View {
|
||||
private val objectRect = Rect()
|
||||
private val drawingRect = Rect()
|
||||
|
||||
override fun dispatchDraw(canvas: Canvas?) {
|
||||
if (canvas == null) return
|
||||
|
||||
override fun dispatchDraw(canvas: Canvas) {
|
||||
getDrawingRect(objectRect)
|
||||
drawingRect.set(objectRect)
|
||||
|
||||
|
@ -52,6 +52,7 @@ public class IdentityKeyUtil {
|
||||
public static final String IDENTITY_PRIVATE_KEY_PREF = "pref_identity_private_v3";
|
||||
public static final String ED25519_PUBLIC_KEY = "pref_ed25519_public_key";
|
||||
public static final String ED25519_SECRET_KEY = "pref_ed25519_secret_key";
|
||||
public static final String NOTIFICATION_KEY = "pref_notification_key";
|
||||
public static final String LOKI_SEED = "loki_seed";
|
||||
public static final String HAS_MIGRATED_KEY = "has_migrated_keys";
|
||||
|
||||
|
@ -0,0 +1,85 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||
import org.session.libsession.messaging.messages.ExpirationDatabaseMetadata
|
||||
import org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX
|
||||
import org.session.libsession.utilities.GroupUtil.OPEN_GROUP_INBOX_PREFIX
|
||||
import org.session.libsession.utilities.GroupUtil.OPEN_GROUP_PREFIX
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
|
||||
class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
|
||||
|
||||
companion object {
|
||||
const val TABLE_NAME = "expiration_configuration"
|
||||
const val THREAD_ID = "thread_id"
|
||||
const val UPDATED_TIMESTAMP_MS = "updated_timestamp_ms"
|
||||
|
||||
@JvmField
|
||||
val CREATE_EXPIRATION_CONFIGURATION_TABLE_COMMAND = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$THREAD_ID INTEGER NOT NULL PRIMARY KEY ON CONFLICT REPLACE,
|
||||
$UPDATED_TIMESTAMP_MS INTEGER DEFAULT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
|
||||
@JvmField
|
||||
val MIGRATE_GROUP_CONVERSATION_EXPIRY_TYPE_COMMAND = """
|
||||
INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID}
|
||||
FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME}
|
||||
WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} LIKE '$CLOSED_GROUP_PREFIX%'
|
||||
AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0)
|
||||
""".trimIndent()
|
||||
|
||||
@JvmField
|
||||
val MIGRATE_ONE_TO_ONE_CONVERSATION_EXPIRY_TYPE_COMMAND = """
|
||||
INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID}
|
||||
FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME}
|
||||
WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$CLOSED_GROUP_PREFIX%'
|
||||
AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$OPEN_GROUP_PREFIX%'
|
||||
AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$OPEN_GROUP_INBOX_PREFIX%'
|
||||
AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0)
|
||||
""".trimIndent()
|
||||
|
||||
private fun readExpirationConfiguration(cursor: Cursor): ExpirationDatabaseMetadata {
|
||||
return ExpirationDatabaseMetadata(
|
||||
threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)),
|
||||
updatedTimestampMs = cursor.getLong(cursor.getColumnIndexOrThrow(UPDATED_TIMESTAMP_MS))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getExpirationConfiguration(threadId: Long): ExpirationDatabaseMetadata? {
|
||||
val query = "$THREAD_ID = ?"
|
||||
val args = arrayOf("$threadId")
|
||||
|
||||
val configurations: MutableList<ExpirationDatabaseMetadata> = mutableListOf()
|
||||
|
||||
readableDatabase.query(TABLE_NAME, null, query, args, null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
configurations += readExpirationConfiguration(cursor)
|
||||
}
|
||||
}
|
||||
|
||||
return configurations.firstOrNull()
|
||||
}
|
||||
|
||||
fun setExpirationConfiguration(configuration: ExpirationConfiguration) {
|
||||
writableDatabase.beginTransaction()
|
||||
try {
|
||||
val values = ContentValues().apply {
|
||||
put(THREAD_ID, configuration.threadId)
|
||||
put(UPDATED_TIMESTAMP_MS, configuration.updatedTimestampMs)
|
||||
}
|
||||
|
||||
writableDatabase.insert(TABLE_NAME, null, values)
|
||||
writableDatabase.setTransactionSuccessful()
|
||||
notifyConversationListeners(configuration.threadId)
|
||||
} finally {
|
||||
writableDatabase.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
data class ExpirationInfo(
|
||||
val id: Long,
|
||||
val timestamp: Long,
|
||||
val expiresIn: Long,
|
||||
val expireStarted: Long,
|
||||
val isMms: Boolean
|
||||
) {
|
||||
private fun isDisappearAfterSend() = timestamp == expireStarted
|
||||
fun isDisappearAfterRead() = expiresIn > 0 && !isDisappearAfterSend()
|
||||
}
|
@ -1,110 +0,0 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public abstract class FastCursorRecyclerViewAdapter<VH extends RecyclerView.ViewHolder, T>
|
||||
extends CursorRecyclerViewAdapter<VH>
|
||||
{
|
||||
private static final String TAG = FastCursorRecyclerViewAdapter.class.getSimpleName();
|
||||
|
||||
private final LinkedList<T> fastRecords = new LinkedList<>();
|
||||
private final List<Long> releasedRecordIds = new LinkedList<>();
|
||||
|
||||
protected FastCursorRecyclerViewAdapter(Context context, Cursor cursor) {
|
||||
super(context, cursor);
|
||||
}
|
||||
|
||||
public void addFastRecord(@NonNull T record) {
|
||||
fastRecords.addFirst(record);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void releaseFastRecord(long id) {
|
||||
synchronized (releasedRecordIds) {
|
||||
releasedRecordIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
protected void cleanFastRecords() {
|
||||
synchronized (releasedRecordIds) {
|
||||
Iterator<Long> releaseIdIterator = releasedRecordIds.iterator();
|
||||
|
||||
while (releaseIdIterator.hasNext()) {
|
||||
long releasedId = releaseIdIterator.next();
|
||||
Iterator<T> fastRecordIterator = fastRecords.iterator();
|
||||
|
||||
while (fastRecordIterator.hasNext()) {
|
||||
if (isRecordForId(fastRecordIterator.next(), releasedId)) {
|
||||
fastRecordIterator.remove();
|
||||
releaseIdIterator.remove();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract T getRecordFromCursor(@NonNull Cursor cursor);
|
||||
protected abstract void onBindItemViewHolder(VH viewHolder, @NonNull T record);
|
||||
protected abstract long getItemId(@NonNull T record);
|
||||
protected abstract int getItemViewType(@NonNull T record);
|
||||
protected abstract boolean isRecordForId(@NonNull T record, long id);
|
||||
|
||||
@Override
|
||||
public int getItemViewType(@NonNull Cursor cursor) {
|
||||
T record = getRecordFromCursor(cursor);
|
||||
return getItemViewType(record);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindItemViewHolder(VH viewHolder, @NonNull Cursor cursor) {
|
||||
T record = getRecordFromCursor(cursor);
|
||||
onBindItemViewHolder(viewHolder, record);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindFastAccessItemViewHolder(VH viewHolder, int position) {
|
||||
int calculatedPosition = getCalculatedPosition(position);
|
||||
onBindItemViewHolder(viewHolder, fastRecords.get(calculatedPosition));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getFastAccessSize() {
|
||||
return fastRecords.size();
|
||||
}
|
||||
|
||||
protected T getRecordForPositionOrThrow(int position) {
|
||||
if (isFastAccessPosition(position)) {
|
||||
return fastRecords.get(getCalculatedPosition(position));
|
||||
} else {
|
||||
Cursor cursor = getCursorAtPositionOrThrow(position);
|
||||
return getRecordFromCursor(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
protected int getFastAccessItemViewType(int position) {
|
||||
return getItemViewType(fastRecords.get(getCalculatedPosition(position)));
|
||||
}
|
||||
|
||||
protected boolean isFastAccessPosition(int position) {
|
||||
position = getCalculatedPosition(position);
|
||||
return position >= 0 && position < fastRecords.size();
|
||||
}
|
||||
|
||||
protected long getFastAccessItemId(int position) {
|
||||
return getItemId(fastRecords.get(getCalculatedPosition(position)));
|
||||
}
|
||||
|
||||
private int getCalculatedPosition(int position) {
|
||||
return hasHeaderView() ? position - 1 : position;
|
||||
}
|
||||
|
||||
}
|
@ -97,6 +97,16 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
||||
public val groupPublicKey = "group_public_key"
|
||||
@JvmStatic
|
||||
val createClosedGroupPublicKeysTable = "CREATE TABLE $closedGroupPublicKeysTable ($groupPublicKey STRING PRIMARY KEY)"
|
||||
|
||||
private const val LAST_LEGACY_MESSAGE_TABLE = "last_legacy_messages"
|
||||
// The overall "thread recipient
|
||||
private const val LAST_LEGACY_THREAD_RECIPIENT = "last_legacy_thread_recipient"
|
||||
// The individual 'last' person who sent the message with legacy expiration attached
|
||||
private const val LAST_LEGACY_SENDER_RECIPIENT = "last_legacy_sender_recipient"
|
||||
private const val LEGACY_THREAD_RECIPIENT_QUERY = "$LAST_LEGACY_THREAD_RECIPIENT = ?"
|
||||
|
||||
const val CREATE_LAST_LEGACY_MESSAGE_TABLE = "CREATE TABLE $LAST_LEGACY_MESSAGE_TABLE ($LAST_LEGACY_THREAD_RECIPIENT STRING PRIMARY KEY, $LAST_LEGACY_SENDER_RECIPIENT STRING NOT NULL);"
|
||||
|
||||
// Hard fork service node info
|
||||
const val FORK_INFO_TABLE = "fork_info"
|
||||
const val DUMMY_KEY = "dummy_key"
|
||||
@ -415,6 +425,31 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
||||
database.endTransaction()
|
||||
}
|
||||
|
||||
override fun getLastLegacySenderAddress(threadRecipientAddress: String): String? =
|
||||
databaseHelper.readableDatabase.get(LAST_LEGACY_MESSAGE_TABLE, LEGACY_THREAD_RECIPIENT_QUERY, wrap(threadRecipientAddress)) { cursor ->
|
||||
cursor.getString(LAST_LEGACY_SENDER_RECIPIENT)
|
||||
}
|
||||
|
||||
override fun setLastLegacySenderAddress(
|
||||
threadRecipientAddress: String,
|
||||
senderRecipientAddress: String?
|
||||
) {
|
||||
val database = databaseHelper.writableDatabase
|
||||
if (senderRecipientAddress == null) {
|
||||
// delete
|
||||
database.delete(LAST_LEGACY_MESSAGE_TABLE, LEGACY_THREAD_RECIPIENT_QUERY, wrap(threadRecipientAddress))
|
||||
} else {
|
||||
// just update the value to a new one
|
||||
val values = wrap(
|
||||
mapOf(
|
||||
LAST_LEGACY_THREAD_RECIPIENT to threadRecipientAddress,
|
||||
LAST_LEGACY_SENDER_RECIPIENT to senderRecipientAddress
|
||||
)
|
||||
)
|
||||
database.insertOrUpdate(LAST_LEGACY_MESSAGE_TABLE, values, LEGACY_THREAD_RECIPIENT_QUERY, wrap(threadRecipientAddress))
|
||||
}
|
||||
}
|
||||
|
||||
fun getUserCount(room: String, server: String): Int? {
|
||||
val database = databaseHelper.readableDatabase
|
||||
val index = "$server.$room"
|
||||
|
@ -13,6 +13,8 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
|
||||
private val messageThreadMappingTable = "loki_message_thread_mapping_database"
|
||||
private val errorMessageTable = "loki_error_message_database"
|
||||
private val messageHashTable = "loki_message_hash_database"
|
||||
private val smsHashTable = "loki_sms_hash_database"
|
||||
private val mmsHashTable = "loki_mms_hash_database"
|
||||
private val messageID = "message_id"
|
||||
private val serverID = "server_id"
|
||||
private val friendRequestStatus = "friend_request_status"
|
||||
@ -32,6 +34,10 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
|
||||
val updateMessageMappingTable = "ALTER TABLE $messageThreadMappingTable ADD COLUMN $serverID INTEGER DEFAULT 0; ALTER TABLE $messageThreadMappingTable ADD CONSTRAINT PK_$messageThreadMappingTable PRIMARY KEY ($messageID, $serverID);"
|
||||
@JvmStatic
|
||||
val createMessageHashTableCommand = "CREATE TABLE IF NOT EXISTS $messageHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);"
|
||||
@JvmStatic
|
||||
val createMmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $mmsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);"
|
||||
@JvmStatic
|
||||
val createSmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $smsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);"
|
||||
|
||||
const val SMS_TYPE = 0
|
||||
const val MMS_TYPE = 1
|
||||
@ -201,52 +207,52 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
|
||||
messages.add(cursor.getLong(messageID) to cursor.getLong(serverID))
|
||||
}
|
||||
}
|
||||
var deletedCount = 0L
|
||||
database.beginTransaction()
|
||||
messages.forEach { (messageId, serverId) ->
|
||||
deletedCount += database.delete(messageIDTable, "$messageID = ? AND $serverID = ?", arrayOf(messageId.toString(), serverId.toString()))
|
||||
database.delete(messageIDTable, "$messageID = ? AND $serverID = ?", arrayOf(messageId.toString(), serverId.toString()))
|
||||
}
|
||||
val mappingDeleted = database.delete(messageThreadMappingTable, "$threadID = ?", arrayOf(threadId.toString()))
|
||||
database.delete(messageThreadMappingTable, "$threadID = ?", arrayOf(threadId.toString()))
|
||||
database.setTransactionSuccessful()
|
||||
} finally {
|
||||
database.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
fun getMessageServerHash(messageID: Long): String? {
|
||||
val database = databaseHelper.readableDatabase
|
||||
return database.get(messageHashTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor ->
|
||||
fun getMessageServerHash(messageID: Long, mms: Boolean): String? = getMessageTables(mms).firstNotNullOfOrNull {
|
||||
databaseHelper.readableDatabase.get(it, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor ->
|
||||
cursor.getString(serverHash)
|
||||
}
|
||||
}
|
||||
|
||||
fun setMessageServerHash(messageID: Long, serverHash: String) {
|
||||
val database = databaseHelper.writableDatabase
|
||||
val contentValues = ContentValues(2)
|
||||
contentValues.put(Companion.messageID, messageID)
|
||||
contentValues.put(Companion.serverHash, serverHash)
|
||||
database.insertOrUpdate(messageHashTable, contentValues, "${Companion.messageID} = ?", arrayOf(messageID.toString()))
|
||||
fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) {
|
||||
val contentValues = ContentValues(2).apply {
|
||||
put(Companion.messageID, messageID)
|
||||
put(Companion.serverHash, serverHash)
|
||||
}
|
||||
|
||||
fun deleteMessageServerHash(messageID: Long) {
|
||||
val database = databaseHelper.writableDatabase
|
||||
database.delete(messageHashTable, "${Companion.messageID} = ?", arrayOf(messageID.toString()))
|
||||
databaseHelper.writableDatabase.apply {
|
||||
insertOrUpdate(getMessageTable(mms), contentValues, "${Companion.messageID} = ?", arrayOf(messageID.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMessageServerHashes(messageIDs: List<Long>) {
|
||||
val database = databaseHelper.writableDatabase
|
||||
database.delete(
|
||||
messageHashTable,
|
||||
"${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})",
|
||||
fun deleteMessageServerHash(messageID: Long, mms: Boolean) {
|
||||
getMessageTables(mms).firstOrNull {
|
||||
databaseHelper.writableDatabase.delete(it, "${Companion.messageID} = ?", arrayOf(messageID.toString())) > 0
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMessageServerHashes(messageIDs: List<Long>, mms: Boolean) {
|
||||
databaseHelper.writableDatabase.delete(
|
||||
getMessageTable(mms),
|
||||
"${Companion.messageID} IN (${messageIDs.joinToString(",") { "?" }})",
|
||||
messageIDs.map { "$it" }.toTypedArray()
|
||||
)
|
||||
}
|
||||
|
||||
fun migrateThreadId(legacyThreadId: Long, newThreadId: Long) {
|
||||
val database = databaseHelper.writableDatabase
|
||||
val contentValues = ContentValues(1)
|
||||
contentValues.put(threadID, newThreadId)
|
||||
database.update(messageThreadMappingTable, contentValues, "$threadID = ?", arrayOf(legacyThreadId.toString()))
|
||||
}
|
||||
private fun getMessageTables(mms: Boolean) = sequenceOf(
|
||||
getMessageTable(mms),
|
||||
messageHashTable
|
||||
)
|
||||
|
||||
private fun getMessageTable(mms: Boolean) = if (mms) mmsHashTable else smsHashTable
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType
|
||||
import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId
|
||||
|
||||
data class MarkedMessageInfo(val syncMessageId: SyncMessageId, val expirationInfo: ExpirationInfo) {
|
||||
val expiryType get() = when {
|
||||
syncMessageId.timetamp == expirationInfo.expireStarted -> ExpiryType.AFTER_SEND
|
||||
expirationInfo.expiresIn > 0 -> ExpiryType.AFTER_READ
|
||||
else -> ExpiryType.NONE
|
||||
}
|
||||
|
||||
val expiryMode get() = expiryType.mode(expirationInfo.expiresIn)
|
||||
}
|
@ -14,6 +14,7 @@ import org.session.libsession.utilities.IdentityKeyMismatchList;
|
||||
import org.session.libsignal.crypto.IdentityKey;
|
||||
import org.session.libsignal.utilities.JsonUtil;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.util.SqlUtil;
|
||||
@ -33,7 +34,6 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
|
||||
|
||||
protected abstract String getTableName();
|
||||
|
||||
public abstract void markExpireStarted(long messageId);
|
||||
public abstract void markExpireStarted(long messageId, long startTime);
|
||||
|
||||
public abstract void markAsSent(long messageId, boolean secure);
|
||||
@ -225,56 +225,6 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
|
||||
}
|
||||
}
|
||||
|
||||
public static class ExpirationInfo {
|
||||
|
||||
private final long id;
|
||||
private final long expiresIn;
|
||||
private final long expireStarted;
|
||||
private final boolean mms;
|
||||
|
||||
public ExpirationInfo(long id, long expiresIn, long expireStarted, boolean mms) {
|
||||
this.id = id;
|
||||
this.expiresIn = expiresIn;
|
||||
this.expireStarted = expireStarted;
|
||||
this.mms = mms;
|
||||
}
|
||||
|
||||
public long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public long getExpiresIn() {
|
||||
return expiresIn;
|
||||
}
|
||||
|
||||
public long getExpireStarted() {
|
||||
return expireStarted;
|
||||
}
|
||||
|
||||
public boolean isMms() {
|
||||
return mms;
|
||||
}
|
||||
}
|
||||
|
||||
public static class MarkedMessageInfo {
|
||||
|
||||
private final SyncMessageId syncMessageId;
|
||||
private final ExpirationInfo expirationInfo;
|
||||
|
||||
public MarkedMessageInfo(SyncMessageId syncMessageId, ExpirationInfo expirationInfo) {
|
||||
this.syncMessageId = syncMessageId;
|
||||
this.expirationInfo = expirationInfo;
|
||||
}
|
||||
|
||||
public SyncMessageId getSyncMessageId() {
|
||||
return syncMessageId;
|
||||
}
|
||||
|
||||
public ExpirationInfo getExpirationInfo() {
|
||||
return expirationInfo;
|
||||
}
|
||||
}
|
||||
|
||||
public static class InsertResult {
|
||||
private final long messageId;
|
||||
private final long threadId;
|
||||
|
@ -19,11 +19,13 @@ package org.thoughtcrime.securesms.database
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.provider.ContactsContract.CommonDataKinds.BaseTypes
|
||||
import com.annimon.stream.Stream
|
||||
import com.google.android.mms.pdu_alt.PduHeaders
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||
import org.session.libsession.messaging.messages.signal.IncomingMediaMessage
|
||||
import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage
|
||||
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
|
||||
@ -222,6 +224,12 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
return readerFor(rawQuery(where, null))!!
|
||||
}
|
||||
|
||||
val expireNotStartedMessages: Reader
|
||||
get() {
|
||||
val where = "$EXPIRES_IN > 0 AND $EXPIRE_STARTED = 0"
|
||||
return readerFor(rawQuery(where, null))!!
|
||||
}
|
||||
|
||||
private fun updateMailboxBitmask(
|
||||
id: Long,
|
||||
maskOff: Long,
|
||||
@ -296,10 +304,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId)
|
||||
}
|
||||
|
||||
override fun markExpireStarted(messageId: Long) {
|
||||
markExpireStarted(messageId, SnodeAPI.nowWithOffset)
|
||||
}
|
||||
|
||||
override fun markExpireStarted(messageId: Long, startedTimestamp: Long) {
|
||||
val contentValues = ContentValues()
|
||||
contentValues.put(EXPIRE_STARTED, startedTimestamp)
|
||||
@ -347,13 +351,14 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
)
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
if (MmsSmsColumns.Types.isSecureType(cursor.getLong(3))) {
|
||||
val syncMessageId =
|
||||
SyncMessageId(fromSerialized(cursor.getString(1)), cursor.getLong(2))
|
||||
val timestamp = cursor.getLong(2)
|
||||
val syncMessageId = SyncMessageId(fromSerialized(cursor.getString(1)), timestamp)
|
||||
val expirationInfo = ExpirationInfo(
|
||||
cursor.getLong(0),
|
||||
cursor.getLong(4),
|
||||
cursor.getLong(5),
|
||||
true
|
||||
id = cursor.getLong(0),
|
||||
timestamp = timestamp,
|
||||
expiresIn = cursor.getLong(4),
|
||||
expireStarted = cursor.getLong(5),
|
||||
isMms = true
|
||||
)
|
||||
result.add(MarkedMessageInfo(syncMessageId, expirationInfo))
|
||||
}
|
||||
@ -383,6 +388,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT))
|
||||
val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID))
|
||||
val expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN))
|
||||
val expireStartedAt = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRE_STARTED))
|
||||
val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS))
|
||||
val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID))
|
||||
val distributionType = get(context).threadDatabase().getDistributionType(threadId)
|
||||
@ -451,6 +457,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
timestamp,
|
||||
subscriptionId,
|
||||
expiresIn,
|
||||
expireStartedAt,
|
||||
distributionType,
|
||||
quote,
|
||||
contacts,
|
||||
@ -550,6 +557,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
runThreadUpdate: Boolean
|
||||
): Optional<InsertResult> {
|
||||
if (threadId < 0 ) throw MmsException("No thread ID supplied!")
|
||||
if (retrieved.isExpirationUpdate) deleteExpirationTimerMessages(threadId, false.takeUnless { retrieved.groupId != null })
|
||||
val contentValues = ContentValues()
|
||||
contentValues.put(DATE_SENT, retrieved.sentTimeMillis)
|
||||
contentValues.put(ADDRESS, retrieved.from.serialize())
|
||||
@ -570,7 +578,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
contentValues.put(PART_COUNT, retrieved.attachments.size)
|
||||
contentValues.put(SUBSCRIPTION_ID, retrieved.subscriptionId)
|
||||
contentValues.put(EXPIRES_IN, retrieved.expiresIn)
|
||||
contentValues.put(READ, if (retrieved.isExpirationUpdate) 1 else 0)
|
||||
contentValues.put(EXPIRE_STARTED, retrieved.expireStartedAt)
|
||||
contentValues.put(UNIDENTIFIED, retrieved.isUnidentified)
|
||||
contentValues.put(HAS_MENTION, retrieved.hasMention())
|
||||
contentValues.put(MESSAGE_REQUEST_RESPONSE, retrieved.isMessageRequestResponse)
|
||||
@ -619,6 +627,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
runThreadUpdate: Boolean
|
||||
): Optional<InsertResult> {
|
||||
if (threadId < 0 ) throw MmsException("No thread ID supplied!")
|
||||
if (retrieved.isExpirationUpdate) deleteExpirationTimerMessages(threadId, true.takeUnless { retrieved.isGroup })
|
||||
val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate)
|
||||
if (messageId == -1L) {
|
||||
return Optional.absent()
|
||||
@ -689,6 +698,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
contentValues.put(DATE_RECEIVED, receivedTimestamp)
|
||||
contentValues.put(SUBSCRIPTION_ID, message.subscriptionId)
|
||||
contentValues.put(EXPIRES_IN, message.expiresIn)
|
||||
contentValues.put(EXPIRE_STARTED, message.expireStartedAt)
|
||||
contentValues.put(ADDRESS, message.recipient.address.serialize())
|
||||
contentValues.put(
|
||||
DELIVERY_RECEIPT_COUNT,
|
||||
@ -1152,6 +1162,20 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param outgoing if true only delete outgoing messages, if false only delete incoming messages, if null delete both.
|
||||
*/
|
||||
private fun deleteExpirationTimerMessages(threadId: Long, outgoing: Boolean? = null) {
|
||||
val outgoingClause = outgoing?.takeIf { ExpirationConfiguration.isNewConfigEnabled }?.let {
|
||||
val comparison = if (it) "IN" else "NOT IN"
|
||||
" AND $MESSAGE_BOX & ${MmsSmsColumns.Types.BASE_TYPE_MASK} $comparison (${MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES.joinToString()})"
|
||||
} ?: ""
|
||||
|
||||
val where = "$THREAD_ID = ? AND ($MESSAGE_BOX & ${MmsSmsColumns.Types.EXPIRATION_TIMER_UPDATE_BIT}) <> 0" + outgoingClause
|
||||
writableDatabase.delete(TABLE_NAME, where, arrayOf("$threadId"))
|
||||
notifyConversationListeners(threadId)
|
||||
}
|
||||
|
||||
object Status {
|
||||
const val DOWNLOAD_INITIALIZED = 1
|
||||
const val DOWNLOAD_NO_CONNECTIVITY = 2
|
||||
@ -1398,7 +1422,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
const val SHARED_CONTACTS: String = "shared_contacts"
|
||||
const val LINK_PREVIEWS: String = "previews"
|
||||
const val CREATE_TABLE: String =
|
||||
"CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " +
|
||||
"CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
THREAD_ID + " INTEGER, " + DATE_SENT + " INTEGER, " + DATE_RECEIVED + " INTEGER, " + MESSAGE_BOX + " INTEGER, " +
|
||||
READ + " INTEGER DEFAULT 0, " + "m_id" + " TEXT, " + "sub" + " TEXT, " +
|
||||
"sub_cs" + " INTEGER, " + BODY + " TEXT, " + PART_COUNT + " INTEGER, " +
|
||||
@ -1503,5 +1527,21 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
|
||||
const val CREATE_REACTIONS_UNREAD_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_UNREAD INTEGER DEFAULT 0;"
|
||||
const val CREATE_REACTIONS_LAST_SEEN_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_LAST_SEEN INTEGER DEFAULT 0;"
|
||||
const val CREATE_HAS_MENTION_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $HAS_MENTION INTEGER DEFAULT 0;"
|
||||
|
||||
private const val TEMP_TABLE_NAME = "TEMP_TABLE_NAME"
|
||||
|
||||
const val COMMA_SEPARATED_COLUMNS = "$ID, $THREAD_ID, $DATE_SENT, $DATE_RECEIVED, $MESSAGE_BOX, $READ, m_id, sub, sub_cs, $BODY, $PART_COUNT, ct_t, $CONTENT_LOCATION, $ADDRESS, $ADDRESS_DEVICE_ID, $EXPIRY, m_cls, $MESSAGE_TYPE, v, $MESSAGE_SIZE, pri, rr,rpt_a, resp_st, $STATUS, $TRANSACTION_ID, retr_st, retr_txt, retr_txt_cs, read_status, ct_cls, resp_txt, d_tm, $DELIVERY_RECEIPT_COUNT, $MISMATCHED_IDENTITIES, $NETWORK_FAILURE, d_rpt, $SUBSCRIPTION_ID, $EXPIRES_IN, $EXPIRE_STARTED, $NOTIFIED, $READ_RECEIPT_COUNT, $QUOTE_ID, $QUOTE_AUTHOR, $QUOTE_BODY, $QUOTE_ATTACHMENT, $QUOTE_MISSING, $SHARED_CONTACTS, $UNIDENTIFIED, $LINK_PREVIEWS, $MESSAGE_REQUEST_RESPONSE, $REACTIONS_UNREAD, $REACTIONS_LAST_SEEN, $HAS_MENTION"
|
||||
|
||||
@JvmField
|
||||
val ADD_AUTOINCREMENT = arrayOf(
|
||||
"ALTER TABLE $TABLE_NAME RENAME TO $TEMP_TABLE_NAME",
|
||||
CREATE_TABLE,
|
||||
CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND,
|
||||
CREATE_REACTIONS_UNREAD_COMMAND,
|
||||
CREATE_REACTIONS_LAST_SEEN_COMMAND,
|
||||
CREATE_HAS_MENTION_COMMAND,
|
||||
"INSERT INTO $TABLE_NAME ($COMMA_SEPARATED_COLUMNS) SELECT $COMMA_SEPARATED_COLUMNS FROM $TEMP_TABLE_NAME",
|
||||
"DROP TABLE $TEMP_TABLE_NAME"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -46,7 +46,8 @@ public class RecipientDatabase extends Database {
|
||||
private static final String COLOR = "color";
|
||||
private static final String SEEN_INVITE_REMINDER = "seen_invite_reminder";
|
||||
private static final String DEFAULT_SUBSCRIPTION_ID = "default_subscription_id";
|
||||
private static final String EXPIRE_MESSAGES = "expire_messages";
|
||||
static final String EXPIRE_MESSAGES = "expire_messages";
|
||||
private static final String DISAPPEARING_STATE = "disappearing_state";
|
||||
private static final String REGISTERED = "registered";
|
||||
private static final String PROFILE_KEY = "profile_key";
|
||||
private static final String SYSTEM_DISPLAY_NAME = "system_display_name";
|
||||
@ -63,13 +64,14 @@ public class RecipientDatabase extends Database {
|
||||
private static final String FORCE_SMS_SELECTION = "force_sms_selection";
|
||||
private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none
|
||||
private static final String WRAPPER_HASH = "wrapper_hash";
|
||||
private static final String BLOCKS_COMMUNITY_MESSAGE_REQUESTS = "blocks_community_message_requests";
|
||||
|
||||
private static final String[] RECIPIENT_PROJECTION = new String[] {
|
||||
BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED,
|
||||
PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI,
|
||||
SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
|
||||
UNIDENTIFIED_ACCESS_MODE,
|
||||
FORCE_SMS_SELECTION, NOTIFY_TYPE, WRAPPER_HASH
|
||||
FORCE_SMS_SELECTION, NOTIFY_TYPE, DISAPPEARING_STATE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS
|
||||
};
|
||||
|
||||
static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
|
||||
@ -137,11 +139,21 @@ public class RecipientDatabase extends Database {
|
||||
"OR "+ADDRESS+" IN (SELECT "+GroupDatabase.TABLE_NAME+"."+GroupDatabase.ADMINS+" FROM "+GroupDatabase.TABLE_NAME+")))";
|
||||
}
|
||||
|
||||
public static String getCreateDisappearingStateCommand() {
|
||||
return "ALTER TABLE "+ TABLE_NAME + " " +
|
||||
"ADD COLUMN " + DISAPPEARING_STATE + " INTEGER DEFAULT 0;";
|
||||
}
|
||||
|
||||
public static String getAddWrapperHash() {
|
||||
return "ALTER TABLE "+TABLE_NAME+" "+
|
||||
"ADD COLUMN "+WRAPPER_HASH+" TEXT DEFAULT NULL;";
|
||||
}
|
||||
|
||||
public static String getAddBlocksCommunityMessageRequests() {
|
||||
return "ALTER TABLE "+TABLE_NAME+" "+
|
||||
"ADD COLUMN "+BLOCKS_COMMUNITY_MESSAGE_REQUESTS+" INT DEFAULT 0;";
|
||||
}
|
||||
|
||||
public static final int NOTIFY_TYPE_ALL = 0;
|
||||
public static final int NOTIFY_TYPE_MENTIONS = 1;
|
||||
public static final int NOTIFY_TYPE_NONE = 2;
|
||||
@ -177,6 +189,7 @@ public class RecipientDatabase extends Database {
|
||||
boolean approvedMe = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED_ME)) == 1;
|
||||
String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION));
|
||||
String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE));
|
||||
int disappearingState = cursor.getInt(cursor.getColumnIndexOrThrow(DISAPPEARING_STATE));
|
||||
int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE));
|
||||
int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE));
|
||||
long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL));
|
||||
@ -197,6 +210,7 @@ public class RecipientDatabase extends Database {
|
||||
int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE));
|
||||
boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1;
|
||||
String wrapperHash = cursor.getString(cursor.getColumnIndexOrThrow(WRAPPER_HASH));
|
||||
boolean blocksCommunityMessageRequests = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCKS_COMMUNITY_MESSAGE_REQUESTS)) == 1;
|
||||
|
||||
MaterialColor color;
|
||||
byte[] profileKey = null;
|
||||
@ -219,6 +233,7 @@ public class RecipientDatabase extends Database {
|
||||
|
||||
return Optional.of(new RecipientSettings(blocked, approved, approvedMe, muteUntil,
|
||||
notifyType,
|
||||
Recipient.DisappearingState.fromId(disappearingState),
|
||||
Recipient.VibrateState.fromId(messageVibrateState),
|
||||
Recipient.VibrateState.fromId(callVibrateState),
|
||||
Util.uri(messageRingtone), Util.uri(callRingtone),
|
||||
@ -228,7 +243,7 @@ public class RecipientDatabase extends Database {
|
||||
systemPhoneLabel, systemContactUri,
|
||||
signalProfileName, signalProfileAvatar, profileSharing,
|
||||
notificationChannel, Recipient.UnidentifiedAccessMode.fromMode(unidentifiedAccessMode),
|
||||
forceSmsSelection, wrapperHash));
|
||||
forceSmsSelection, wrapperHash, blocksCommunityMessageRequests));
|
||||
}
|
||||
|
||||
public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) {
|
||||
@ -328,16 +343,6 @@ public class RecipientDatabase extends Database {
|
||||
notifyRecipientListeners();
|
||||
}
|
||||
|
||||
public void setExpireMessages(@NonNull Recipient recipient, int expiration) {
|
||||
recipient.setExpireMessages(expiration);
|
||||
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(EXPIRE_MESSAGES, expiration);
|
||||
updateOrInsert(recipient.getAddress(), values);
|
||||
recipient.resolve().setExpireMessages(expiration);
|
||||
notifyRecipientListeners();
|
||||
}
|
||||
|
||||
public void setUnidentifiedAccessMode(@NonNull Recipient recipient, @NonNull UnidentifiedAccessMode unidentifiedAccessMode) {
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(UNIDENTIFIED_ACCESS_MODE, unidentifiedAccessMode.getMode());
|
||||
@ -395,6 +400,14 @@ public class RecipientDatabase extends Database {
|
||||
notifyRecipientListeners();
|
||||
}
|
||||
|
||||
public void setBlocksCommunityMessageRequests(@NonNull Recipient recipient, boolean isBlocked) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(BLOCKS_COMMUNITY_MESSAGE_REQUESTS, isBlocked ? 1 : 0);
|
||||
updateOrInsert(recipient.getAddress(), contentValues);
|
||||
recipient.resolve().setBlocksCommunityMessageRequests(isBlocked);
|
||||
notifyRecipientListeners();
|
||||
}
|
||||
|
||||
private void updateOrInsert(Address address, ContentValues contentValues) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
|
||||
@ -428,6 +441,14 @@ public class RecipientDatabase extends Database {
|
||||
return returnList;
|
||||
}
|
||||
|
||||
public void setDisappearingState(@NonNull Recipient recipient, @NonNull Recipient.DisappearingState disappearingState) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(DISAPPEARING_STATE, disappearingState.getId());
|
||||
updateOrInsert(recipient.getAddress(), values);
|
||||
recipient.resolve().setDisappearingState(disappearingState);
|
||||
notifyRecipientListeners();
|
||||
}
|
||||
|
||||
public static class RecipientReader implements Closeable {
|
||||
|
||||
private final Context context;
|
||||
|
@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
||||
import org.session.libsession.messaging.jobs.AttachmentUploadJob
|
||||
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
|
||||
import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob
|
||||
@ -26,6 +27,9 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
|
||||
const val serializedData = "serialized_data"
|
||||
@JvmStatic val createSessionJobTableCommand
|
||||
= "CREATE TABLE $sessionJobTable ($jobID INTEGER PRIMARY KEY, $jobType STRING, $failureCount INTEGER DEFAULT 0, $serializedData TEXT);"
|
||||
|
||||
const val dropAttachmentDownloadJobs =
|
||||
"DELETE FROM $sessionJobTable WHERE $jobType = '${AttachmentDownloadJob.KEY}';"
|
||||
}
|
||||
|
||||
fun persistJob(job: Job) {
|
||||
|
@ -52,6 +52,7 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
@ -90,6 +91,7 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
EXPIRES_IN + " INTEGER DEFAULT 0, " + EXPIRE_STARTED + " INTEGER DEFAULT 0, " + NOTIFIED + " DEFAULT 0, " +
|
||||
READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + UNIDENTIFIED + " INTEGER DEFAULT 0);";
|
||||
|
||||
|
||||
public static final String[] CREATE_INDEXS = {
|
||||
"CREATE INDEX IF NOT EXISTS sms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");",
|
||||
"CREATE INDEX IF NOT EXISTS sms_read_index ON " + TABLE_NAME + " (" + READ + ");",
|
||||
@ -127,6 +129,18 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
public static String CREATE_HAS_MENTION_COMMAND = "ALTER TABLE "+ TABLE_NAME + " " +
|
||||
"ADD COLUMN " + HAS_MENTION + " INTEGER DEFAULT 0;";
|
||||
|
||||
private static String COMMA_SEPARATED_COLUMNS = ID + ", " + THREAD_ID + ", " + ADDRESS + ", " + ADDRESS_DEVICE_ID + ", " + PERSON + ", " + DATE_RECEIVED + ", " + DATE_SENT + ", " + PROTOCOL + ", " + READ + ", " + STATUS + ", " + TYPE + ", " + REPLY_PATH_PRESENT + ", " + DELIVERY_RECEIPT_COUNT + ", " + SUBJECT + ", " + BODY + ", " + MISMATCHED_IDENTITIES + ", " + SERVICE_CENTER + ", " + SUBSCRIPTION_ID + ", " + EXPIRES_IN + ", " + EXPIRE_STARTED + ", " + NOTIFIED + ", " + READ_RECEIPT_COUNT + ", " + UNIDENTIFIED + ", " + REACTIONS_UNREAD + ", " + HAS_MENTION;
|
||||
private static String TEMP_TABLE_NAME = "TEMP_TABLE_NAME";
|
||||
|
||||
public static final String[] ADD_AUTOINCREMENT = new String[]{
|
||||
"ALTER TABLE " + TABLE_NAME + " RENAME TO " + TEMP_TABLE_NAME,
|
||||
CREATE_TABLE,
|
||||
CREATE_REACTIONS_UNREAD_COMMAND,
|
||||
CREATE_HAS_MENTION_COMMAND,
|
||||
"INSERT INTO " + TABLE_NAME + " (" + COMMA_SEPARATED_COLUMNS + ") SELECT " + COMMA_SEPARATED_COLUMNS + " FROM " + TEMP_TABLE_NAME,
|
||||
"DROP TABLE " + TEMP_TABLE_NAME
|
||||
};
|
||||
|
||||
private static final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache();
|
||||
private static final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache();
|
||||
|
||||
@ -237,11 +251,6 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
updateTypeBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_DELETED_TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markExpireStarted(long id) {
|
||||
markExpireStarted(id, SnodeAPI.getNowWithOffset());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markExpireStarted(long id, long startedAtTimestamp) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
@ -354,13 +363,12 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
cursor = database.query(TABLE_NAME, new String[] {ID, ADDRESS, DATE_SENT, TYPE, EXPIRES_IN, EXPIRE_STARTED}, where, arguments, null, null, null);
|
||||
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
if (Types.isSecureType(cursor.getLong(3))) {
|
||||
SyncMessageId syncMessageId = new SyncMessageId(Address.fromSerialized(cursor.getString(1)), cursor.getLong(2));
|
||||
ExpirationInfo expirationInfo = new ExpirationInfo(cursor.getLong(0), cursor.getLong(4), cursor.getLong(5), false);
|
||||
long timestamp = cursor.getLong(2);
|
||||
SyncMessageId syncMessageId = new SyncMessageId(Address.fromSerialized(cursor.getString(1)), timestamp);
|
||||
ExpirationInfo expirationInfo = new ExpirationInfo(cursor.getLong(0), timestamp, cursor.getLong(4), cursor.getLong(5), false);
|
||||
|
||||
results.add(new MarkedMessageInfo(syncMessageId, expirationInfo));
|
||||
}
|
||||
}
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(READ, 1);
|
||||
@ -407,35 +415,6 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
}
|
||||
|
||||
protected Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runThreadUpdate) {
|
||||
if (message.isSecureMessage()) {
|
||||
type |= Types.SECURE_MESSAGE_BIT;
|
||||
} else if (message.isGroup()) {
|
||||
type |= Types.SECURE_MESSAGE_BIT;
|
||||
if (((IncomingGroupMessage)message).isUpdateMessage()) type |= Types.GROUP_UPDATE_MESSAGE_BIT;
|
||||
}
|
||||
|
||||
if (message.isPush()) type |= Types.PUSH_MESSAGE_BIT;
|
||||
|
||||
if (message.isOpenGroupInvitation()) type |= Types.OPEN_GROUP_INVITATION_BIT;
|
||||
|
||||
CallMessageType callMessageType = message.getCallType();
|
||||
if (callMessageType != null) {
|
||||
switch (callMessageType) {
|
||||
case CALL_OUTGOING:
|
||||
type |= Types.OUTGOING_CALL_TYPE;
|
||||
break;
|
||||
case CALL_INCOMING:
|
||||
type |= Types.INCOMING_CALL_TYPE;
|
||||
break;
|
||||
case CALL_MISSED:
|
||||
type |= Types.MISSED_CALL_TYPE;
|
||||
break;
|
||||
case CALL_FIRST_MISSED:
|
||||
type |= Types.FIRST_MISSED_CALL_TYPE;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Recipient recipient = Recipient.from(context, message.getSender(), true);
|
||||
|
||||
Recipient groupRecipient;
|
||||
@ -454,6 +433,22 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
if (groupRecipient == null) threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient);
|
||||
else threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(groupRecipient);
|
||||
|
||||
if (message.isSecureMessage()) {
|
||||
type |= Types.SECURE_MESSAGE_BIT;
|
||||
} else if (message.isGroup()) {
|
||||
type |= Types.SECURE_MESSAGE_BIT;
|
||||
if (((IncomingGroupMessage)message).isUpdateMessage()) type |= Types.GROUP_UPDATE_MESSAGE_BIT;
|
||||
}
|
||||
|
||||
if (message.isPush()) type |= Types.PUSH_MESSAGE_BIT;
|
||||
|
||||
if (message.isOpenGroupInvitation()) type |= Types.OPEN_GROUP_INVITATION_BIT;
|
||||
|
||||
CallMessageType callMessageType = message.getCallType();
|
||||
if (callMessageType != null) {
|
||||
type |= getCallMessageTypeMask(callMessageType);
|
||||
}
|
||||
|
||||
ContentValues values = new ContentValues(6);
|
||||
values.put(ADDRESS, message.getSender().serialize());
|
||||
values.put(ADDRESS_DEVICE_ID, message.getSenderDeviceId());
|
||||
@ -466,6 +461,7 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
values.put(READ, unread ? 0 : 1);
|
||||
values.put(SUBSCRIPTION_ID, message.getSubscriptionId());
|
||||
values.put(EXPIRES_IN, message.getExpiresIn());
|
||||
values.put(EXPIRE_STARTED, message.getExpireStartedAt());
|
||||
values.put(UNIDENTIFIED, message.isUnidentified());
|
||||
values.put(HAS_MENTION, message.hasMention());
|
||||
|
||||
@ -499,6 +495,21 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
private long getCallMessageTypeMask(CallMessageType callMessageType) {
|
||||
switch (callMessageType) {
|
||||
case CALL_OUTGOING:
|
||||
return Types.OUTGOING_CALL_TYPE;
|
||||
case CALL_INCOMING:
|
||||
return Types.INCOMING_CALL_TYPE;
|
||||
case CALL_MISSED:
|
||||
return Types.MISSED_CALL_TYPE;
|
||||
case CALL_FIRST_MISSED:
|
||||
return Types.FIRST_MISSED_CALL_TYPE;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, boolean runThreadUpdate) {
|
||||
return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, runThreadUpdate);
|
||||
}
|
||||
@ -547,6 +558,7 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
contentValues.put(TYPE, type);
|
||||
contentValues.put(SUBSCRIPTION_ID, message.getSubscriptionId());
|
||||
contentValues.put(EXPIRES_IN, message.getExpiresIn());
|
||||
contentValues.put(EXPIRE_STARTED, message.getExpireStartedAt());
|
||||
contentValues.put(DELIVERY_RECEIPT_COUNT, Stream.of(earlyDeliveryReceipts.values()).mapToLong(Long::longValue).sum());
|
||||
contentValues.put(READ_RECEIPT_COUNT, Stream.of(earlyReadReceipts.values()).mapToLong(Long::longValue).sum());
|
||||
|
||||
@ -590,6 +602,11 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
return rawQuery(where, null);
|
||||
}
|
||||
|
||||
public Cursor getExpirationNotStartedMessages() {
|
||||
String where = EXPIRES_IN + " > 0 AND " + EXPIRE_STARTED + " = 0";
|
||||
return rawQuery(where, null);
|
||||
}
|
||||
|
||||
public SmsMessageRecord getMessage(long messageId) throws NoSuchMessageException {
|
||||
Cursor cursor = rawQuery(ID_WHERE, new String[]{messageId + ""});
|
||||
Reader reader = new Reader(cursor);
|
||||
@ -615,7 +632,6 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
long threadId = getThreadIdForMessage(messageId);
|
||||
db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""});
|
||||
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
|
||||
notifyConversationListeners(threadId);
|
||||
return threadDeleted;
|
||||
}
|
||||
|
||||
@ -787,7 +803,7 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
public class Reader {
|
||||
public class Reader implements Closeable {
|
||||
|
||||
private final Cursor cursor;
|
||||
|
||||
@ -853,10 +869,13 @@ public class SmsDatabase extends MessagingDatabase {
|
||||
return new LinkedList<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface InsertListener {
|
||||
public void onComplete();
|
||||
|
@ -14,6 +14,7 @@ import network.loki.messenger.libsession_util.util.Conversation
|
||||
import network.loki.messenger.libsession_util.util.ExpiryMode
|
||||
import network.loki.messenger.libsession_util.util.GroupInfo
|
||||
import network.loki.messenger.libsession_util.util.UserPic
|
||||
import network.loki.messenger.libsession_util.util.afterSend
|
||||
import org.session.libsession.avatars.AvatarHelper
|
||||
import org.session.libsession.database.StorageProtocol
|
||||
import org.session.libsession.messaging.BlindedIdMapping
|
||||
@ -29,6 +30,7 @@ import org.session.libsession.messaging.jobs.MessageReceiveJob
|
||||
import org.session.libsession.messaging.jobs.MessageSendJob
|
||||
import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob
|
||||
import org.session.libsession.messaging.messages.Destination
|
||||
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||
import org.session.libsession.messaging.messages.Message
|
||||
import org.session.libsession.messaging.messages.control.ConfigurationMessage
|
||||
import org.session.libsession.messaging.messages.control.MessageRequestResponse
|
||||
@ -50,7 +52,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
|
||||
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
|
||||
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
|
||||
import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
|
||||
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
|
||||
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
|
||||
import org.session.libsession.messaging.utilities.SessionId
|
||||
@ -66,6 +68,7 @@ import org.session.libsession.utilities.ProfileKeyUtil
|
||||
import org.session.libsession.utilities.SSKEnvironment
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsession.utilities.recipients.Recipient.DisappearingState
|
||||
import org.session.libsignal.crypto.ecc.DjbECPrivateKey
|
||||
import org.session.libsignal.crypto.ecc.DjbECPublicKey
|
||||
import org.session.libsignal.crypto.ecc.ECKeyPair
|
||||
@ -89,10 +92,16 @@ import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
||||
import org.thoughtcrime.securesms.util.SessionMetaProtocol
|
||||
import java.security.MessageDigest
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import network.loki.messenger.libsession_util.util.Contact as LibSessionContact
|
||||
|
||||
open class Storage(context: Context, helper: SQLCipherOpenHelper, private val configFactory: ConfigFactory) : Database(context, helper), StorageProtocol,
|
||||
ThreadDatabase.ConversationThreadUpdateListener {
|
||||
private const val TAG = "Storage"
|
||||
|
||||
open class Storage(
|
||||
context: Context,
|
||||
helper: SQLCipherOpenHelper,
|
||||
private val configFactory: ConfigFactory
|
||||
) : Database(context, helper), StorageProtocol, ThreadDatabase.ConversationThreadUpdateListener {
|
||||
|
||||
override fun threadCreated(address: Address, threadId: Long) {
|
||||
val localUserAddress = getUserPublicKey() ?: return
|
||||
@ -173,7 +182,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
}
|
||||
|
||||
override fun getUserProfile(): Profile {
|
||||
val displayName = TextSecurePreferences.getProfileName(context)!!
|
||||
val displayName = TextSecurePreferences.getProfileName(context)
|
||||
val profileKey = ProfileKeyUtil.getProfileKey(context)
|
||||
val profilePictureUrl = TextSecurePreferences.getProfilePictureURL(context)
|
||||
return Profile(displayName, profileKey, profilePictureUrl)
|
||||
@ -190,6 +199,11 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
db.setProfileKey(recipient, newProfileKey)
|
||||
}
|
||||
|
||||
override fun setBlocksCommunityMessageRequests(recipient: Recipient, blocksMessageRequests: Boolean) {
|
||||
val db = DatabaseComponent.get(context).recipientDatabase()
|
||||
db.setBlocksCommunityMessageRequests(recipient, blocksMessageRequests)
|
||||
}
|
||||
|
||||
override fun setUserProfilePicture(newProfilePicture: String?, newProfileKey: ByteArray?) {
|
||||
val ourRecipient = fromSerialized(getUserPublicKey()!!).let {
|
||||
Recipient.from(context, it, false)
|
||||
@ -317,19 +331,30 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
// open group recipients should explicitly create threads
|
||||
message.threadID = getOrCreateThreadIdFor(targetAddress)
|
||||
}
|
||||
val expiryMode = message.expiryMode
|
||||
val expiresInMillis = expiryMode.expiryMillis
|
||||
val expireStartedAt = if (expiryMode is ExpiryMode.AfterSend) message.sentTimestamp!! else 0
|
||||
if (message.isMediaMessage() || attachments.isNotEmpty()) {
|
||||
val quote: Optional<QuoteModel> = if (quotes != null) Optional.of(quotes) else Optional.absent()
|
||||
val linkPreviews: Optional<List<LinkPreview>> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! })
|
||||
val mmsDatabase = DatabaseComponent.get(context).mmsDatabase()
|
||||
val insertResult = if (isUserSender || isUserBlindedSender) {
|
||||
val mediaMessage = OutgoingMediaMessage.from(message, targetRecipient, pointers, quote.orNull(), linkPreviews.orNull()?.firstOrNull())
|
||||
val mediaMessage = OutgoingMediaMessage.from(
|
||||
message,
|
||||
targetRecipient,
|
||||
pointers,
|
||||
quote.orNull(),
|
||||
linkPreviews.orNull()?.firstOrNull(),
|
||||
expiresInMillis,
|
||||
expireStartedAt
|
||||
)
|
||||
mmsDatabase.insertSecureDecryptedMessageOutbox(mediaMessage, message.threadID ?: -1, message.sentTimestamp!!, runThreadUpdate)
|
||||
} else {
|
||||
// It seems like we have replaced SignalServiceAttachment with SessionServiceAttachment
|
||||
val signalServiceAttachments = attachments.mapNotNull {
|
||||
it.toSignalPointer()
|
||||
}
|
||||
val mediaMessage = IncomingMediaMessage.from(message, senderAddress, targetRecipient.expireMessages * 1000L, group, signalServiceAttachments, quote, linkPreviews)
|
||||
val mediaMessage = IncomingMediaMessage.from(message, senderAddress, expiresInMillis, expireStartedAt, group, signalServiceAttachments, quote, linkPreviews)
|
||||
mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID!!, message.receivedTimestamp ?: 0, runThreadUpdate)
|
||||
}
|
||||
if (insertResult.isPresent) {
|
||||
@ -340,12 +365,12 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
val isOpenGroupInvitation = (message.openGroupInvitation != null)
|
||||
|
||||
val insertResult = if (isUserSender || isUserBlindedSender) {
|
||||
val textMessage = if (isOpenGroupInvitation) OutgoingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, targetRecipient, message.sentTimestamp)
|
||||
else OutgoingTextMessage.from(message, targetRecipient)
|
||||
val textMessage = if (isOpenGroupInvitation) OutgoingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, targetRecipient, message.sentTimestamp, expiresInMillis, expireStartedAt)
|
||||
else OutgoingTextMessage.from(message, targetRecipient, expiresInMillis, expireStartedAt)
|
||||
smsDatabase.insertMessageOutbox(message.threadID ?: -1, textMessage, message.sentTimestamp!!, runThreadUpdate)
|
||||
} else {
|
||||
val textMessage = if (isOpenGroupInvitation) IncomingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, senderAddress, message.sentTimestamp)
|
||||
else IncomingTextMessage.from(message, senderAddress, group, targetRecipient.expireMessages * 1000L)
|
||||
val textMessage = if (isOpenGroupInvitation) IncomingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, senderAddress, message.sentTimestamp, expiresInMillis, expireStartedAt)
|
||||
else IncomingTextMessage.from(message, senderAddress, group, expiresInMillis, expireStartedAt)
|
||||
val encrypted = IncomingEncryptedMessage(textMessage, textMessage.messageBody)
|
||||
smsDatabase.insertMessageInbox(encrypted, message.receivedTimestamp ?: 0, runThreadUpdate)
|
||||
}
|
||||
@ -355,7 +380,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
}
|
||||
message.serverHash?.let { serverHash ->
|
||||
messageID?.let { id ->
|
||||
DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(id, serverHash)
|
||||
DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(id, message.isMediaMessage(), serverHash)
|
||||
}
|
||||
}
|
||||
return messageID
|
||||
@ -418,8 +443,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
return DatabaseComponent.get(context).lokiAPIDatabase().getAuthToken(id)
|
||||
}
|
||||
|
||||
override fun notifyConfigUpdates(forConfigObject: ConfigBase) {
|
||||
notifyUpdates(forConfigObject)
|
||||
override fun notifyConfigUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) {
|
||||
notifyUpdates(forConfigObject, messageTimestamp)
|
||||
}
|
||||
|
||||
override fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean {
|
||||
@ -430,16 +455,20 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
return configFactory.canPerformChange(variant, publicKey, changeTimestampMs)
|
||||
}
|
||||
|
||||
fun notifyUpdates(forConfigObject: ConfigBase) {
|
||||
override fun isCheckingCommunityRequests(): Boolean {
|
||||
return configFactory.user?.getCommunityMessageRequests() == true
|
||||
}
|
||||
|
||||
private fun notifyUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) {
|
||||
when (forConfigObject) {
|
||||
is UserProfile -> updateUser(forConfigObject)
|
||||
is Contacts -> updateContacts(forConfigObject)
|
||||
is ConversationVolatileConfig -> updateConvoVolatile(forConfigObject)
|
||||
is UserGroupsConfig -> updateUserGroups(forConfigObject)
|
||||
is UserProfile -> updateUser(forConfigObject, messageTimestamp)
|
||||
is Contacts -> updateContacts(forConfigObject, messageTimestamp)
|
||||
is ConversationVolatileConfig -> updateConvoVolatile(forConfigObject, messageTimestamp)
|
||||
is UserGroupsConfig -> updateUserGroups(forConfigObject, messageTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUser(userProfile: UserProfile) {
|
||||
private fun updateUser(userProfile: UserProfile, messageTimestamp: Long) {
|
||||
val userPublicKey = getUserPublicKey() ?: return
|
||||
// would love to get rid of recipient and context from this
|
||||
val recipient = Recipient.from(context, fromSerialized(userPublicKey), false)
|
||||
@ -465,16 +494,25 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
deleteConversation(ourThread)
|
||||
} else {
|
||||
// create note to self thread if needed (?)
|
||||
val ourThread = getOrCreateThreadIdFor(recipient.address)
|
||||
val address = recipient.address
|
||||
val ourThread = getThreadId(address) ?: getOrCreateThreadIdFor(address).also {
|
||||
setThreadDate(it, 0)
|
||||
}
|
||||
DatabaseComponent.get(context).threadDatabase().setHasSent(ourThread, true)
|
||||
setPinned(ourThread, userProfile.getNtsPriority() > 0)
|
||||
}
|
||||
|
||||
// Set or reset the shared library to use latest expiration config
|
||||
getThreadId(recipient)?.let {
|
||||
setExpirationConfiguration(
|
||||
getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp } ?: ExpirationConfiguration(it, userProfile.getNtsExpiry(), messageTimestamp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateContacts(contacts: Contacts) {
|
||||
private fun updateContacts(contacts: Contacts, messageTimestamp: Long) {
|
||||
val extracted = contacts.all().toList()
|
||||
addLibSessionContacts(extracted)
|
||||
addLibSessionContacts(extracted, messageTimestamp)
|
||||
}
|
||||
|
||||
override fun clearUserPic() {
|
||||
@ -494,7 +532,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
||||
}
|
||||
|
||||
private fun updateConvoVolatile(convos: ConversationVolatileConfig) {
|
||||
private fun updateConvoVolatile(convos: ConversationVolatileConfig, messageTimestamp: Long) {
|
||||
val extracted = convos.all()
|
||||
for (conversation in extracted) {
|
||||
val threadId = when (conversation) {
|
||||
@ -511,7 +549,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUserGroups(userGroups: UserGroupsConfig) {
|
||||
private fun updateUserGroups(userGroups: UserGroupsConfig, messageTimestamp: Long) {
|
||||
val threadDb = DatabaseComponent.get(context).threadDatabase()
|
||||
val localUserPublicKey = getUserPublicKey() ?: return Log.w(
|
||||
"Loki",
|
||||
@ -563,6 +601,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
}
|
||||
|
||||
for (group in lgc) {
|
||||
val groupId = GroupUtil.doubleEncodeGroupID(group.sessionId)
|
||||
val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.sessionId }
|
||||
val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) }
|
||||
if (existingGroup != null) {
|
||||
@ -577,7 +616,6 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
} else {
|
||||
val members = group.members.keys.map { Address.fromSerialized(it) }
|
||||
val admins = group.members.filter { it.value /*admin = true*/ }.keys.map { Address.fromSerialized(it) }
|
||||
val groupId = GroupUtil.doubleEncodeGroupID(group.sessionId)
|
||||
val title = group.name
|
||||
val formationTimestamp = (group.joinedAt * 1000L)
|
||||
createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp)
|
||||
@ -587,11 +625,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
// Store the encryption key pair
|
||||
val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey))
|
||||
addClosedGroupEncryptionKeyPair(keyPair, group.sessionId, SnodeAPI.nowWithOffset)
|
||||
// Set expiration timer
|
||||
val expireTimer = group.disappearingTimer
|
||||
setExpirationTimer(groupId, expireTimer.toInt())
|
||||
// Notify the PN server
|
||||
PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, group.sessionId, localUserPublicKey)
|
||||
PushRegistryV1.subscribeGroup(group.sessionId, publicKey = localUserPublicKey)
|
||||
// Notify the user
|
||||
val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId))
|
||||
threadDb.setDate(threadID, formationTimestamp)
|
||||
@ -600,6 +635,12 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
// Start polling
|
||||
ClosedGroupPollerV2.shared.startPolling(group.sessionId)
|
||||
}
|
||||
getThreadId(Address.fromSerialized(groupId))?.let {
|
||||
setExpirationConfiguration(
|
||||
getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp }
|
||||
?: ExpirationConfiguration(it, afterSend(group.disappearingTimer), messageTimestamp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -703,10 +744,10 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
SessionMetaProtocol.removeTimestamps(timestamps)
|
||||
}
|
||||
|
||||
override fun getMessageIdInDatabase(timestamp: Long, author: String): Long? {
|
||||
override fun getMessageIdInDatabase(timestamp: Long, author: String): Pair<Long, Boolean>? {
|
||||
val database = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
val address = fromSerialized(author)
|
||||
return database.getMessageFor(timestamp, address)?.getId()
|
||||
return database.getMessageFor(timestamp, address)?.run { getId() to isMms }
|
||||
}
|
||||
|
||||
override fun updateSentTimestamp(
|
||||
@ -825,8 +866,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
db.clearErrorMessage(messageID)
|
||||
}
|
||||
|
||||
override fun setMessageServerHash(messageID: Long, serverHash: String) {
|
||||
DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(messageID, serverHash)
|
||||
override fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) {
|
||||
DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(messageID, mms, serverHash)
|
||||
}
|
||||
|
||||
override fun getGroup(groupID: String): GroupRecord? {
|
||||
@ -838,9 +879,10 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
DatabaseComponent.get(context).groupDatabase().create(groupId, title, members, avatar, relay, admins, formationTimestamp)
|
||||
}
|
||||
|
||||
override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map<String, Boolean>, formationTimestamp: Long, encryptionKeyPair: ECKeyPair) {
|
||||
override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map<String, Boolean>, formationTimestamp: Long, encryptionKeyPair: ECKeyPair, expirationTimer: Int) {
|
||||
val volatiles = configFactory.convoVolatile ?: return
|
||||
val userGroups = configFactory.userGroups ?: return
|
||||
if (volatiles.getLegacyClosedGroup(groupPublicKey) != null && userGroups.getLegacyGroupInfo(groupPublicKey) != null) return
|
||||
val groupVolatileConfig = volatiles.getOrConstructLegacyGroup(groupPublicKey)
|
||||
groupVolatileConfig.lastRead = formationTimestamp
|
||||
volatiles.set(groupVolatileConfig)
|
||||
@ -851,7 +893,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
priority = ConfigBase.PRIORITY_VISIBLE,
|
||||
encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
|
||||
encSecKey = encryptionKeyPair.privateKey.serialize(),
|
||||
disappearingTimer = 0L,
|
||||
disappearingTimer = expirationTimer.toLong(),
|
||||
joinedAt = (formationTimestamp / 1000L)
|
||||
)
|
||||
// shouldn't exist, don't use getOrConstruct + copy
|
||||
@ -862,8 +904,6 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
override fun updateGroupConfig(groupPublicKey: String) {
|
||||
val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey)
|
||||
val groupAddress = fromSerialized(groupID)
|
||||
// TODO: probably add a check in here for isActive?
|
||||
// TODO: also check if local user is a member / maybe run delete otherwise?
|
||||
val existingGroup = getGroup(groupID)
|
||||
?: return Log.w("Loki-DBG", "No existing group for ${groupPublicKey.take(4)}} when updating group config")
|
||||
val userGroups = configFactory.userGroups ?: return
|
||||
@ -877,7 +917,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
val membersMap = GroupUtil.createConfigMemberMap(admins = admins, members = members)
|
||||
val latestKeyPair = getLatestClosedGroupEncryptionKeyPair(groupPublicKey)
|
||||
?: return Log.w("Loki-DBG", "No latest closed group encryption key pair for ${groupPublicKey.take(4)}} when updating group config")
|
||||
val recipientSettings = getRecipientSettings(groupAddress) ?: return
|
||||
|
||||
val threadID = getThreadId(groupAddress) ?: return
|
||||
val groupInfo = userGroups.getOrConstructLegacyGroupInfo(groupPublicKey).copy(
|
||||
name = name,
|
||||
@ -885,7 +925,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
|
||||
encSecKey = latestKeyPair.privateKey.serialize(),
|
||||
priority = if (isPinned(threadID)) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE,
|
||||
disappearingTimer = recipientSettings.expireMessages.toLong(),
|
||||
disappearingTimer = getExpirationConfiguration(threadID)?.expiryMode?.expirySeconds ?: 0L,
|
||||
joinedAt = (existingGroup.formationTimestamp / 1000L)
|
||||
)
|
||||
userGroups.set(groupInfo)
|
||||
@ -917,7 +957,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
|
||||
override fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection<String>, admins: Collection<String>, sentTimestamp: Long) {
|
||||
val group = SignalServiceGroup(type, GroupUtil.getDecodedGroupIDAsData(groupID), SignalServiceGroup.GroupType.SIGNAL, name, members.toList(), null, admins.toList())
|
||||
val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), 0, true, false)
|
||||
val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), 0, 0, true, false)
|
||||
val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON()
|
||||
val infoMessage = IncomingGroupMessage(m, groupID, updateData, true)
|
||||
val smsDB = DatabaseComponent.get(context).smsDatabase()
|
||||
@ -925,11 +965,10 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
}
|
||||
|
||||
override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection<String>, admins: Collection<String>, threadID: Long, sentTimestamp: Long) {
|
||||
val userPublicKey = getUserPublicKey()
|
||||
val userPublicKey = getUserPublicKey()!!
|
||||
val recipient = Recipient.from(context, fromSerialized(groupID), false)
|
||||
|
||||
val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() ?: ""
|
||||
val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, groupID, null, sentTimestamp, 0, true, null, listOf(), listOf())
|
||||
val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, groupID, null, sentTimestamp, 0, 0, true, null, listOf(), listOf())
|
||||
val mmsDB = DatabaseComponent.get(context).mmsDatabase()
|
||||
val mmsSmsDB = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) return
|
||||
@ -987,23 +1026,6 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
.updateTimestampUpdated(groupID, updatedTimestamp)
|
||||
}
|
||||
|
||||
override fun setExpirationTimer(address: String, duration: Int) {
|
||||
val recipient = Recipient.from(context, fromSerialized(address), false)
|
||||
DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration)
|
||||
if (recipient.isContactRecipient && !recipient.isLocalNumber) {
|
||||
configFactory.contacts?.upsertContact(address) {
|
||||
this.expiryMode = if (duration != 0) {
|
||||
ExpiryMode.AfterRead(duration.toLong())
|
||||
} else { // = 0 / delete
|
||||
ExpiryMode.NONE
|
||||
}
|
||||
}
|
||||
if (configFactory.contacts?.needsPush() == true) {
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setServerCapabilities(server: String, capabilities: List<String>) {
|
||||
return DatabaseComponent.get(context).lokiAPIDatabase().setServerCapabilities(server, capabilities)
|
||||
}
|
||||
@ -1126,11 +1148,10 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
}
|
||||
|
||||
override fun getRecipientSettings(address: Address): Recipient.RecipientSettings? {
|
||||
val recipientSettings = DatabaseComponent.get(context).recipientDatabase().getRecipientSettings(address)
|
||||
return if (recipientSettings.isPresent) { recipientSettings.get() } else null
|
||||
return DatabaseComponent.get(context).recipientDatabase().getRecipientSettings(address).orNull()
|
||||
}
|
||||
|
||||
override fun addLibSessionContacts(contacts: List<LibSessionContact>) {
|
||||
override fun addLibSessionContacts(contacts: List<LibSessionContact>, timestamp: Long) {
|
||||
val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
|
||||
val moreContacts = contacts.filter { contact ->
|
||||
val id = SessionId(contact.id)
|
||||
@ -1163,13 +1184,19 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
profileManager.setProfilePicture(context, recipient, null, null)
|
||||
}
|
||||
if (contact.priority == PRIORITY_HIDDEN) {
|
||||
getThreadId(fromSerialized(contact.id))?.let { conversationThreadId ->
|
||||
deleteConversation(conversationThreadId)
|
||||
}
|
||||
getThreadId(fromSerialized(contact.id))?.let(::deleteConversation)
|
||||
} else {
|
||||
getThreadId(fromSerialized(contact.id))?.let { conversationThreadId ->
|
||||
setPinned(conversationThreadId, contact.priority == PRIORITY_PINNED)
|
||||
(
|
||||
getThreadId(address) ?: getOrCreateThreadIdFor(address).also {
|
||||
setThreadDate(it, 0)
|
||||
}
|
||||
).also { setPinned(it, contact.priority == PRIORITY_PINNED) }
|
||||
}
|
||||
getThreadId(recipient)?.let {
|
||||
setExpirationConfiguration(
|
||||
getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > timestamp }
|
||||
?: ExpirationConfiguration(it, contact.expiryMode, timestamp)
|
||||
)
|
||||
}
|
||||
setRecipientHash(recipient, contact.hashCode().toString())
|
||||
}
|
||||
@ -1284,20 +1311,26 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
threadDb.setDate(threadId, newDate)
|
||||
}
|
||||
|
||||
override fun getLastLegacyRecipient(threadRecipient: String): String? =
|
||||
DatabaseComponent.get(context).lokiAPIDatabase().getLastLegacySenderAddress(threadRecipient)
|
||||
|
||||
override fun setLastLegacyRecipient(threadRecipient: String, senderRecipient: String?) {
|
||||
DatabaseComponent.get(context).lokiAPIDatabase().setLastLegacySenderAddress(threadRecipient, senderRecipient)
|
||||
}
|
||||
|
||||
override fun deleteConversation(threadID: Long) {
|
||||
val recipient = getRecipientForThread(threadID)
|
||||
val threadDB = DatabaseComponent.get(context).threadDatabase()
|
||||
val groupDB = DatabaseComponent.get(context).groupDatabase()
|
||||
threadDB.deleteConversation(threadID)
|
||||
if (recipient != null) {
|
||||
if (recipient.isContactRecipient) {
|
||||
val recipient = getRecipientForThread(threadID) ?: return
|
||||
when {
|
||||
recipient.isContactRecipient -> {
|
||||
if (recipient.isLocalNumber) return
|
||||
val contacts = configFactory.contacts ?: return
|
||||
contacts.upsertContact(recipient.address.serialize()) {
|
||||
this.priority = PRIORITY_HIDDEN
|
||||
}
|
||||
contacts.upsertContact(recipient.address.serialize()) { priority = PRIORITY_HIDDEN }
|
||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
|
||||
} else if (recipient.isClosedGroupRecipient) {
|
||||
}
|
||||
recipient.isClosedGroupRecipient -> {
|
||||
// TODO: handle closed group
|
||||
val volatile = configFactory.convoVolatile ?: return
|
||||
val groups = configFactory.userGroups ?: return
|
||||
@ -1329,14 +1362,17 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
val recipient = Recipient.from(context, address, false)
|
||||
|
||||
if (recipient.isBlocked) return
|
||||
|
||||
val threadId = getThreadId(recipient) ?: return
|
||||
|
||||
val expirationConfig = getExpirationConfiguration(threadId)
|
||||
val expiryMode = expirationConfig?.expiryMode ?: ExpiryMode.NONE
|
||||
val expiresInMillis = expiryMode.expiryMillis
|
||||
val expireStartedAt = if (expiryMode is ExpiryMode.AfterSend) sentTimestamp else 0
|
||||
val mediaMessage = IncomingMediaMessage(
|
||||
address,
|
||||
sentTimestamp,
|
||||
-1,
|
||||
0,
|
||||
expiresInMillis,
|
||||
expireStartedAt,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
@ -1351,6 +1387,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
)
|
||||
|
||||
database.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true)
|
||||
|
||||
SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode)
|
||||
}
|
||||
|
||||
override fun insertMessageRequestResponse(response: MessageRequestResponse) {
|
||||
@ -1405,7 +1443,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
val blindedId = when {
|
||||
recipient.isGroupRecipient -> null
|
||||
recipient.isOpenGroupInboxRecipient -> {
|
||||
GroupUtil.getDecodedOpenGroupInbox(address)
|
||||
GroupUtil.getDecodedOpenGroupInboxSessionId(address)
|
||||
}
|
||||
else -> {
|
||||
if (SessionId(address).prefix == IdPrefix.BLINDED) {
|
||||
@ -1431,12 +1469,12 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
}
|
||||
recipientDb.setApproved(sender, true)
|
||||
recipientDb.setApprovedMe(sender, true)
|
||||
|
||||
val message = IncomingMediaMessage(
|
||||
sender.address,
|
||||
response.sentTimestamp!!,
|
||||
-1,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
@ -1476,8 +1514,15 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) {
|
||||
val database = DatabaseComponent.get(context).smsDatabase()
|
||||
val address = fromSerialized(senderPublicKey)
|
||||
val callMessage = IncomingTextMessage.fromCallInfo(callMessageType, address, Optional.absent(), sentTimestamp)
|
||||
val recipient = Recipient.from(context, address, false)
|
||||
val threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient)
|
||||
val expirationConfig = getExpirationConfiguration(threadId)
|
||||
val expiryMode = expirationConfig?.expiryMode?.coerceSendToRead() ?: ExpiryMode.NONE
|
||||
val expiresInMillis = expiryMode.expiryMillis
|
||||
val expireStartedAt = if (expiryMode is ExpiryMode.AfterSend) sentTimestamp else 0
|
||||
val callMessage = IncomingTextMessage.fromCallInfo(callMessageType, address, Optional.absent(), sentTimestamp, expiresInMillis, expireStartedAt)
|
||||
database.insertCallMessage(callMessage)
|
||||
SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode)
|
||||
}
|
||||
|
||||
override fun conversationHasOutgoing(userPublicKey: String): Boolean {
|
||||
@ -1524,18 +1569,14 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
if (mapping.sessionId != null) {
|
||||
return mapping
|
||||
}
|
||||
val threadDb = DatabaseComponent.get(context).threadDatabase()
|
||||
threadDb.readerFor(threadDb.conversationList).use { reader ->
|
||||
while (reader.next != null) {
|
||||
val recipient = reader.current.recipient
|
||||
val sessionId = recipient.address.serialize()
|
||||
if (!recipient.isGroupRecipient && SodiumUtilities.sessionId(sessionId, blindedId, serverPublicKey)) {
|
||||
val contactMapping = mapping.copy(sessionId = sessionId)
|
||||
getAllContacts().forEach { contact ->
|
||||
val sessionId = SessionId(contact.sessionID)
|
||||
if (sessionId.prefix == IdPrefix.STANDARD && SodiumUtilities.sessionId(sessionId.hexString, blindedId, serverPublicKey)) {
|
||||
val contactMapping = mapping.copy(sessionId = sessionId.hexString)
|
||||
db.addBlindedIdMapping(contactMapping)
|
||||
return contactMapping
|
||||
}
|
||||
}
|
||||
}
|
||||
db.getBlindedIdMappingsExceptFor(server).forEach {
|
||||
if (SodiumUtilities.sessionId(it.sessionId!!, blindedId, serverPublicKey)) {
|
||||
val otherMapping = mapping.copy(sessionId = it.sessionId)
|
||||
@ -1618,4 +1659,100 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
|
||||
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
|
||||
return recipientDb.blockedContacts
|
||||
}
|
||||
|
||||
override fun getExpirationConfiguration(threadId: Long): ExpirationConfiguration? {
|
||||
val recipient = getRecipientForThread(threadId) ?: return null
|
||||
val dbExpirationMetadata = DatabaseComponent.get(context).expirationConfigurationDatabase().getExpirationConfiguration(threadId) ?: return null
|
||||
return when {
|
||||
recipient.isLocalNumber -> configFactory.user?.getNtsExpiry()
|
||||
recipient.isContactRecipient -> {
|
||||
// read it from contacts config if exists
|
||||
recipient.address.serialize().takeIf { it.startsWith(IdPrefix.STANDARD.value) }
|
||||
?.let { configFactory.contacts?.get(it)?.expiryMode }
|
||||
}
|
||||
recipient.isClosedGroupRecipient -> {
|
||||
// read it from group config if exists
|
||||
GroupUtil.doubleDecodeGroupId(recipient.address.serialize())
|
||||
.let { configFactory.userGroups?.getLegacyGroupInfo(it) }
|
||||
?.run { disappearingTimer.takeIf { it != 0L }?.let(ExpiryMode::AfterSend) ?: ExpiryMode.NONE }
|
||||
}
|
||||
else -> null
|
||||
}?.let { ExpirationConfiguration(threadId, it, dbExpirationMetadata.updatedTimestampMs) }
|
||||
}
|
||||
|
||||
override fun setExpirationConfiguration(config: ExpirationConfiguration) {
|
||||
val recipient = getRecipientForThread(config.threadId) ?: return
|
||||
|
||||
val expirationDb = DatabaseComponent.get(context).expirationConfigurationDatabase()
|
||||
val currentConfig = expirationDb.getExpirationConfiguration(config.threadId)
|
||||
if (currentConfig != null && currentConfig.updatedTimestampMs >= config.updatedTimestampMs) return
|
||||
val expiryMode = config.expiryMode
|
||||
|
||||
if (expiryMode == ExpiryMode.NONE) {
|
||||
// Clear the legacy recipients on updating config to be none
|
||||
DatabaseComponent.get(context).lokiAPIDatabase().setLastLegacySenderAddress(recipient.address.serialize(), null)
|
||||
}
|
||||
|
||||
if (recipient.isClosedGroupRecipient) {
|
||||
val userGroups = configFactory.userGroups ?: return
|
||||
val groupPublicKey = GroupUtil.addressToGroupSessionId(recipient.address)
|
||||
val groupInfo = userGroups.getLegacyGroupInfo(groupPublicKey)
|
||||
?.copy(disappearingTimer = expiryMode.expirySeconds) ?: return
|
||||
userGroups.set(groupInfo)
|
||||
} else if (recipient.isLocalNumber) {
|
||||
val user = configFactory.user ?: return
|
||||
user.setNtsExpiry(expiryMode)
|
||||
} else if (recipient.isContactRecipient) {
|
||||
val contacts = configFactory.contacts ?: return
|
||||
|
||||
val contact = contacts.get(recipient.address.serialize())?.copy(expiryMode = expiryMode) ?: return
|
||||
contacts.set(contact)
|
||||
}
|
||||
expirationDb.setExpirationConfiguration(
|
||||
config.run { copy(expiryMode = expiryMode) }
|
||||
)
|
||||
}
|
||||
|
||||
override fun getExpiringMessages(messageIds: List<Long>): List<Pair<Long, Long>> {
|
||||
val expiringMessages = mutableListOf<Pair<Long, Long>>()
|
||||
val smsDb = DatabaseComponent.get(context).smsDatabase()
|
||||
smsDb.readerFor(smsDb.expirationNotStartedMessages).use { reader ->
|
||||
while (reader.next != null) {
|
||||
if (messageIds.isEmpty() || reader.current.id in messageIds) {
|
||||
expiringMessages.add(reader.current.id to reader.current.expiresIn)
|
||||
}
|
||||
}
|
||||
}
|
||||
val mmsDb = DatabaseComponent.get(context).mmsDatabase()
|
||||
mmsDb.expireNotStartedMessages.use { reader ->
|
||||
while (reader.next != null) {
|
||||
if (messageIds.isEmpty() || reader.current.id in messageIds) {
|
||||
expiringMessages.add(reader.current.id to reader.current.expiresIn)
|
||||
}
|
||||
}
|
||||
}
|
||||
return expiringMessages
|
||||
}
|
||||
|
||||
override fun updateDisappearingState(
|
||||
messageSender: String,
|
||||
threadID: Long,
|
||||
disappearingState: Recipient.DisappearingState
|
||||
) {
|
||||
val threadDb = DatabaseComponent.get(context).threadDatabase()
|
||||
val lokiDb = DatabaseComponent.get(context).lokiAPIDatabase()
|
||||
val recipient = threadDb.getRecipientForThreadId(threadID) ?: return
|
||||
val recipientAddress = recipient.address.serialize()
|
||||
DatabaseComponent.get(context).recipientDatabase()
|
||||
.setDisappearingState(recipient, disappearingState);
|
||||
val currentLegacyRecipient = lokiDb.getLastLegacySenderAddress(recipientAddress)
|
||||
val currentExpiry = getExpirationConfiguration(threadID)
|
||||
if (disappearingState == DisappearingState.LEGACY
|
||||
&& currentExpiry?.isEnabled == true
|
||||
&& ExpirationConfiguration.isNewConfigEnabled) { // only set "this person is legacy" if new config enabled
|
||||
lokiDb.setLastLegacySenderAddress(recipientAddress, messageSender)
|
||||
} else if (messageSender == currentLegacyRecipient) {
|
||||
lokiDb.setLastLegacySenderAddress(recipientAddress, null)
|
||||
}
|
||||
}
|
||||
}
|
@ -50,8 +50,7 @@ import org.session.libsignal.utilities.Log;
|
||||
import org.session.libsignal.utilities.Pair;
|
||||
import org.session.libsignal.utilities.guava.Optional;
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
import org.thoughtcrime.securesms.contactshare.ContactUtil;
|
||||
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
|
||||
import org.thoughtcrime.securesms.contacts.ContactUtil;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
@ -816,13 +815,7 @@ public class ThreadDatabase extends Database {
|
||||
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
|
||||
if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && !force) return false;
|
||||
List<MarkedMessageInfo> messages = setRead(threadId, lastSeenTime);
|
||||
if (isGroupRecipient) {
|
||||
for (MarkedMessageInfo message: messages) {
|
||||
MarkReadReceiver.scheduleDeletion(context, message.getExpirationInfo());
|
||||
}
|
||||
} else {
|
||||
MarkReadReceiver.process(context, messages);
|
||||
}
|
||||
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, threadId);
|
||||
return setLastSeen(threadId, lastSeenTime);
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase;
|
||||
import org.thoughtcrime.securesms.database.ConfigDatabase;
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase;
|
||||
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
|
||||
import org.thoughtcrime.securesms.database.ExpirationConfigurationDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupMemberDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
|
||||
@ -88,9 +89,13 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
private static final int lokiV40 = 61;
|
||||
private static final int lokiV41 = 62;
|
||||
private static final int lokiV42 = 63;
|
||||
private static final int lokiV43 = 64;
|
||||
private static final int lokiV44 = 65;
|
||||
private static final int lokiV45 = 66;
|
||||
private static final int lokiV46 = 67;
|
||||
|
||||
// Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
|
||||
private static final int DATABASE_VERSION = lokiV42;
|
||||
private static final int DATABASE_VERSION = lokiV46;
|
||||
private static final int MIN_DATABASE_VERSION = lokiV7;
|
||||
private static final String CIPHER3_DATABASE_NAME = "signal.db";
|
||||
public static final String DATABASE_NAME = "signal_v4.db";
|
||||
@ -310,6 +315,8 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
db.execSQL(LokiMessageDatabase.getCreateMessageToThreadMappingTableCommand());
|
||||
db.execSQL(LokiMessageDatabase.getCreateErrorMessageTableCommand());
|
||||
db.execSQL(LokiMessageDatabase.getCreateMessageHashTableCommand());
|
||||
db.execSQL(LokiMessageDatabase.getCreateSmsHashTableCommand());
|
||||
db.execSQL(LokiMessageDatabase.getCreateMmsHashTableCommand());
|
||||
db.execSQL(LokiThreadDatabase.getCreateSessionResetTableCommand());
|
||||
db.execSQL(LokiThreadDatabase.getCreatePublicChatTableCommand());
|
||||
db.execSQL(LokiUserDatabase.getCreateDisplayNameTableCommand());
|
||||
@ -323,6 +330,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
db.execSQL(GroupDatabase.getCreateUpdatedTimestampCommand());
|
||||
db.execSQL(RecipientDatabase.getCreateApprovedCommand());
|
||||
db.execSQL(RecipientDatabase.getCreateApprovedMeCommand());
|
||||
db.execSQL(RecipientDatabase.getCreateDisappearingStateCommand());
|
||||
db.execSQL(MmsDatabase.CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND);
|
||||
db.execSQL(MmsDatabase.CREATE_REACTIONS_UNREAD_COMMAND);
|
||||
db.execSQL(SmsDatabase.CREATE_REACTIONS_UNREAD_COMMAND);
|
||||
@ -344,6 +352,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
db.execSQL(SmsDatabase.CREATE_HAS_MENTION_COMMAND);
|
||||
db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND);
|
||||
db.execSQL(ConfigDatabase.CREATE_CONFIG_TABLE_COMMAND);
|
||||
db.execSQL(ExpirationConfigurationDatabase.CREATE_EXPIRATION_CONFIGURATION_TABLE_COMMAND);
|
||||
|
||||
executeStatements(db, SmsDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, MmsDatabase.CREATE_INDEXS);
|
||||
@ -356,6 +365,8 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
|
||||
executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS);
|
||||
db.execSQL(RecipientDatabase.getAddWrapperHash());
|
||||
db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests());
|
||||
db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -598,6 +609,30 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
db.execSQL(RecipientDatabase.getAddWrapperHash());
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV43) {
|
||||
db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests());
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV44) {
|
||||
db.execSQL(SessionJobDatabase.dropAttachmentDownloadJobs);
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV45) {
|
||||
db.execSQL(RecipientDatabase.getCreateDisappearingStateCommand());
|
||||
db.execSQL(ExpirationConfigurationDatabase.CREATE_EXPIRATION_CONFIGURATION_TABLE_COMMAND);
|
||||
db.execSQL(ExpirationConfigurationDatabase.MIGRATE_GROUP_CONVERSATION_EXPIRY_TYPE_COMMAND);
|
||||
db.execSQL(ExpirationConfigurationDatabase.MIGRATE_ONE_TO_ONE_CONVERSATION_EXPIRY_TYPE_COMMAND);
|
||||
|
||||
db.execSQL(LokiMessageDatabase.getCreateSmsHashTableCommand());
|
||||
db.execSQL(LokiMessageDatabase.getCreateMmsHashTableCommand());
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV46) {
|
||||
executeStatements(db, SmsDatabase.ADD_AUTOINCREMENT);
|
||||
executeStatements(db, MmsDatabase.ADD_AUTOINCREMENT);
|
||||
db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE);
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
|
@ -54,6 +54,10 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
private final List<ReactionRecord> reactions;
|
||||
private final boolean hasMention;
|
||||
|
||||
public final boolean isNotDisappearAfterRead() {
|
||||
return expireStarted == getTimestamp();
|
||||
}
|
||||
|
||||
public abstract boolean isMms();
|
||||
public abstract boolean isMmsNotification();
|
||||
|
||||
@ -116,7 +120,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing()));
|
||||
} else if (isExpirationTimerUpdate()) {
|
||||
int seconds = (int) (getExpiresIn() / 1000);
|
||||
return new SpannableString(UpdateMessageBuilder.INSTANCE.buildExpirationTimerMessage(context, seconds, getIndividualRecipient().getAddress().serialize(), isOutgoing()));
|
||||
return new SpannableString(UpdateMessageBuilder.INSTANCE.buildExpirationTimerMessage(context, seconds, getRecipient(), getIndividualRecipient().getAddress().serialize(), isOutgoing(), getTimestamp(), expireStarted));
|
||||
} else if (isDataExtractionNotification()) {
|
||||
if (isScreenshotNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.SCREENSHOT, getIndividualRecipient().getAddress().serialize())));
|
||||
else if (isMediaSavedNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED, getIndividualRecipient().getAddress().serialize())));
|
||||
|
@ -187,7 +187,7 @@ class ConfigFactory(
|
||||
override fun persist(forConfigObject: ConfigBase, timestamp: Long) {
|
||||
try {
|
||||
listeners.forEach { listener ->
|
||||
listener.notifyUpdates(forConfigObject)
|
||||
listener.notifyUpdates(forConfigObject, timestamp)
|
||||
}
|
||||
when (forConfigObject) {
|
||||
is UserProfile -> persistUserConfigDump(timestamp)
|
||||
|
@ -0,0 +1,17 @@
|
||||
package org.thoughtcrime.securesms.dependencies
|
||||
|
||||
import android.content.Context
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ContentModule {
|
||||
|
||||
@Provides
|
||||
fun providesContentResolver(@ApplicationContext context: Context) =context.contentResolver
|
||||
|
||||
}
|
@ -7,6 +7,7 @@ import dagger.hilt.components.SingletonComponent
|
||||
import org.session.libsession.database.MessageDataProvider
|
||||
import org.thoughtcrime.securesms.ApplicationContext
|
||||
import org.thoughtcrime.securesms.database.*
|
||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
|
||||
@EntryPoint
|
||||
@ -45,5 +46,6 @@ interface DatabaseComponent {
|
||||
fun attachmentProvider(): MessageDataProvider
|
||||
fun blindedIdMappingDatabase(): BlindedIdMappingDatabase
|
||||
fun groupMemberDatabase(): GroupMemberDatabase
|
||||
fun expirationConfigurationDatabase(): ExpirationConfigurationDatabase
|
||||
fun configDatabase(): ConfigDatabase
|
||||
}
|
@ -7,12 +7,14 @@ import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.session.libsession.database.MessageDataProvider
|
||||
import org.session.libsession.utilities.SSKEnvironment
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
|
||||
import org.thoughtcrime.securesms.database.*
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@ -24,6 +26,10 @@ object DatabaseModule {
|
||||
System.loadLibrary("sqlcipher")
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMessageExpirationManagerProtocol(@ApplicationContext context: Context): SSKEnvironment.MessageExpirationManagerProtocol = ExpiringMessageManager(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAttachmentSecret(@ApplicationContext context: Context) = AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret
|
||||
@ -129,6 +135,10 @@ object DatabaseModule {
|
||||
@Singleton
|
||||
fun provideEmojiSearchDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = EmojiSearchDatabase(context, openHelper)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExpirationConfigurationDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = ExpirationConfigurationDatabase(context, openHelper)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage {
|
||||
|
@ -3,12 +3,11 @@ package org.thoughtcrime.securesms.groups
|
||||
import android.content.Context
|
||||
import network.loki.messenger.libsession_util.ConfigBase
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
|
||||
import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
|
||||
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.GroupRecord
|
||||
import org.session.libsession.utilities.GroupUtil
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsignal.crypto.ecc.DjbECPublicKey
|
||||
import org.thoughtcrime.securesms.ApplicationContext
|
||||
import org.thoughtcrime.securesms.dependencies.ConfigFactory
|
||||
@ -24,7 +23,7 @@ object ClosedGroupManager {
|
||||
storage.removeAllClosedGroupEncryptionKeyPairs(groupPublicKey)
|
||||
storage.removeMember(groupID, Address.fromSerialized(userPublicKey))
|
||||
// Notify the PN server
|
||||
PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Unsubscribe, groupPublicKey, userPublicKey)
|
||||
PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = groupPublicKey, publicKey = userPublicKey)
|
||||
// Stop polling
|
||||
ClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
|
||||
storage.cancelPendingMessageSendJobs(threadId)
|
||||
@ -41,7 +40,7 @@ object ClosedGroupManager {
|
||||
return groups.eraseLegacyGroup(groupPublicKey)
|
||||
}
|
||||
|
||||
fun ConfigFactory.updateLegacyGroup(groupRecipientSettings: Recipient.RecipientSettings, group: GroupRecord) {
|
||||
fun ConfigFactory.updateLegacyGroup(group: GroupRecord) {
|
||||
val groups = userGroups ?: return
|
||||
if (!group.isClosedGroup) return
|
||||
val storage = MessagingModuleConfiguration.shared.storage
|
||||
@ -53,7 +52,6 @@ object ClosedGroupManager {
|
||||
val toSet = legacyInfo.copy(
|
||||
members = latestMemberMap,
|
||||
name = group.title,
|
||||
disappearingTimer = groupRecipientSettings.expireMessages.toLong(),
|
||||
priority = if (storage.isPinned(threadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE,
|
||||
encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
|
||||
encSecKey = latestKeyPair.privateKey.serialize()
|
||||
|
@ -21,6 +21,7 @@ import nl.komponents.kovenant.ui.successUi
|
||||
import org.session.libsession.messaging.sending_receiving.MessageSender
|
||||
import org.session.libsession.messaging.sending_receiving.groupSizeLimit
|
||||
import org.session.libsession.utilities.Address
|
||||
import org.session.libsession.utilities.Device
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.contacts.SelectContactsAdapter
|
||||
@ -31,10 +32,14 @@ import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.util.fadeIn
|
||||
import org.thoughtcrime.securesms.util.fadeOut
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class CreateGroupFragment : Fragment() {
|
||||
|
||||
@Inject
|
||||
lateinit var device: Device
|
||||
|
||||
private lateinit var binding: FragmentCreateGroupBinding
|
||||
private val viewModel: CreateGroupViewModel by viewModels()
|
||||
|
||||
@ -86,7 +91,7 @@ class CreateGroupFragment : Fragment() {
|
||||
val userPublicKey = TextSecurePreferences.getLocalNumber(requireContext())!!
|
||||
isLoading = true
|
||||
binding.loaderContainer.fadeIn()
|
||||
MessageSender.createClosedGroup(name.toString(), selectedMembers + setOf( userPublicKey )).successUi { groupID ->
|
||||
MessageSender.createClosedGroup(device, name.toString(), selectedMembers + setOf( userPublicKey )).successUi { groupID ->
|
||||
binding.loaderContainer.fadeOut()
|
||||
isLoading = false
|
||||
val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getOrCreateThreadIdFor(Recipient.from(requireContext(), Address.fromSerialized(groupID), false))
|
||||
|
@ -176,6 +176,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
|
||||
// endregion
|
||||
|
||||
// region Updating
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
when (requestCode) {
|
||||
@ -335,7 +336,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
|
||||
?: return Log.w("Loki", "No recipient settings when trying to update group config")
|
||||
val latestGroup = storage.getGroup(groupID)
|
||||
?: return Log.w("Loki", "No group record when trying to update group config")
|
||||
groupConfigFactory.updateLegacyGroup(latestRecipient, latestGroup)
|
||||
groupConfigFactory.updateLegacyGroup(latestGroup)
|
||||
}
|
||||
|
||||
class GroupMembers(val members: List<String>, val zombieMembers: List<String>)
|
||||
|
@ -94,7 +94,7 @@ class ConversationView : LinearLayout {
|
||||
val senderDisplayName = getTitle(thread.recipient)
|
||||
?: thread.recipient.address.toString()
|
||||
binding.conversationViewDisplayNameTextView.text = senderDisplayName
|
||||
binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date)
|
||||
binding.timestampTextView.text = thread.date.takeIf { it != 0L }?.let { DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), it) }
|
||||
val recipient = thread.recipient
|
||||
binding.muteIndicatorImageView.isVisible = recipient.isMuted || recipient.notifyType != NOTIFY_TYPE_ALL
|
||||
val drawableRes = if (recipient.isMuted || recipient.notifyType == NOTIFY_TYPE_NONE) {
|
||||
|
@ -8,6 +8,7 @@ import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableString
|
||||
import android.widget.Toast
|
||||
@ -67,12 +68,13 @@ import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel
|
||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
import org.thoughtcrime.securesms.notifications.PushRegistry
|
||||
import org.thoughtcrime.securesms.onboarding.SeedActivity
|
||||
import org.thoughtcrime.securesms.onboarding.SeedReminderViewDelegate
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.preferences.SettingsActivity
|
||||
import org.thoughtcrime.securesms.showSessionDialog
|
||||
import org.thoughtcrime.securesms.showMuteDialog
|
||||
import org.thoughtcrime.securesms.showSessionDialog
|
||||
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.IP2Country
|
||||
@ -106,6 +108,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
@Inject lateinit var groupDatabase: GroupDatabase
|
||||
@Inject lateinit var textSecurePreferences: TextSecurePreferences
|
||||
@Inject lateinit var configFactory: ConfigFactory
|
||||
@Inject lateinit var pushRegistry: PushRegistry
|
||||
|
||||
private val globalSearchViewModel by viewModels<GlobalSearchViewModel>()
|
||||
private val homeViewModel by viewModels<HomeViewModel>()
|
||||
@ -230,8 +233,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
(applicationContext as ApplicationContext).startPollingIfNeeded()
|
||||
// update things based on TextSecurePrefs (profile info etc)
|
||||
// Set up remaining components if needed
|
||||
val application = ApplicationContext.getInstance(this@HomeActivity)
|
||||
application.registerForFCMIfNeeded(false)
|
||||
pushRegistry.refresh(false)
|
||||
if (textSecurePreferences.getLocalNumber() != null) {
|
||||
OpenGroupManager.startPolling()
|
||||
JobQueue.shared.resumePendingJobs()
|
||||
@ -298,13 +300,19 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
}
|
||||
EventBus.getDefault().register(this@HomeActivity)
|
||||
if (intent.hasExtra(FROM_ONBOARDING)
|
||||
&& intent.getBooleanExtra(FROM_ONBOARDING, false)
|
||||
&& !(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()
|
||||
) {
|
||||
&& intent.getBooleanExtra(FROM_ONBOARDING, false)) {
|
||||
if (Build.VERSION.SDK_INT >= 33 &&
|
||||
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled().not()) {
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.POST_NOTIFICATIONS)
|
||||
.execute()
|
||||
}
|
||||
configFactory.user?.let { user ->
|
||||
if (!user.isBlockCommunityMessageRequestsSet()) {
|
||||
user.setCommunityMessageRequests(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInputFocusChanged(hasFocus: Boolean) {
|
||||
@ -447,6 +455,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
// endregion
|
||||
|
||||
// region Interaction
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onBackPressed() {
|
||||
if (binding.globalSearchRecycler.isVisible) {
|
||||
binding.globalSearchInputLayout.clearSearch(true)
|
||||
|
@ -1,5 +1,7 @@
|
||||
package org.thoughtcrime.securesms.linkpreview;
|
||||
|
||||
import static org.session.libsession.utilities.Util.readFully;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
@ -8,8 +10,6 @@ import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.gms.common.util.IOUtils;
|
||||
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.Attachment;
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress;
|
||||
import org.session.libsession.messaging.sending_receiving.attachments.UriAttachment;
|
||||
@ -148,7 +148,7 @@ public class LinkPreviewRepository {
|
||||
InputStream bodyStream = response.body().byteStream();
|
||||
controller.setStream(bodyStream);
|
||||
|
||||
byte[] data = IOUtils.readInputStreamFully(bodyStream);
|
||||
byte[] data = readFully(bodyStream);
|
||||
Bitmap bitmap = BitmapFactory.decodeByteArray(data, 0, data.length);
|
||||
Optional<Attachment> thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.JPEG, MediaTypes.IMAGE_JPEG);
|
||||
|
||||
|
@ -1,28 +0,0 @@
|
||||
package org.thoughtcrime.securesms.longmessage;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
|
||||
/**
|
||||
* A wrapper around a {@link MessageRecord} and its extra text attachment expanded into a string
|
||||
* held in memory.
|
||||
*/
|
||||
class LongMessage {
|
||||
|
||||
private final MessageRecord messageRecord;
|
||||
private final String fullBody;
|
||||
|
||||
LongMessage(MessageRecord messageRecord, String fullBody) {
|
||||
this.messageRecord = messageRecord;
|
||||
this.fullBody = fullBody;
|
||||
}
|
||||
|
||||
MessageRecord getMessageRecord() {
|
||||
return messageRecord;
|
||||
}
|
||||
|
||||
String getFullBody() {
|
||||
return !TextUtils.isEmpty(fullBody) ? fullBody : messageRecord.getBody();
|
||||
}
|
||||
}
|
@ -1,91 +0,0 @@
|
||||
package org.thoughtcrime.securesms.longmessage;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.text.Spannable;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.view.MenuItem;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.session.libsession.utilities.Util;
|
||||
import org.session.libsession.utilities.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity;
|
||||
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentView;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
public class LongMessageActivity extends PassphraseRequiredActionBarActivity {
|
||||
|
||||
private static final String KEY_ADDRESS = "address";
|
||||
private static final String KEY_MESSAGE_ID = "message_id";
|
||||
private static final String KEY_IS_MMS = "is_mms";
|
||||
|
||||
private static final int MAX_DISPLAY_LENGTH = 64 * 1024;
|
||||
|
||||
private TextView textBody;
|
||||
|
||||
private LongMessageViewModel viewModel;
|
||||
|
||||
public static Intent getIntent(@NonNull Context context, @NonNull Address conversationAddress, long messageId, boolean isMms) {
|
||||
Intent intent = new Intent(context, LongMessageActivity.class);
|
||||
intent.putExtra(KEY_ADDRESS, conversationAddress.serialize());
|
||||
intent.putExtra(KEY_MESSAGE_ID, messageId);
|
||||
intent.putExtra(KEY_IS_MMS, isMms);
|
||||
return intent;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState, boolean ready) {
|
||||
super.onCreate(savedInstanceState, ready);
|
||||
setContentView(R.layout.longmessage_activity);
|
||||
textBody = findViewById(R.id.longmessage_text);
|
||||
|
||||
initViewModel(getIntent().getLongExtra(KEY_MESSAGE_ID, -1), getIntent().getBooleanExtra(KEY_IS_MMS, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
super.onOptionsItemSelected(item);
|
||||
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home:
|
||||
finish();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void initViewModel(long messageId, boolean isMms) {
|
||||
viewModel = new ViewModelProvider(this, new LongMessageViewModel.Factory(getApplication(), new LongMessageRepository(this), messageId, isMms))
|
||||
.get(LongMessageViewModel.class);
|
||||
|
||||
viewModel.getMessage().observe(this, message -> {
|
||||
if (message == null) return;
|
||||
|
||||
if (!message.isPresent()) {
|
||||
Toast.makeText(this, R.string.LongMessageActivity_unable_to_find_message, Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.get().getMessageRecord().isOutgoing()) {
|
||||
getSupportActionBar().setTitle(getString(R.string.LongMessageActivity_your_message));
|
||||
} else {
|
||||
Recipient recipient = message.get().getMessageRecord().getRecipient();
|
||||
String name = Util.getFirstNonEmpty(recipient.getName(), recipient.getProfileName(), recipient.getAddress().serialize());
|
||||
getSupportActionBar().setTitle(getString(R.string.LongMessageActivity_message_from_s, name));
|
||||
}
|
||||
Spannable bodySpans = VisibleMessageContentView.Companion.getBodySpans(this, message.get().getMessageRecord(), null);
|
||||
textBody.setText(bodySpans);
|
||||
textBody.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
});
|
||||
}
|
||||
|
||||
}
|
@ -1,102 +0,0 @@
|
||||
package org.thoughtcrime.securesms.longmessage;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.session.libsession.utilities.Util;
|
||||
import org.session.libsession.utilities.concurrent.SignalExecutors;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.session.libsignal.utilities.guava.Optional;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
import org.thoughtcrime.securesms.mms.TextSlide;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
class LongMessageRepository {
|
||||
|
||||
private final static String TAG = LongMessageRepository.class.getSimpleName();
|
||||
|
||||
private final MmsDatabase mmsDatabase;
|
||||
private final SmsDatabase smsDatabase;
|
||||
|
||||
LongMessageRepository(@NonNull Context context) {
|
||||
this.mmsDatabase = DatabaseComponent.get(context).mmsDatabase();
|
||||
this.smsDatabase = DatabaseComponent.get(context).smsDatabase();
|
||||
}
|
||||
|
||||
void getMessage(@NonNull Context context, long messageId, boolean isMms, @NonNull Callback<Optional<LongMessage>> callback) {
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
if (isMms) {
|
||||
callback.onComplete(getMmsLongMessage(context, mmsDatabase, messageId));
|
||||
} else {
|
||||
callback.onComplete(getSmsLongMessage(smsDatabase, messageId));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private Optional<LongMessage> getMmsLongMessage(@NonNull Context context, @NonNull MmsDatabase mmsDatabase, long messageId) {
|
||||
Optional<MmsMessageRecord> record = getMmsMessage(mmsDatabase, messageId);
|
||||
|
||||
if (record.isPresent()) {
|
||||
TextSlide textSlide = record.get().getSlideDeck().getTextSlide();
|
||||
|
||||
if (textSlide != null && textSlide.getUri() != null) {
|
||||
return Optional.of(new LongMessage(record.get(), readFullBody(context, textSlide.getUri())));
|
||||
} else {
|
||||
return Optional.of(new LongMessage(record.get(), ""));
|
||||
}
|
||||
} else {
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private Optional<LongMessage> getSmsLongMessage(@NonNull SmsDatabase smsDatabase, long messageId) {
|
||||
Optional<MessageRecord> record = getSmsMessage(smsDatabase, messageId);
|
||||
|
||||
if (record.isPresent()) {
|
||||
return Optional.of(new LongMessage(record.get(), ""));
|
||||
} else {
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@WorkerThread
|
||||
private Optional<MmsMessageRecord> getMmsMessage(@NonNull MmsDatabase mmsDatabase, long messageId) {
|
||||
try (Cursor cursor = mmsDatabase.getMessage(messageId)) {
|
||||
return Optional.fromNullable((MmsMessageRecord) mmsDatabase.readerFor(cursor).getNext());
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private Optional<MessageRecord> getSmsMessage(@NonNull SmsDatabase smsDatabase, long messageId) {
|
||||
try (Cursor cursor = smsDatabase.getMessageCursor(messageId)) {
|
||||
return Optional.fromNullable(smsDatabase.readerFor(cursor).getNext());
|
||||
}
|
||||
}
|
||||
|
||||
private String readFullBody(@NonNull Context context, @NonNull Uri uri) {
|
||||
try (InputStream stream = PartAuthority.getAttachmentStream(context, uri)) {
|
||||
return Util.readFullyAsString(stream);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed to read full text body.", e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback<T> {
|
||||
void onComplete(T result);
|
||||
}
|
||||
}
|
@ -1,84 +0,0 @@
|
||||
package org.thoughtcrime.securesms.longmessage;
|
||||
|
||||
import android.app.Application;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import android.database.ContentObserver;
|
||||
import android.net.Uri;
|
||||
import android.os.Handler;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseContentProviders;
|
||||
import org.session.libsignal.utilities.guava.Optional;
|
||||
|
||||
class LongMessageViewModel extends ViewModel {
|
||||
|
||||
private final Application application;
|
||||
private final LongMessageRepository repository;
|
||||
private final long messageId;
|
||||
private final boolean isMms;
|
||||
|
||||
private final MutableLiveData<Optional<LongMessage>> message;
|
||||
private final MessageObserver messageObserver;
|
||||
|
||||
private LongMessageViewModel(@NonNull Application application, @NonNull LongMessageRepository repository, long messageId, boolean isMms) {
|
||||
this.application = application;
|
||||
this.repository = repository;
|
||||
this.messageId = messageId;
|
||||
this.isMms = isMms;
|
||||
this.message = new MutableLiveData<>();
|
||||
this.messageObserver = new MessageObserver(new Handler());
|
||||
|
||||
repository.getMessage(application, messageId, isMms, longMessage -> {
|
||||
if (longMessage.isPresent()) {
|
||||
Uri uri = DatabaseContentProviders.Conversation.getUriForThread(longMessage.get().getMessageRecord().getThreadId());
|
||||
application.getContentResolver().registerContentObserver(uri, true, messageObserver);
|
||||
}
|
||||
|
||||
message.postValue(longMessage);
|
||||
});
|
||||
}
|
||||
|
||||
LiveData<Optional<LongMessage>> getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCleared() {
|
||||
application.getContentResolver().unregisterContentObserver(messageObserver);
|
||||
}
|
||||
|
||||
private class MessageObserver extends ContentObserver {
|
||||
MessageObserver(Handler handler) {
|
||||
super(handler);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChange(boolean selfChange) {
|
||||
repository.getMessage(application, messageId, isMms, message::postValue);
|
||||
}
|
||||
}
|
||||
|
||||
static class Factory extends ViewModelProvider.NewInstanceFactory {
|
||||
|
||||
private final Application context;
|
||||
private final LongMessageRepository repository;
|
||||
private final long messageId;
|
||||
private final boolean isMms;
|
||||
|
||||
public Factory(@NonNull Application application, @NonNull LongMessageRepository repository, long messageId, boolean isMms) {
|
||||
this.context = application;
|
||||
this.repository = repository;
|
||||
this.messageId = messageId;
|
||||
this.isMms = isMms;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull<T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
return modelClass.cast(new LongMessageViewModel(context, repository, messageId, isMms));
|
||||
}
|
||||
}
|
||||
}
|
@ -37,7 +37,7 @@ import org.thoughtcrime.securesms.components.emoji.EmojiEventListener;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
|
||||
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
|
||||
import org.thoughtcrime.securesms.util.SimpleTextWatcher;
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
|
||||
|
@ -27,7 +27,7 @@ import androidx.core.app.NotificationManagerCompat;
|
||||
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
|
||||
import org.thoughtcrime.securesms.database.MarkedMessageInfo;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
|
||||
import java.util.LinkedList;
|
||||
|
@ -26,6 +26,7 @@ import android.os.Bundle;
|
||||
|
||||
import androidx.core.app.RemoteInput;
|
||||
|
||||
import org.session.libsession.messaging.messages.ExpirationConfiguration;
|
||||
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage;
|
||||
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage;
|
||||
import org.session.libsession.messaging.messages.visible.VisibleMessage;
|
||||
@ -35,13 +36,15 @@ import org.session.libsession.utilities.Address;
|
||||
import org.session.libsession.utilities.recipients.Recipient;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
|
||||
import org.thoughtcrime.securesms.database.MarkedMessageInfo;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
import org.thoughtcrime.securesms.mms.MmsException;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import network.loki.messenger.libsession_util.util.ExpiryMode;
|
||||
|
||||
/**
|
||||
* Get the response text from the Android Auto and sends an message as a reply
|
||||
*/
|
||||
@ -85,10 +88,14 @@ public class AndroidAutoReplyReceiver extends BroadcastReceiver {
|
||||
message.setText(responseText.toString());
|
||||
message.setSentTimestamp(SnodeAPI.getNowWithOffset());
|
||||
MessageSender.send(message, recipient.getAddress());
|
||||
ExpirationConfiguration config = DatabaseComponent.get(context).storage().getExpirationConfiguration(threadId);
|
||||
ExpiryMode expiryMode = config == null ? null : config.getExpiryMode();
|
||||
long expiresInMillis = expiryMode == null ? 0 : expiryMode.getExpiryMillis();
|
||||
long expireStartedAt = expiryMode instanceof ExpiryMode.AfterSend ? message.getSentTimestamp() : 0L;
|
||||
|
||||
if (recipient.isGroupRecipient()) {
|
||||
Log.w("AndroidAutoReplyReceiver", "GroupRecipient, Sending media message");
|
||||
OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null);
|
||||
OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null, expiresInMillis, 0);
|
||||
try {
|
||||
DatabaseComponent.get(context).mmsDatabase().insertMessageOutbox(reply, replyThreadId, false, null, true);
|
||||
} catch (MmsException e) {
|
||||
@ -96,7 +103,7 @@ public class AndroidAutoReplyReceiver extends BroadcastReceiver {
|
||||
}
|
||||
} else {
|
||||
Log.w("AndroidAutoReplyReceiver", "Sending regular message ");
|
||||
OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient);
|
||||
OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt);
|
||||
DatabaseComponent.get(context).smsDatabase().insertMessageOutbox(replyThreadId, reply, false, SnodeAPI.getNowWithOffset(), null, true);
|
||||
}
|
||||
|
||||
|
@ -54,7 +54,7 @@ import org.session.libsignal.utilities.IdPrefix;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.session.libsignal.utilities.Util;
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
import org.thoughtcrime.securesms.contactshare.ContactUtil;
|
||||
import org.thoughtcrime.securesms.contacts.ContactUtil;
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2;
|
||||
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities;
|
||||
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities;
|
||||
|
@ -1,19 +0,0 @@
|
||||
@file:JvmName("FcmUtils")
|
||||
package org.thoughtcrime.securesms.notifications
|
||||
|
||||
import com.google.android.gms.tasks.Task
|
||||
import com.google.firebase.iid.FirebaseInstanceId
|
||||
import com.google.firebase.iid.InstanceIdResult
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
|
||||
fun getFcmInstanceId(body: (Task<InstanceIdResult>)->Unit): Job = MainScope().launch(Dispatchers.IO) {
|
||||
val task = FirebaseInstanceId.getInstance().instanceId
|
||||
while (!task.isComplete && isActive) {
|
||||
// wait for task to complete while we are active
|
||||
}
|
||||
if (!isActive) return@launch // don't 'complete' task if we were canceled
|
||||
withContext(Dispatchers.Main) {
|
||||
body(task)
|
||||
}
|
||||
}
|
@ -1,121 +0,0 @@
|
||||
package org.thoughtcrime.securesms.notifications
|
||||
|
||||
import android.content.Context
|
||||
import nl.komponents.kovenant.Promise
|
||||
import nl.komponents.kovenant.functional.map
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
|
||||
import org.session.libsession.snode.OnionRequestAPI
|
||||
import org.session.libsession.snode.Version
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsignal.utilities.JsonUtil
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.session.libsignal.utilities.retryIfNeeded
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
|
||||
object LokiPushNotificationManager {
|
||||
private val maxRetryCount = 4
|
||||
private val tokenExpirationInterval = 12 * 60 * 60 * 1000
|
||||
|
||||
private val server by lazy {
|
||||
PushNotificationAPI.server
|
||||
}
|
||||
private val pnServerPublicKey by lazy {
|
||||
PushNotificationAPI.serverPublicKey
|
||||
}
|
||||
|
||||
enum class ClosedGroupOperation {
|
||||
Subscribe, Unsubscribe;
|
||||
|
||||
val rawValue: String
|
||||
get() {
|
||||
return when (this) {
|
||||
Subscribe -> "subscribe_closed_group"
|
||||
Unsubscribe -> "unsubscribe_closed_group"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun unregister(token: String, context: Context) {
|
||||
val parameters = mapOf( "token" to token )
|
||||
val url = "$server/unregister"
|
||||
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
|
||||
val request = Request.Builder().url(url).post(body)
|
||||
retryIfNeeded(maxRetryCount) {
|
||||
getResponseBody(request.build()).map { json ->
|
||||
val code = json["code"] as? Int
|
||||
if (code != null && code != 0) {
|
||||
TextSecurePreferences.setIsUsingFCM(context, false)
|
||||
} else {
|
||||
Log.d("Loki", "Couldn't disable FCM due to error: ${json["message"] as? String ?: "null"}.")
|
||||
}
|
||||
}.fail { exception ->
|
||||
Log.d("Loki", "Couldn't disable FCM due to error: ${exception}.")
|
||||
}
|
||||
}
|
||||
// Unsubscribe from all closed groups
|
||||
val allClosedGroupPublicKeys = DatabaseComponent.get(context).lokiAPIDatabase().getAllClosedGroupPublicKeys()
|
||||
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
|
||||
allClosedGroupPublicKeys.iterator().forEach { closedGroup ->
|
||||
performOperation(context, ClosedGroupOperation.Unsubscribe, closedGroup, userPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun register(token: String, publicKey: String, context: Context, force: Boolean) {
|
||||
val oldToken = TextSecurePreferences.getFCMToken(context)
|
||||
val lastUploadDate = TextSecurePreferences.getLastFCMUploadTime(context)
|
||||
if (!force && token == oldToken && System.currentTimeMillis() - lastUploadDate < tokenExpirationInterval) { return }
|
||||
val parameters = mapOf( "token" to token, "pubKey" to publicKey )
|
||||
val url = "$server/register"
|
||||
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
|
||||
val request = Request.Builder().url(url).post(body)
|
||||
retryIfNeeded(maxRetryCount) {
|
||||
getResponseBody(request.build()).map { json ->
|
||||
val code = json["code"] as? Int
|
||||
if (code != null && code != 0) {
|
||||
TextSecurePreferences.setIsUsingFCM(context, true)
|
||||
TextSecurePreferences.setFCMToken(context, token)
|
||||
TextSecurePreferences.setLastFCMUploadTime(context, System.currentTimeMillis())
|
||||
} else {
|
||||
Log.d("Loki", "Couldn't register for FCM due to error: ${json["message"] as? String ?: "null"}.")
|
||||
}
|
||||
}.fail { exception ->
|
||||
Log.d("Loki", "Couldn't register for FCM due to error: ${exception}.")
|
||||
}
|
||||
}
|
||||
// Subscribe to all closed groups
|
||||
val allClosedGroupPublicKeys = DatabaseComponent.get(context).lokiAPIDatabase().getAllClosedGroupPublicKeys()
|
||||
allClosedGroupPublicKeys.iterator().forEach { closedGroup ->
|
||||
performOperation(context, ClosedGroupOperation.Subscribe, closedGroup, publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun performOperation(context: Context, operation: ClosedGroupOperation, closedGroupPublicKey: String, publicKey: String) {
|
||||
if (!TextSecurePreferences.isUsingFCM(context)) { return }
|
||||
val parameters = mapOf( "closedGroupPublicKey" to closedGroupPublicKey, "pubKey" to publicKey )
|
||||
val url = "$server/${operation.rawValue}"
|
||||
val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
|
||||
val request = Request.Builder().url(url).post(body)
|
||||
retryIfNeeded(maxRetryCount) {
|
||||
getResponseBody(request.build()).map { json ->
|
||||
val code = json["code"] as? Int
|
||||
if (code == null || code == 0) {
|
||||
Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${json["message"] as? String ?: "null"}.")
|
||||
}
|
||||
}.fail { exception ->
|
||||
Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${exception}.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getResponseBody(request: Request): Promise<Map<*, *>, Exception> {
|
||||
return OnionRequestAPI.sendOnionRequest(request, server, pnServerPublicKey, Version.V2).map { response ->
|
||||
JsonUtil.fromJson(response.body, Map::class.java)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,100 +0,0 @@
|
||||
package org.thoughtcrime.securesms.notifications;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.AsyncTask;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.session.libsession.database.StorageProtocol;
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration;
|
||||
import org.session.libsession.messaging.messages.control.ReadReceipt;
|
||||
import org.session.libsession.messaging.sending_receiving.MessageSender;
|
||||
import org.session.libsession.snode.SnodeAPI;
|
||||
import org.session.libsession.utilities.Address;
|
||||
import org.session.libsession.utilities.TextSecurePreferences;
|
||||
import org.session.libsession.utilities.recipients.Recipient;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
import org.thoughtcrime.securesms.database.MessagingDatabase.ExpirationInfo;
|
||||
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
|
||||
import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId;
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
|
||||
import org.thoughtcrime.securesms.util.SessionMetaProtocol;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class MarkReadReceiver extends BroadcastReceiver {
|
||||
|
||||
private static final String TAG = MarkReadReceiver.class.getSimpleName();
|
||||
public static final String CLEAR_ACTION = "network.loki.securesms.notifications.CLEAR";
|
||||
public static final String THREAD_IDS_EXTRA = "thread_ids";
|
||||
public static final String NOTIFICATION_ID_EXTRA = "notification_id";
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
@Override
|
||||
public void onReceive(final Context context, Intent intent) {
|
||||
if (!CLEAR_ACTION.equals(intent.getAction()))
|
||||
return;
|
||||
|
||||
final long[] threadIds = intent.getLongArrayExtra(THREAD_IDS_EXTRA);
|
||||
|
||||
if (threadIds != null) {
|
||||
NotificationManagerCompat.from(context).cancel(intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1));
|
||||
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
@Override
|
||||
protected Void doInBackground(Void... params) {
|
||||
long currentTime = SnodeAPI.getNowWithOffset();
|
||||
for (long threadId : threadIds) {
|
||||
Log.i(TAG, "Marking as read: " + threadId);
|
||||
StorageProtocol storage = MessagingModuleConfiguration.getShared().getStorage();
|
||||
storage.markConversationAsRead(threadId,currentTime, true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
}
|
||||
}
|
||||
|
||||
public static void process(@NonNull Context context, @NonNull List<MarkedMessageInfo> markedReadMessages) {
|
||||
if (markedReadMessages.isEmpty()) return;
|
||||
|
||||
for (MarkedMessageInfo messageInfo : markedReadMessages) {
|
||||
scheduleDeletion(context, messageInfo.getExpirationInfo());
|
||||
}
|
||||
|
||||
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) return;
|
||||
|
||||
Map<Address, List<SyncMessageId>> addressMap = Stream.of(markedReadMessages)
|
||||
.map(MarkedMessageInfo::getSyncMessageId)
|
||||
.collect(Collectors.groupingBy(SyncMessageId::getAddress));
|
||||
|
||||
for (Address address : addressMap.keySet()) {
|
||||
List<Long> timestamps = Stream.of(addressMap.get(address)).map(SyncMessageId::getTimetamp).toList();
|
||||
if (!SessionMetaProtocol.shouldSendReadReceipt(Recipient.from(context, address, false))) { continue; }
|
||||
ReadReceipt readReceipt = new ReadReceipt(timestamps);
|
||||
readReceipt.setSentTimestamp(SnodeAPI.getNowWithOffset());
|
||||
MessageSender.send(readReceipt, address);
|
||||
}
|
||||
}
|
||||
|
||||
public static void scheduleDeletion(Context context, ExpirationInfo expirationInfo) {
|
||||
if (expirationInfo.getExpiresIn() > 0 && expirationInfo.getExpireStarted() <= 0) {
|
||||
ExpiringMessageManager expirationManager = ApplicationContext.getInstance(context).getExpiringMessageManager();
|
||||
|
||||
if (expirationInfo.isMms()) DatabaseComponent.get(context).mmsDatabase().markExpireStarted(expirationInfo.getId());
|
||||
else DatabaseComponent.get(context).smsDatabase().markExpireStarted(expirationInfo.getId());
|
||||
|
||||
expirationManager.scheduleDeletion(expirationInfo.getId(), expirationInfo.isMms(), expirationInfo.getExpiresIn());
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,157 @@
|
||||
package org.thoughtcrime.securesms.notifications
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.AsyncTask
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared
|
||||
import org.session.libsession.messaging.messages.control.ReadReceipt
|
||||
import org.session.libsession.messaging.sending_receiving.MessageSender.send
|
||||
import org.session.libsession.snode.SnodeAPI
|
||||
import org.session.libsession.snode.SnodeAPI.nowWithOffset
|
||||
import org.session.libsession.utilities.SSKEnvironment
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled
|
||||
import org.session.libsession.utilities.associateByNotNull
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.thoughtcrime.securesms.ApplicationContext
|
||||
import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType
|
||||
import org.thoughtcrime.securesms.database.ExpirationInfo
|
||||
import org.thoughtcrime.securesms.database.MarkedMessageInfo
|
||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||
import org.thoughtcrime.securesms.util.SessionMetaProtocol.shouldSendReadReceipt
|
||||
|
||||
class MarkReadReceiver : BroadcastReceiver() {
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (CLEAR_ACTION != intent.action) return
|
||||
val threadIds = intent.getLongArrayExtra(THREAD_IDS_EXTRA) ?: return
|
||||
NotificationManagerCompat.from(context).cancel(intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1))
|
||||
object : AsyncTask<Void?, Void?, Void?>() {
|
||||
override fun doInBackground(vararg params: Void?): Void? {
|
||||
val currentTime = nowWithOffset
|
||||
threadIds.forEach {
|
||||
Log.i(TAG, "Marking as read: $it")
|
||||
shared.storage.markConversationAsRead(it, currentTime, true)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = MarkReadReceiver::class.java.simpleName
|
||||
const val CLEAR_ACTION = "network.loki.securesms.notifications.CLEAR"
|
||||
const val THREAD_IDS_EXTRA = "thread_ids"
|
||||
const val NOTIFICATION_ID_EXTRA = "notification_id"
|
||||
|
||||
val messageExpirationManager = SSKEnvironment.shared.messageExpirationManager
|
||||
|
||||
@JvmStatic
|
||||
fun process(
|
||||
context: Context,
|
||||
markedReadMessages: List<MarkedMessageInfo>
|
||||
) {
|
||||
if (markedReadMessages.isEmpty()) return
|
||||
|
||||
sendReadReceipts(context, markedReadMessages)
|
||||
|
||||
val mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase()
|
||||
|
||||
// start disappear after read messages except TimerUpdates in groups.
|
||||
markedReadMessages
|
||||
.filter { it.expiryType == ExpiryType.AFTER_READ }
|
||||
.map { it.syncMessageId }
|
||||
.filter { mmsSmsDatabase.getMessageForTimestamp(it.timetamp)?.run { isExpirationTimerUpdate && recipient.isClosedGroupRecipient } == false }
|
||||
.forEach { messageExpirationManager.startDisappearAfterRead(it.timetamp, it.address.serialize()) }
|
||||
|
||||
hashToDisappearAfterReadMessage(context, markedReadMessages)?.let {
|
||||
fetchUpdatedExpiriesAndScheduleDeletion(context, it)
|
||||
shortenExpiryOfDisappearingAfterRead(context, it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hashToDisappearAfterReadMessage(
|
||||
context: Context,
|
||||
markedReadMessages: List<MarkedMessageInfo>
|
||||
): Map<String, MarkedMessageInfo>? {
|
||||
val loki = DatabaseComponent.get(context).lokiMessageDatabase()
|
||||
|
||||
return markedReadMessages
|
||||
.filter { it.expiryType == ExpiryType.AFTER_READ }
|
||||
.associateByNotNull { it.expirationInfo.run { loki.getMessageServerHash(id, isMms) } }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun shortenExpiryOfDisappearingAfterRead(
|
||||
context: Context,
|
||||
hashToMessage: Map<String, MarkedMessageInfo>
|
||||
) {
|
||||
hashToMessage.entries
|
||||
.groupBy(
|
||||
keySelector = { it.value.expirationInfo.expiresIn },
|
||||
valueTransform = { it.key }
|
||||
).forEach { (expiresIn, hashes) ->
|
||||
SnodeAPI.alterTtl(
|
||||
messageHashes = hashes,
|
||||
newExpiry = nowWithOffset + expiresIn,
|
||||
publicKey = TextSecurePreferences.getLocalNumber(context)!!,
|
||||
shorten = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendReadReceipts(
|
||||
context: Context,
|
||||
markedReadMessages: List<MarkedMessageInfo>
|
||||
) {
|
||||
if (!isReadReceiptsEnabled(context)) return
|
||||
|
||||
markedReadMessages.map { it.syncMessageId }
|
||||
.filter { shouldSendReadReceipt(Recipient.from(context, it.address, false)) }
|
||||
.groupBy { it.address }
|
||||
.forEach { (address, messages) ->
|
||||
messages.map { it.timetamp }
|
||||
.let(::ReadReceipt)
|
||||
.apply { sentTimestamp = nowWithOffset }
|
||||
.let { send(it, address) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchUpdatedExpiriesAndScheduleDeletion(
|
||||
context: Context,
|
||||
hashToMessage: Map<String, MarkedMessageInfo>
|
||||
) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val expiries = SnodeAPI.getExpiries(hashToMessage.keys.toList(), TextSecurePreferences.getLocalNumber(context)!!).get()["expiries"] as Map<String, Long>
|
||||
hashToMessage.forEach { (hash, info) -> expiries[hash]?.let { scheduleDeletion(context, info.expirationInfo, it - info.expirationInfo.expireStarted) } }
|
||||
}
|
||||
|
||||
private fun scheduleDeletion(
|
||||
context: Context?,
|
||||
expirationInfo: ExpirationInfo,
|
||||
expiresIn: Long = expirationInfo.expiresIn
|
||||
) {
|
||||
if (expiresIn == 0L) return
|
||||
|
||||
val now = nowWithOffset
|
||||
|
||||
val expireStarted = expirationInfo.expireStarted
|
||||
|
||||
if (expirationInfo.isDisappearAfterRead() && expireStarted == 0L || now < expireStarted) {
|
||||
val db = DatabaseComponent.get(context!!).run { if (expirationInfo.isMms) mmsDatabase() else smsDatabase() }
|
||||
db.markExpireStarted(expirationInfo.id, now)
|
||||
}
|
||||
|
||||
ApplicationContext.getInstance(context).expiringMessageManager.scheduleDeletion(
|
||||
expirationInfo.id,
|
||||
expirationInfo.isMms,
|
||||
now,
|
||||
expiresIn
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
package org.thoughtcrime.securesms.notifications
|
||||
|
||||
interface PushManager {
|
||||
fun refresh(force: Boolean)
|
||||
}
|
@ -1,58 +0,0 @@
|
||||
package org.thoughtcrime.securesms.notifications
|
||||
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
|
||||
import org.session.libsession.messaging.jobs.JobQueue
|
||||
import org.session.libsession.messaging.jobs.MessageReceiveParameters
|
||||
import org.session.libsession.messaging.utilities.MessageWrapper
|
||||
import org.session.libsession.utilities.TextSecurePreferences
|
||||
import org.session.libsignal.utilities.Base64
|
||||
import org.session.libsignal.utilities.Log
|
||||
|
||||
class PushNotificationService : FirebaseMessagingService() {
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
super.onNewToken(token)
|
||||
Log.d("Loki", "New FCM token: $token.")
|
||||
val userPublicKey = TextSecurePreferences.getLocalNumber(this) ?: return
|
||||
LokiPushNotificationManager.register(token, userPublicKey, this, false)
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
Log.d("Loki", "Received a push notification.")
|
||||
val base64EncodedData = message.data?.get("ENCRYPTED_DATA")
|
||||
val data = base64EncodedData?.let { Base64.decode(it) }
|
||||
if (data != null) {
|
||||
try {
|
||||
val envelopeAsData = MessageWrapper.unwrap(data).toByteArray()
|
||||
val job = BatchMessageReceiveJob(listOf(MessageReceiveParameters(envelopeAsData)), null)
|
||||
JobQueue.shared.add(job)
|
||||
} catch (e: Exception) {
|
||||
Log.d("Loki", "Failed to unwrap data for message due to error: $e.")
|
||||
}
|
||||
} else {
|
||||
Log.d("Loki", "Failed to decode data for message.")
|
||||
val builder = NotificationCompat.Builder(this, NotificationChannels.OTHER)
|
||||
.setSmallIcon(network.loki.messenger.R.drawable.ic_notification)
|
||||
.setColor(this.getResources().getColor(network.loki.messenger.R.color.textsecure_primary))
|
||||
.setContentTitle("Session")
|
||||
.setContentText("You've got a new message.")
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setAutoCancel(true)
|
||||
with(NotificationManagerCompat.from(this)) {
|
||||
notify(11111, builder.build())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDeletedMessages() {
|
||||
Log.d("Loki", "Called onDeletedMessages.")
|
||||
super.onDeletedMessages()
|
||||
val token = TextSecurePreferences.getFCMToken(this)!!
|
||||
val userPublicKey = TextSecurePreferences.getLocalNumber(this) ?: return
|
||||
LokiPushNotificationManager.register(token, userPublicKey, this, true)
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user