mirror of
https://github.com/oxen-io/session-android.git
synced 2025-12-05 10:42:16 +00:00
Merge branch 'dev' into swap-video-views
This commit is contained in:
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
|
ffpr
|
||||||
*.sh
|
*.sh
|
||||||
pkcs11.password
|
pkcs11.password
|
||||||
play
|
app/play
|
||||||
|
app/huawei
|
||||||
|
|
||||||
|
!/scripts/drone-static-upload.sh
|
||||||
|
!/scripts/drone-upload-exists.sh
|
||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "libsession-util/libsession-util"]
|
||||||
|
path = libsession-util/libsession-util
|
||||||
|
url = https://github.com/oxen-io/libsession-util.git
|
||||||
24
.run/Run Tests.run.xml
Normal file
24
.run/Run Tests.run.xml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Run Tests" type="GradleRunConfiguration" factoryName="Gradle">
|
||||||
|
<ExternalSystemSettings>
|
||||||
|
<option name="executionName" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="externalSystemIdString" value="GRADLE" />
|
||||||
|
<option name="scriptParameters" value="" />
|
||||||
|
<option name="taskDescriptions">
|
||||||
|
<list />
|
||||||
|
</option>
|
||||||
|
<option name="taskNames">
|
||||||
|
<list>
|
||||||
|
<option value="testPlayDebugUnitTestCoverageReport" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="vmOptions" />
|
||||||
|
</ExternalSystemSettings>
|
||||||
|
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||||
|
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||||
|
<DebugAllEnabled>false</DebugAllEnabled>
|
||||||
|
<RunAsTest>false</RunAsTest>
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -32,6 +32,13 @@ Setting up a development environment and building from Android Studio
|
|||||||
4. Android Studio should detect the presence of a project file and ask you whether to open it. Click "yes".
|
4. Android Studio should detect the presence of a project file and ask you whether to open it. Click "yes".
|
||||||
5. Default config options should be good enough.
|
5. Default config options should be good enough.
|
||||||
6. Project initialization and building should proceed.
|
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
|
Contributing code
|
||||||
-----------------
|
-----------------
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
# Session Android
|
# Session Android
|
||||||
|
|
||||||
[Download on the Google Play Store](https://getsession.org/android)
|
[Download on the Google Play Store](https://getsession.org/android)
|
||||||
|
|
||||||
Add the [F-Droid repo](https://fdroid.getsession.org/)
|
Add the [F-Droid repo](https://fdroid.getsession.org/)
|
||||||
|
|
||||||
[Download the APK from here](https://github.com/loki-project/session-android/releases/latest)
|
[Download the APK from here](https://github.com/oxen-io/session-android/releases/latest)
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Session integrates directly with [Oxen Service Nodes](https://docs.oxen.io/about-the-oxen-blockchain/oxen-service-nodes), which are a set of distributed, decentralized and Sybil resistant nodes. Service Nodes act as servers which store messages offline, and a set of nodes which allow for onion routing functionality obfuscating users' IP addresses. For a full understanding of how Session works, read the [Session Whitepaper](https://getsession.org/whitepaper).
|
Session integrates directly with [Oxen Service Nodes](https://docs.oxen.io/about-the-oxen-blockchain/oxen-service-nodes), which are a set of distributed, decentralized and Sybil resistant nodes. Service Nodes act as servers which store messages offline, and a set of nodes which allow for onion routing functionality obfuscating users' IP addresses. For a full understanding of how Session works, read the [Session Whitepaper](https://getsession.org/whitepaper).
|
||||||
|
|
||||||
<img src="https://i.imgur.com/dO9f7Hg.jpg" width="320" />
|
<img src="https://i.imgur.com/wcdAGBh.png" width="320" />
|
||||||
|
|
||||||
## Want to contribute? Found a bug or have a feature request?
|
## Want to contribute? Found a bug or have a feature request?
|
||||||
|
|
||||||
|
|||||||
357
app/build.gradle
357
app/build.gradle
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
@@ -13,12 +14,16 @@ buildscript {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id 'kotlin-kapt'
|
||||||
|
id 'com.google.dagger.hilt.android'
|
||||||
|
}
|
||||||
|
|
||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
apply plugin: 'kotlin-android'
|
apply plugin: 'kotlin-android'
|
||||||
apply plugin: 'witness'
|
apply plugin: 'witness'
|
||||||
apply plugin: 'kotlin-kapt'
|
apply plugin: 'kotlin-kapt'
|
||||||
apply plugin: 'kotlin-parcelize'
|
apply plugin: 'kotlin-parcelize'
|
||||||
apply plugin: 'com.google.gms.google-services'
|
|
||||||
apply plugin: 'kotlinx-serialization'
|
apply plugin: 'kotlinx-serialization'
|
||||||
apply plugin: 'dagger.hilt.android.plugin'
|
apply plugin: 'dagger.hilt.android.plugin'
|
||||||
|
|
||||||
@@ -26,141 +31,8 @@ configurations.all {
|
|||||||
exclude module: "commons-logging"
|
exclude module: "commons-logging"
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
def canonicalVersionCode = 369
|
||||||
implementation "androidx.appcompat:appcompat:$appcompatVersion"
|
def canonicalVersionName = "1.18.1"
|
||||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
|
||||||
implementation "com.google.android.material:material:$materialVersion"
|
|
||||||
implementation 'com.google.android:flexbox:2.0.1'
|
|
||||||
implementation 'androidx.legacy:legacy-support-v13:1.0.0'
|
|
||||||
implementation 'androidx.cardview:cardview:1.0.0'
|
|
||||||
implementation "androidx.preference:preference-ktx:$preferenceVersion"
|
|
||||||
implementation 'androidx.legacy:legacy-preference-v14:1.0.0'
|
|
||||||
implementation 'androidx.gridlayout:gridlayout:1.0.0'
|
|
||||||
implementation 'androidx.exifinterface:exifinterface:1.3.4'
|
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
|
||||||
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
|
|
||||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
|
|
||||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
|
|
||||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
|
|
||||||
implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion"
|
|
||||||
implementation "androidx.paging:paging-runtime-ktx:$pagingVersion"
|
|
||||||
implementation 'androidx.activity:activity-ktx:1.5.1'
|
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.5.3'
|
|
||||||
implementation "androidx.core:core-ktx:$coreVersion"
|
|
||||||
implementation "androidx.work:work-runtime-ktx:2.7.1"
|
|
||||||
implementation ("com.google.firebase:firebase-messaging:18.0.0") {
|
|
||||||
exclude group: 'com.google.firebase', module: 'firebase-core'
|
|
||||||
exclude group: 'com.google.firebase', module: 'firebase-analytics'
|
|
||||||
exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
|
|
||||||
}
|
|
||||||
implementation 'com.google.android.exoplayer:exoplayer-core:2.9.1'
|
|
||||||
implementation 'com.google.android.exoplayer:exoplayer-ui:2.9.1'
|
|
||||||
implementation 'org.conscrypt:conscrypt-android:2.0.0'
|
|
||||||
implementation 'org.signal:aesgcmprovider:0.0.3'
|
|
||||||
implementation 'org.webrtc:google-webrtc:1.0.32006'
|
|
||||||
implementation "me.leolin:ShortcutBadger:1.1.16"
|
|
||||||
implementation 'se.emilsjolander:stickylistheaders:2.7.0'
|
|
||||||
implementation 'com.jpardogo.materialtabstrip:library:1.0.9'
|
|
||||||
implementation 'org.apache.httpcomponents:httpclient-android:4.3.5'
|
|
||||||
implementation 'commons-net:commons-net:3.7.2'
|
|
||||||
implementation 'com.github.chrisbanes:PhotoView:2.1.3'
|
|
||||||
implementation "com.github.bumptech.glide:glide:$glideVersion"
|
|
||||||
annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion"
|
|
||||||
kapt "com.github.bumptech.glide:compiler:$glideVersion"
|
|
||||||
implementation 'com.makeramen:roundedimageview:2.1.0'
|
|
||||||
implementation 'com.pnikosis:materialish-progress:1.5'
|
|
||||||
implementation 'org.greenrobot:eventbus:3.0.0'
|
|
||||||
implementation 'pl.tajchert:waitingdots:0.1.0'
|
|
||||||
implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0'
|
|
||||||
implementation 'com.melnykov:floatingactionbutton:1.3.0'
|
|
||||||
implementation 'com.google.zxing:android-integration:3.1.0'
|
|
||||||
implementation "com.google.dagger:hilt-android:$daggerVersion"
|
|
||||||
kapt "com.google.dagger:hilt-compiler:$daggerVersion"
|
|
||||||
implementation 'mobi.upod:time-duration-picker:1.1.3'
|
|
||||||
implementation 'com.google.zxing:core:3.2.1'
|
|
||||||
implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.6.0') {
|
|
||||||
exclude group: 'com.android.support', module: 'support-annotations'
|
|
||||||
}
|
|
||||||
implementation ('cn.carbswang.android:NumberPickerView:1.0.9') {
|
|
||||||
exclude group: 'com.android.support', module: 'appcompat-v7'
|
|
||||||
}
|
|
||||||
implementation ('com.tomergoldst.android:tooltips:1.0.6') {
|
|
||||||
exclude group: 'com.android.support', module: 'appcompat-v7'
|
|
||||||
}
|
|
||||||
implementation ('com.klinkerapps:android-smsmms:4.0.1') {
|
|
||||||
exclude group: 'com.squareup.okhttp', module: 'okhttp'
|
|
||||||
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
|
|
||||||
}
|
|
||||||
implementation 'com.annimon:stream:1.1.8'
|
|
||||||
implementation 'com.takisoft.fix:colorpicker:1.0.1'
|
|
||||||
implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
|
|
||||||
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
|
|
||||||
implementation 'androidx.sqlite:sqlite-ktx:2.2.0'
|
|
||||||
implementation 'net.zetetic:sqlcipher-android:4.5.3@aar'
|
|
||||||
implementation ('com.googlecode.ez-vcard:ez-vcard:0.9.11') {
|
|
||||||
exclude group: 'com.fasterxml.jackson.core'
|
|
||||||
exclude group: 'org.freemarker'
|
|
||||||
}
|
|
||||||
implementation project(":libsignal")
|
|
||||||
implementation project(":libsession")
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion"
|
|
||||||
implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version"
|
|
||||||
implementation project(":liblazysodium")
|
|
||||||
implementation "net.java.dev.jna:jna:5.8.0@aar"
|
|
||||||
implementation "com.google.protobuf:protobuf-java:$protobufVersion"
|
|
||||||
implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion"
|
|
||||||
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
|
|
||||||
implementation 'app.cash.copper:copper-flow:1.0.0'
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
|
|
||||||
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
|
|
||||||
implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion"
|
|
||||||
implementation "com.github.lelloman:android-identicons:v11"
|
|
||||||
implementation "com.prof.rssparser:rssparser:2.0.4"
|
|
||||||
implementation "com.jakewharton.rxbinding3:rxbinding:3.1.0"
|
|
||||||
implementation "com.github.tbruyelle:rxpermissions:0.10.2"
|
|
||||||
implementation "com.github.ybq:Android-SpinKit:1.4.0"
|
|
||||||
implementation "com.opencsv:opencsv:4.6"
|
|
||||||
testImplementation "junit:junit:$junitVersion"
|
|
||||||
testImplementation 'org.assertj:assertj-core:3.11.1'
|
|
||||||
testImplementation "org.mockito:mockito-inline:4.0.0"
|
|
||||||
testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
|
|
||||||
testImplementation 'org.powermock:powermock-api-mockito:1.6.1'
|
|
||||||
testImplementation 'org.powermock:powermock-module-junit4:1.6.1'
|
|
||||||
testImplementation 'org.powermock:powermock-module-junit4-rule:1.6.1'
|
|
||||||
testImplementation 'org.powermock:powermock-classloading-xstream:1.6.1'
|
|
||||||
testImplementation "androidx.test:core:$testCoreVersion"
|
|
||||||
testImplementation "androidx.arch.core:core-testing:2.1.0"
|
|
||||||
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
|
|
||||||
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
|
|
||||||
// Core library
|
|
||||||
androidTestImplementation 'androidx.test:core:1.4.0'
|
|
||||||
|
|
||||||
// AndroidJUnitRunner and JUnit Rules
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.4.0'
|
|
||||||
androidTestImplementation 'androidx.test:rules:1.4.0'
|
|
||||||
|
|
||||||
// Assertions
|
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
|
||||||
androidTestImplementation 'androidx.test.ext:truth:1.4.0'
|
|
||||||
androidTestImplementation 'com.google.truth:truth:1.1.3'
|
|
||||||
|
|
||||||
// Espresso dependencies
|
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0'
|
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0'
|
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.4.0'
|
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-web:3.4.0'
|
|
||||||
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.4.0'
|
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.4.0'
|
|
||||||
androidTestUtil 'androidx.test:orchestrator:1.4.1'
|
|
||||||
|
|
||||||
testImplementation 'org.robolectric:robolectric:4.4'
|
|
||||||
testImplementation 'org.robolectric:shadows-multidex:4.4'
|
|
||||||
}
|
|
||||||
|
|
||||||
def canonicalVersionCode = 323
|
|
||||||
def canonicalVersionName = "1.16.3"
|
|
||||||
|
|
||||||
def postFixSize = 10
|
def postFixSize = 10
|
||||||
def abiPostFix = ['armeabi-v7a' : 1,
|
def abiPostFix = ['armeabi-v7a' : 1,
|
||||||
@@ -202,6 +74,13 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose true
|
||||||
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion '1.4.7'
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
versionCode canonicalVersionCode * postFixSize
|
versionCode canonicalVersionCode * postFixSize
|
||||||
versionName canonicalVersionName
|
versionName canonicalVersionName
|
||||||
@@ -243,22 +122,41 @@ android {
|
|||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
}
|
}
|
||||||
debug {
|
debug {
|
||||||
|
isDefault true
|
||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
|
enableUnitTestCoverage true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
flavorDimensions "distribution"
|
flavorDimensions "distribution"
|
||||||
productFlavors {
|
productFlavors {
|
||||||
play {
|
play {
|
||||||
|
isDefault true
|
||||||
|
dimension "distribution"
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
ext.websiteUpdateUrl = "null"
|
ext.websiteUpdateUrl = "null"
|
||||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
||||||
|
buildConfigField "org.session.libsession.utilities.Device", "DEVICE", "org.session.libsession.utilities.Device.ANDROID"
|
||||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
||||||
|
buildConfigField 'String', 'PUSH_KEY_SUFFIX', '\"\"'
|
||||||
|
}
|
||||||
|
|
||||||
|
huawei {
|
||||||
|
dimension "distribution"
|
||||||
|
ext.websiteUpdateUrl = "null"
|
||||||
|
buildConfigField "boolean", "PLAY_STORE_DISABLED", "true"
|
||||||
|
buildConfigField "org.session.libsession.utilities.Device", "DEVICE", "org.session.libsession.utilities.Device.HUAWEI"
|
||||||
|
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
||||||
|
buildConfigField 'String', 'PUSH_KEY_SUFFIX', '\"_HUAWEI\"'
|
||||||
}
|
}
|
||||||
|
|
||||||
website {
|
website {
|
||||||
|
dimension "distribution"
|
||||||
ext.websiteUpdateUrl = "https://github.com/oxen-io/session-android/releases"
|
ext.websiteUpdateUrl = "https://github.com/oxen-io/session-android/releases"
|
||||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "true"
|
buildConfigField "boolean", "PLAY_STORE_DISABLED", "true"
|
||||||
|
buildConfigField "org.session.libsession.utilities.Device", "DEVICE", "org.session.libsession.utilities.Device.ANDROID"
|
||||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\""
|
buildConfigField "String", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\""
|
||||||
|
buildConfigField 'String', 'PUSH_KEY_SUFFIX', '\"\"'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +186,188 @@ android {
|
|||||||
dataBinding true
|
dataBinding true
|
||||||
viewBinding true
|
viewBinding true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def huaweiEnabled = project.properties['huawei'] != null
|
||||||
|
|
||||||
|
applicationVariants.configureEach { variant ->
|
||||||
|
if (variant.flavorName == 'huawei') {
|
||||||
|
variant.getPreBuildProvider().configure { task ->
|
||||||
|
task.doFirst {
|
||||||
|
if (!huaweiEnabled) {
|
||||||
|
def message = 'Huawei is not enabled. Please add -Phuawei command line arg. See BUILDING.md'
|
||||||
|
logger.error(message)
|
||||||
|
throw new GradleException(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task testPlayDebugUnitTestCoverageReport(type: JacocoReport, dependsOn: "testPlayDebugUnitTest") {
|
||||||
|
reports {
|
||||||
|
xml.enabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add files that should not be listed in the report (e.g. generated Files from dagger)
|
||||||
|
def fileFilter = []
|
||||||
|
def mainSrc = "$projectDir/src/main/java"
|
||||||
|
def kotlinDebugTree = fileTree(dir: "${buildDir}/tmp/kotlin-classes/playDebug", excludes: fileFilter)
|
||||||
|
|
||||||
|
// Compiled Kotlin class files are written into build-variant-specific subdirectories of 'build/tmp/kotlin-classes'.
|
||||||
|
classDirectories.from = files([kotlinDebugTree])
|
||||||
|
|
||||||
|
// To produce an accurate report, the bytecode is mapped back to the original source code.
|
||||||
|
sourceDirectories.from = files([mainSrc])
|
||||||
|
|
||||||
|
// Execution data generated when running the tests against classes instrumented by the JaCoCo agent.
|
||||||
|
// This is enabled with 'enableUnitTestCoverage' in the 'debug' build type.
|
||||||
|
executionData.from = "${project.buildDir}/outputs/unit_test_code_coverage/playDebugUnitTest/testPlayDebugUnitTest.exec"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
|
||||||
|
implementation("com.google.dagger:hilt-android:2.46.1")
|
||||||
|
kapt("com.google.dagger:hilt-android-compiler:2.44")
|
||||||
|
|
||||||
|
implementation "androidx.appcompat:appcompat:$appcompatVersion"
|
||||||
|
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||||
|
implementation "com.google.android.material:material:$materialVersion"
|
||||||
|
implementation 'com.google.android:flexbox:2.0.1'
|
||||||
|
implementation 'androidx.legacy:legacy-support-v13:1.0.0'
|
||||||
|
implementation 'androidx.cardview:cardview:1.0.0'
|
||||||
|
implementation "androidx.preference:preference-ktx:$preferenceVersion"
|
||||||
|
implementation 'androidx.legacy:legacy-preference-v14:1.0.0'
|
||||||
|
implementation 'androidx.gridlayout:gridlayout:1.0.0'
|
||||||
|
implementation 'androidx.exifinterface:exifinterface:1.3.4'
|
||||||
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
|
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
|
||||||
|
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
|
||||||
|
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
|
||||||
|
implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion"
|
||||||
|
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
|
||||||
|
implementation "androidx.paging:paging-runtime-ktx:$pagingVersion"
|
||||||
|
implementation 'androidx.activity:activity-ktx:1.5.1'
|
||||||
|
implementation 'androidx.fragment:fragment-ktx:1.5.3'
|
||||||
|
implementation "androidx.core:core-ktx:$coreVersion"
|
||||||
|
implementation "androidx.work:work-runtime-ktx:2.7.1"
|
||||||
|
playImplementation ("com.google.firebase:firebase-messaging:18.0.0") {
|
||||||
|
exclude group: 'com.google.firebase', module: 'firebase-core'
|
||||||
|
exclude group: 'com.google.firebase', module: 'firebase-analytics'
|
||||||
|
exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
|
||||||
|
}
|
||||||
|
if (project.hasProperty('huawei')) huaweiImplementation 'com.huawei.hms:push:6.7.0.300'
|
||||||
|
implementation 'com.google.android.exoplayer:exoplayer-core:2.9.1'
|
||||||
|
implementation 'com.google.android.exoplayer:exoplayer-ui:2.9.1'
|
||||||
|
implementation 'org.conscrypt:conscrypt-android:2.0.0'
|
||||||
|
implementation 'org.signal:aesgcmprovider:0.0.3'
|
||||||
|
implementation 'org.webrtc:google-webrtc:1.0.32006'
|
||||||
|
implementation "me.leolin:ShortcutBadger:1.1.16"
|
||||||
|
implementation 'se.emilsjolander:stickylistheaders:2.7.0'
|
||||||
|
implementation 'com.jpardogo.materialtabstrip:library:1.0.9'
|
||||||
|
implementation 'org.apache.httpcomponents:httpclient-android:4.3.5'
|
||||||
|
implementation 'commons-net:commons-net:3.7.2'
|
||||||
|
implementation 'com.github.chrisbanes:PhotoView:2.1.3'
|
||||||
|
implementation "com.github.bumptech.glide:glide:$glideVersion"
|
||||||
|
annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion"
|
||||||
|
kapt "com.github.bumptech.glide:compiler:$glideVersion"
|
||||||
|
implementation 'com.makeramen:roundedimageview:2.1.0'
|
||||||
|
implementation 'com.pnikosis:materialish-progress:1.5'
|
||||||
|
implementation 'org.greenrobot:eventbus:3.0.0'
|
||||||
|
implementation 'pl.tajchert:waitingdots:0.1.0'
|
||||||
|
implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0'
|
||||||
|
implementation 'com.melnykov:floatingactionbutton:1.3.0'
|
||||||
|
implementation 'com.google.zxing:android-integration:3.1.0'
|
||||||
|
implementation "com.google.dagger:hilt-android:$daggerVersion"
|
||||||
|
kapt "com.google.dagger:hilt-compiler:$daggerVersion"
|
||||||
|
implementation 'mobi.upod:time-duration-picker:1.1.3'
|
||||||
|
implementation 'com.google.zxing:core:3.2.1'
|
||||||
|
implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.6.0') {
|
||||||
|
exclude group: 'com.android.support', module: 'support-annotations'
|
||||||
|
}
|
||||||
|
implementation ('cn.carbswang.android:NumberPickerView:1.0.9') {
|
||||||
|
exclude group: 'com.android.support', module: 'appcompat-v7'
|
||||||
|
}
|
||||||
|
implementation ('com.tomergoldst.android:tooltips:1.0.6') {
|
||||||
|
exclude group: 'com.android.support', module: 'appcompat-v7'
|
||||||
|
}
|
||||||
|
implementation ('com.klinkerapps:android-smsmms:4.0.1') {
|
||||||
|
exclude group: 'com.squareup.okhttp', module: 'okhttp'
|
||||||
|
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
|
||||||
|
}
|
||||||
|
implementation 'com.annimon:stream:1.1.8'
|
||||||
|
implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
|
||||||
|
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
|
||||||
|
implementation 'androidx.sqlite:sqlite-ktx:2.3.1'
|
||||||
|
implementation 'net.zetetic:sqlcipher-android:4.5.4@aar'
|
||||||
|
implementation project(":libsignal")
|
||||||
|
implementation project(":libsession")
|
||||||
|
implementation project(":libsession-util")
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion"
|
||||||
|
implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version"
|
||||||
|
implementation project(":liblazysodium")
|
||||||
|
implementation "net.java.dev.jna:jna:5.12.1@aar"
|
||||||
|
implementation "com.google.protobuf:protobuf-java:$protobufVersion"
|
||||||
|
implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion"
|
||||||
|
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
|
||||||
|
implementation 'app.cash.copper:copper-flow:1.0.0'
|
||||||
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
|
||||||
|
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
|
||||||
|
implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion"
|
||||||
|
implementation "com.jakewharton.rxbinding3:rxbinding:3.1.0"
|
||||||
|
implementation "com.github.tbruyelle:rxpermissions:0.10.2"
|
||||||
|
implementation "com.github.ybq:Android-SpinKit:1.4.0"
|
||||||
|
implementation "com.opencsv:opencsv:4.6"
|
||||||
|
testImplementation "junit:junit:$junitVersion"
|
||||||
|
testImplementation 'org.assertj:assertj-core:3.11.1'
|
||||||
|
testImplementation "org.mockito:mockito-inline:4.11.0"
|
||||||
|
testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
|
||||||
|
androidTestImplementation "org.mockito:mockito-android:4.11.0"
|
||||||
|
androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
|
||||||
|
testImplementation "androidx.test:core:$testCoreVersion"
|
||||||
|
testImplementation "androidx.arch.core:core-testing:2.2.0"
|
||||||
|
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
|
||||||
|
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
|
||||||
|
// Core library
|
||||||
|
androidTestImplementation "androidx.test:core:$testCoreVersion"
|
||||||
|
|
||||||
|
androidTestImplementation('com.adevinta.android:barista:4.2.0') {
|
||||||
|
exclude group: 'org.jetbrains.kotlin'
|
||||||
|
}
|
||||||
|
|
||||||
|
// AndroidJUnitRunner and JUnit Rules
|
||||||
|
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||||
|
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||||
|
|
||||||
|
// Assertions
|
||||||
|
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||||
|
androidTestImplementation 'androidx.test.ext:truth:1.5.0'
|
||||||
|
testImplementation 'com.google.truth:truth:1.1.3'
|
||||||
|
androidTestImplementation 'com.google.truth:truth:1.1.3'
|
||||||
|
|
||||||
|
// Espresso dependencies
|
||||||
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||||
|
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
|
||||||
|
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
|
||||||
|
androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.5.1'
|
||||||
|
androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1'
|
||||||
|
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1'
|
||||||
|
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
|
||||||
|
androidTestUtil 'androidx.test:orchestrator:1.4.2'
|
||||||
|
|
||||||
|
testImplementation 'org.robolectric:robolectric:4.4'
|
||||||
|
testImplementation 'org.robolectric:shadows-multidex:4.4'
|
||||||
|
|
||||||
|
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5'
|
||||||
|
implementation 'androidx.compose.ui:ui:1.5.2'
|
||||||
|
implementation 'androidx.compose.ui:ui-tooling:1.5.2'
|
||||||
|
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha"
|
||||||
|
implementation "com.google.accompanist:accompanist-pager-indicators:0.33.1-alpha"
|
||||||
|
implementation "androidx.compose.runtime:runtime-livedata:1.5.2"
|
||||||
|
|
||||||
|
implementation 'androidx.compose.foundation:foundation-layout:1.5.2'
|
||||||
|
implementation 'androidx.compose.material:material:1.5.2'
|
||||||
}
|
}
|
||||||
|
|
||||||
static def getLastCommitTimestamp() {
|
static def getLastCommitTimestamp() {
|
||||||
@@ -308,3 +388,8 @@ def autoResConfig() {
|
|||||||
.collect { matcher -> matcher.group(1) }
|
.collect { matcher -> matcher.group(1) }
|
||||||
.sort()
|
.sort()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow references to generated code
|
||||||
|
kapt {
|
||||||
|
correctErrorTypes = true
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package network.loki.messenger
|
package network.loki.messenger
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.app.Instrumentation
|
import android.app.Instrumentation
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -21,6 +22,7 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule
|
|||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.LargeTest
|
import androidx.test.filters.LargeTest
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.adevinta.android.barista.interaction.PermissionGranter
|
||||||
import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable
|
import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable
|
||||||
import org.hamcrest.Matcher
|
import org.hamcrest.Matcher
|
||||||
import org.hamcrest.Matchers.allOf
|
import org.hamcrest.Matchers.allOf
|
||||||
@@ -85,6 +87,8 @@ class HomeActivityTests {
|
|||||||
}
|
}
|
||||||
onView(withId(R.id.backgroundPollingOptionView)).perform(ViewActions.click())
|
onView(withId(R.id.backgroundPollingOptionView)).perform(ViewActions.click())
|
||||||
onView(withId(R.id.registerButton)).perform(ViewActions.click())
|
onView(withId(R.id.registerButton)).perform(ViewActions.click())
|
||||||
|
// allow notification permission
|
||||||
|
PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun goToMyChat() {
|
private fun goToMyChat() {
|
||||||
@@ -100,6 +104,7 @@ class HomeActivityTests {
|
|||||||
copied = clipboardManager.primaryClip!!.getItemAt(0).text.toString()
|
copied = clipboardManager.primaryClip!!.getItemAt(0).text.toString()
|
||||||
}
|
}
|
||||||
onView(withId(R.id.publicKeyEditText)).perform(ViewActions.typeText(copied))
|
onView(withId(R.id.publicKeyEditText)).perform(ViewActions.typeText(copied))
|
||||||
|
onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard())
|
||||||
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
|
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +158,7 @@ class HomeActivityTests {
|
|||||||
|
|
||||||
val dialogPromptText = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.dialog_open_url_explanation, amazonPuny)
|
val dialogPromptText = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.dialog_open_url_explanation, amazonPuny)
|
||||||
|
|
||||||
|
onView(isRoot()).perform(waitFor(1000)) // no other way for this to work apparently
|
||||||
onView(withText(dialogPromptText)).check(matches(isDisplayed()))
|
onView(withText(dialogPromptText)).check(matches(isDisplayed()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package network.loki.messenger
|
||||||
|
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.SmallTest
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import network.loki.messenger.libsession_util.ConfigBase
|
||||||
|
import network.loki.messenger.libsession_util.Contacts
|
||||||
|
import network.loki.messenger.libsession_util.ConversationVolatileConfig
|
||||||
|
import network.loki.messenger.libsession_util.util.Contact
|
||||||
|
import network.loki.messenger.libsession_util.util.Conversation
|
||||||
|
import network.loki.messenger.libsession_util.util.ExpiryMode
|
||||||
|
import org.hamcrest.CoreMatchers.equalTo
|
||||||
|
import org.hamcrest.CoreMatchers.instanceOf
|
||||||
|
import org.hamcrest.MatcherAssert.assertThat
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.mockito.kotlin.any
|
||||||
|
import org.mockito.kotlin.argThat
|
||||||
|
import org.mockito.kotlin.argWhere
|
||||||
|
import org.mockito.kotlin.eq
|
||||||
|
import org.mockito.kotlin.spy
|
||||||
|
import org.mockito.kotlin.verify
|
||||||
|
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||||
|
import org.session.libsession.snode.SnodeAPI
|
||||||
|
import org.session.libsession.utilities.Address
|
||||||
|
import org.session.libsession.utilities.TextSecurePreferences
|
||||||
|
import org.session.libsignal.utilities.KeyHelper
|
||||||
|
import org.session.libsignal.utilities.hexEncodedPublicKey
|
||||||
|
import org.thoughtcrime.securesms.ApplicationContext
|
||||||
|
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@SmallTest
|
||||||
|
class LibSessionTests {
|
||||||
|
|
||||||
|
private fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() }
|
||||||
|
private fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray())
|
||||||
|
private fun randomSessionId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey
|
||||||
|
|
||||||
|
private var fakeHashI = 0
|
||||||
|
private val nextFakeHash: String
|
||||||
|
get() = "fakehash${fakeHashI++}"
|
||||||
|
|
||||||
|
private fun maybeGetUserInfo(): Pair<ByteArray, String>? {
|
||||||
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
|
||||||
|
val prefs = appContext.prefs
|
||||||
|
val localUserPublicKey = prefs.getLocalNumber()
|
||||||
|
val secretKey = with(appContext) {
|
||||||
|
val edKey = KeyPairUtilities.getUserED25519KeyPair(this) ?: return null
|
||||||
|
edKey.secretKey.asBytes
|
||||||
|
}
|
||||||
|
return if (localUserPublicKey == null || secretKey == null) null
|
||||||
|
else secretKey to localUserPublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildContactMessage(contactList: List<Contact>): ByteArray {
|
||||||
|
val (key,_) = maybeGetUserInfo()!!
|
||||||
|
val contacts = Contacts.newInstance(key)
|
||||||
|
contactList.forEach { contact ->
|
||||||
|
contacts.set(contact)
|
||||||
|
}
|
||||||
|
return contacts.push().config
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildVolatileMessage(conversations: List<Conversation>): ByteArray {
|
||||||
|
val (key, _) = maybeGetUserInfo()!!
|
||||||
|
val volatile = ConversationVolatileConfig.newInstance(key)
|
||||||
|
conversations.forEach { conversation ->
|
||||||
|
volatile.set(conversation)
|
||||||
|
}
|
||||||
|
return volatile.push().config
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fakePollNewConfig(configBase: ConfigBase, toMerge: ByteArray) {
|
||||||
|
configBase.merge(nextFakeHash to toMerge)
|
||||||
|
MessagingModuleConfiguration.shared.configFactory.persist(configBase, System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setupUser() {
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(InstrumentationRegistry.getInstrumentation().targetContext.applicationContext).edit {
|
||||||
|
putBoolean(TextSecurePreferences.HAS_FORCED_NEW_CONFIG, true).apply()
|
||||||
|
}
|
||||||
|
val newBytes = randomSeedBytes().toByteArray()
|
||||||
|
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||||
|
val kp = KeyPairUtilities.generate(newBytes)
|
||||||
|
KeyPairUtilities.store(context, kp.seed, kp.ed25519KeyPair, kp.x25519KeyPair)
|
||||||
|
val registrationID = KeyHelper.generateRegistrationId(false)
|
||||||
|
TextSecurePreferences.setLocalRegistrationId(context, registrationID)
|
||||||
|
TextSecurePreferences.setLocalNumber(context, kp.x25519KeyPair.hexEncodedPublicKey)
|
||||||
|
TextSecurePreferences.setRestorationTime(context, 0)
|
||||||
|
TextSecurePreferences.setHasViewedSeed(context, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun migration_one_to_ones() {
|
||||||
|
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
|
||||||
|
val storageSpy = spy(app.storage)
|
||||||
|
app.storage = storageSpy
|
||||||
|
|
||||||
|
val newContactId = randomSessionId()
|
||||||
|
val singleContact = Contact(
|
||||||
|
id = newContactId,
|
||||||
|
approved = true,
|
||||||
|
expiryMode = ExpiryMode.NONE
|
||||||
|
)
|
||||||
|
val newContactMerge = buildContactMessage(listOf(singleContact))
|
||||||
|
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
|
||||||
|
fakePollNewConfig(contacts, newContactMerge)
|
||||||
|
verify(storageSpy).addLibSessionContacts(argThat {
|
||||||
|
first().let { it.id == newContactId && it.approved } && size == 1
|
||||||
|
}, any())
|
||||||
|
verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_expected_configs() {
|
||||||
|
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
|
||||||
|
val storageSpy = spy(app.storage)
|
||||||
|
app.storage = storageSpy
|
||||||
|
|
||||||
|
val randomRecipient = randomSessionId()
|
||||||
|
val newContact = Contact(
|
||||||
|
id = randomRecipient,
|
||||||
|
approved = true,
|
||||||
|
expiryMode = ExpiryMode.AfterSend(1000)
|
||||||
|
)
|
||||||
|
val newConvo = Conversation.OneToOne(
|
||||||
|
randomRecipient,
|
||||||
|
SnodeAPI.nowWithOffset,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
val volatiles = MessagingModuleConfiguration.shared.configFactory.convoVolatile!!
|
||||||
|
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
|
||||||
|
val newContactMerge = buildContactMessage(listOf(newContact))
|
||||||
|
val newVolatileMerge = buildVolatileMessage(listOf(newConvo))
|
||||||
|
fakePollNewConfig(contacts, newContactMerge)
|
||||||
|
fakePollNewConfig(volatiles, newVolatileMerge)
|
||||||
|
verify(storageSpy).setExpirationConfiguration(argWhere { config ->
|
||||||
|
config.expiryMode is ExpiryMode.AfterSend
|
||||||
|
&& config.expiryMode.expirySeconds == 1000L
|
||||||
|
})
|
||||||
|
val threadId = storageSpy.getThreadId(Address.fromSerialized(randomRecipient))!!
|
||||||
|
val newExpiry = storageSpy.getExpirationConfiguration(threadId)!!
|
||||||
|
assertThat(newExpiry.expiryMode, instanceOf(ExpiryMode.AfterSend::class.java))
|
||||||
|
assertThat(newExpiry.expiryMode.expirySeconds, equalTo(1000))
|
||||||
|
assertThat(newExpiry.expiryMode.expiryMillis, equalTo(1000000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_overwrite_config() {
|
||||||
|
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
|
||||||
|
val storageSpy = spy(app.storage)
|
||||||
|
app.storage = storageSpy
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
val randomRecipient = randomSessionId()
|
||||||
|
val currentContact = Contact(
|
||||||
|
id = randomRecipient,
|
||||||
|
approved = true,
|
||||||
|
expiryMode = ExpiryMode.NONE
|
||||||
|
)
|
||||||
|
val newConvo = Conversation.OneToOne(
|
||||||
|
randomRecipient,
|
||||||
|
SnodeAPI.nowWithOffset,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
val volatiles = MessagingModuleConfiguration.shared.configFactory.convoVolatile!!
|
||||||
|
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
|
||||||
|
val newContactMerge = buildContactMessage(listOf(currentContact))
|
||||||
|
val newVolatileMerge = buildVolatileMessage(listOf(newConvo))
|
||||||
|
fakePollNewConfig(contacts, newContactMerge)
|
||||||
|
fakePollNewConfig(volatiles, newVolatileMerge)
|
||||||
|
verify(storageSpy).setExpirationConfiguration(argWhere { config ->
|
||||||
|
config.expiryMode == ExpiryMode.NONE
|
||||||
|
})
|
||||||
|
val threadId = storageSpy.getThreadId(Address.fromSerialized(randomRecipient))!!
|
||||||
|
val currentExpiryConfig = storageSpy.getExpirationConfiguration(threadId)!!
|
||||||
|
assertThat(currentExpiryConfig.expiryMode, equalTo(ExpiryMode.NONE))
|
||||||
|
assertThat(currentExpiryConfig.expiryMode.expirySeconds, equalTo(0))
|
||||||
|
assertThat(currentExpiryConfig.expiryMode.expiryMillis, equalTo(0))
|
||||||
|
// Set new state and overwrite
|
||||||
|
val updatedContact = currentContact.copy(expiryMode = ExpiryMode.AfterSend(1000))
|
||||||
|
val updateContactMerge = buildContactMessage(listOf(updatedContact))
|
||||||
|
fakePollNewConfig(contacts, updateContactMerge)
|
||||||
|
val updatedExpiryConfig = storageSpy.getExpirationConfiguration(threadId)!!
|
||||||
|
assertThat(updatedExpiryConfig.expiryMode, instanceOf(ExpiryMode.AfterSend::class.java))
|
||||||
|
assertThat(updatedExpiryConfig.expiryMode.expirySeconds, equalTo(1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
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>
|
||||||
@@ -29,12 +29,18 @@
|
|||||||
android:name="android.hardware.touchscreen"
|
android:name="android.hardware.touchscreen"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
|
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
|
||||||
<uses-permission android:name="network.loki.messenger.ACCESS_SESSION_SECRETS" />
|
<uses-permission android:name="network.loki.messenger.ACCESS_SESSION_SECRETS" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
@@ -100,11 +106,6 @@
|
|||||||
android:name="org.thoughtcrime.securesms.onboarding.RegisterActivity"
|
android:name="org.thoughtcrime.securesms.onboarding.RegisterActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="portrait"
|
||||||
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
|
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
|
||||||
<activity
|
|
||||||
android:name="org.thoughtcrime.securesms.onboarding.RecoveryPhraseRestoreActivity"
|
|
||||||
android:screenOrientation="portrait"
|
|
||||||
android:windowSoftInputMode="adjustResize"
|
|
||||||
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
|
|
||||||
<activity
|
<activity
|
||||||
android:name="org.thoughtcrime.securesms.onboarding.LinkDeviceActivity"
|
android:name="org.thoughtcrime.securesms.onboarding.LinkDeviceActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="portrait"
|
||||||
@@ -172,6 +173,9 @@
|
|||||||
android:screenOrientation="portrait" />
|
android:screenOrientation="portrait" />
|
||||||
<activity android:name="org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity"
|
<activity android:name="org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity"
|
||||||
android:screenOrientation="portrait"/>
|
android:screenOrientation="portrait"/>
|
||||||
|
<activity android:name="org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:theme="@style/Theme.Session.DayNight.NoActionBar" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -221,20 +225,18 @@
|
|||||||
android:name="org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2"
|
android:name="org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="portrait"
|
||||||
android:parentActivityName="org.thoughtcrime.securesms.home.HomeActivity"
|
android:parentActivityName="org.thoughtcrime.securesms.home.HomeActivity"
|
||||||
android:theme="@style/Theme.Session.DayNight.NoActionBar">
|
android:theme="@style/Theme.Session.DayNight.NoActionBar"
|
||||||
|
android:windowSoftInputMode="adjustResize" >
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.support.PARENT_ACTIVITY"
|
android:name="android.support.PARENT_ACTIVITY"
|
||||||
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
|
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name="org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity"
|
android:name="org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="portrait"
|
||||||
android:theme="@style/Theme.Session.DayNight">
|
android:theme="@style/Theme.Session.DayNight">
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
|
||||||
android:name="org.thoughtcrime.securesms.longmessage.LongMessageActivity"
|
|
||||||
android:screenOrientation="portrait"
|
|
||||||
android:theme="@style/Theme.Session.DayNight" />
|
|
||||||
<activity
|
<activity
|
||||||
android:name="org.thoughtcrime.securesms.DatabaseUpgradeActivity"
|
android:name="org.thoughtcrime.securesms.DatabaseUpgradeActivity"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||||
@@ -306,20 +308,16 @@
|
|||||||
android:name="android.support.PARENT_ACTIVITY"
|
android:name="android.support.PARENT_ACTIVITY"
|
||||||
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
|
android:value="org.thoughtcrime.securesms.home.HomeActivity" />
|
||||||
</activity>
|
</activity>
|
||||||
<service
|
|
||||||
android:name="org.thoughtcrime.securesms.notifications.PushNotificationService"
|
|
||||||
android:enabled="true"
|
|
||||||
android:exported="false">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
|
||||||
</intent-filter>
|
|
||||||
</service>
|
|
||||||
<service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService"
|
<service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService"
|
||||||
|
android:foregroundServiceType="microphone"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
<service
|
<service
|
||||||
android:name="org.thoughtcrime.securesms.service.KeyCachingService"
|
android:name="org.thoughtcrime.securesms.service.KeyCachingService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false" />
|
android:exported="false" android:foregroundServiceType="specialUse">
|
||||||
|
<!-- <property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"-->
|
||||||
|
<!-- android:value="@string/preferences_app_protection__lock_signal_access_with_android_screen_lock_or_fingerprint"/>-->
|
||||||
|
</service>
|
||||||
<service
|
<service
|
||||||
android:name="org.thoughtcrime.securesms.service.DirectShareService"
|
android:name="org.thoughtcrime.securesms.service.DirectShareService"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -407,12 +405,6 @@
|
|||||||
<action android:name="network.loki.securesms.RESTART" />
|
<action android:name="network.loki.securesms.RESTART" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name="org.thoughtcrime.securesms.service.LocalBackupListener"
|
|
||||||
android:exported="false">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
<receiver android:name="org.thoughtcrime.securesms.service.PersistentConnectionBootListener"
|
<receiver android:name="org.thoughtcrime.securesms.service.PersistentConnectionBootListener"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -446,17 +438,9 @@
|
|||||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
<service
|
|
||||||
android:name="org.thoughtcrime.securesms.jobmanager.JobSchedulerScheduler$SystemService"
|
|
||||||
android:enabled="@bool/enable_job_service"
|
|
||||||
android:permission="android.permission.BIND_JOB_SERVICE"
|
|
||||||
tools:targetApi="26" />
|
|
||||||
<service
|
<service
|
||||||
android:name="org.thoughtcrime.securesms.jobmanager.KeepAliveService"
|
android:name="org.thoughtcrime.securesms.jobmanager.KeepAliveService"
|
||||||
android:enabled="@bool/enable_alarm_manager" />
|
android:enabled="@bool/enable_alarm_manager" />
|
||||||
<receiver
|
|
||||||
android:name="org.thoughtcrime.securesms.jobmanager.AlarmManagerScheduler$RetryReceiver"
|
|
||||||
android:enabled="@bool/enable_alarm_manager" /> <!-- Probably don't need this one -->
|
|
||||||
<uses-library
|
<uses-library
|
||||||
android:name="com.sec.android.app.multiwindow"
|
android:name="com.sec.android.app.multiwindow"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPol
|
|||||||
import org.session.libsession.messaging.sending_receiving.pollers.Poller;
|
import org.session.libsession.messaging.sending_receiving.pollers.Poller;
|
||||||
import org.session.libsession.snode.SnodeModule;
|
import org.session.libsession.snode.SnodeModule;
|
||||||
import org.session.libsession.utilities.Address;
|
import org.session.libsession.utilities.Address;
|
||||||
|
import org.session.libsession.utilities.ConfigFactoryUpdateListener;
|
||||||
|
import org.session.libsession.utilities.Device;
|
||||||
import org.session.libsession.utilities.ProfilePictureUtilities;
|
import org.session.libsession.utilities.ProfilePictureUtilities;
|
||||||
import org.session.libsession.utilities.SSKEnvironment;
|
import org.session.libsession.utilities.SSKEnvironment;
|
||||||
import org.session.libsession.utilities.TextSecurePreferences;
|
import org.session.libsession.utilities.TextSecurePreferences;
|
||||||
@@ -55,34 +57,29 @@ import org.signal.aesgcmprovider.AesGcmProvider;
|
|||||||
import org.thoughtcrime.securesms.components.TypingStatusSender;
|
import org.thoughtcrime.securesms.components.TypingStatusSender;
|
||||||
import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
|
import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
|
||||||
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
|
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
|
||||||
import org.thoughtcrime.securesms.database.JobDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase;
|
import org.thoughtcrime.securesms.database.LokiAPIDatabase;
|
||||||
import org.thoughtcrime.securesms.database.Storage;
|
import org.thoughtcrime.securesms.database.Storage;
|
||||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||||
import org.thoughtcrime.securesms.database.model.EmojiSearchData;
|
import org.thoughtcrime.securesms.database.model.EmojiSearchData;
|
||||||
|
import org.thoughtcrime.securesms.dependencies.AppComponent;
|
||||||
|
import org.thoughtcrime.securesms.dependencies.ConfigFactory;
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseModule;
|
import org.thoughtcrime.securesms.dependencies.DatabaseModule;
|
||||||
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||||
import org.thoughtcrime.securesms.groups.OpenGroupManager;
|
import org.thoughtcrime.securesms.groups.OpenGroupManager;
|
||||||
import org.thoughtcrime.securesms.home.HomeActivity;
|
import org.thoughtcrime.securesms.home.HomeActivity;
|
||||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
|
||||||
import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer;
|
|
||||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||||
import org.thoughtcrime.securesms.jobs.FastJobStorage;
|
|
||||||
import org.thoughtcrime.securesms.jobs.JobManagerFactories;
|
|
||||||
import org.thoughtcrime.securesms.logging.AndroidLogger;
|
import org.thoughtcrime.securesms.logging.AndroidLogger;
|
||||||
import org.thoughtcrime.securesms.logging.PersistentLogger;
|
import org.thoughtcrime.securesms.logging.PersistentLogger;
|
||||||
import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger;
|
import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger;
|
||||||
import org.thoughtcrime.securesms.notifications.BackgroundPollWorker;
|
import org.thoughtcrime.securesms.notifications.BackgroundPollWorker;
|
||||||
import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier;
|
import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier;
|
||||||
import org.thoughtcrime.securesms.notifications.FcmUtils;
|
|
||||||
import org.thoughtcrime.securesms.notifications.LokiPushNotificationManager;
|
|
||||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||||
import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier;
|
import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier;
|
||||||
|
import org.thoughtcrime.securesms.notifications.PushRegistry;
|
||||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
|
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
|
||||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||||
import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
|
|
||||||
import org.thoughtcrime.securesms.sskenvironment.ProfileManager;
|
import org.thoughtcrime.securesms.sskenvironment.ProfileManager;
|
||||||
import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager;
|
import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager;
|
||||||
import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository;
|
import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository;
|
||||||
@@ -113,6 +110,8 @@ import dagger.hilt.android.HiltAndroidApp;
|
|||||||
import kotlin.Unit;
|
import kotlin.Unit;
|
||||||
import kotlinx.coroutines.Job;
|
import kotlinx.coroutines.Job;
|
||||||
import network.loki.messenger.BuildConfig;
|
import network.loki.messenger.BuildConfig;
|
||||||
|
import network.loki.messenger.libsession_util.ConfigBase;
|
||||||
|
import network.loki.messenger.libsession_util.UserProfile;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Will be called once when the TextSecure process is created.
|
* Will be called once when the TextSecure process is created.
|
||||||
@@ -123,7 +122,7 @@ import network.loki.messenger.BuildConfig;
|
|||||||
* @author Moxie Marlinspike
|
* @author Moxie Marlinspike
|
||||||
*/
|
*/
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
public class ApplicationContext extends Application implements DefaultLifecycleObserver {
|
public class ApplicationContext extends Application implements DefaultLifecycleObserver, ConfigFactoryUpdateListener {
|
||||||
|
|
||||||
public static final String PREFERENCES_NAME = "SecureSMS-Preferences";
|
public static final String PREFERENCES_NAME = "SecureSMS-Preferences";
|
||||||
|
|
||||||
@@ -132,7 +131,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
private ExpiringMessageManager expiringMessageManager;
|
private ExpiringMessageManager expiringMessageManager;
|
||||||
private TypingStatusRepository typingStatusRepository;
|
private TypingStatusRepository typingStatusRepository;
|
||||||
private TypingStatusSender typingStatusSender;
|
private TypingStatusSender typingStatusSender;
|
||||||
private JobManager jobManager;
|
|
||||||
private ReadReceiptManager readReceiptManager;
|
private ReadReceiptManager readReceiptManager;
|
||||||
private ProfileManager profileManager;
|
private ProfileManager profileManager;
|
||||||
public MessageNotifier messageNotifier = null;
|
public MessageNotifier messageNotifier = null;
|
||||||
@@ -145,10 +143,12 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
private PersistentLogger persistentLogger;
|
private PersistentLogger persistentLogger;
|
||||||
|
|
||||||
@Inject LokiAPIDatabase lokiAPIDatabase;
|
@Inject LokiAPIDatabase lokiAPIDatabase;
|
||||||
@Inject Storage storage;
|
@Inject public Storage storage;
|
||||||
|
@Inject Device device;
|
||||||
@Inject MessageDataProvider messageDataProvider;
|
@Inject MessageDataProvider messageDataProvider;
|
||||||
@Inject JobDatabase jobDatabase;
|
|
||||||
@Inject TextSecurePreferences textSecurePreferences;
|
@Inject TextSecurePreferences textSecurePreferences;
|
||||||
|
@Inject PushRegistry pushRegistry;
|
||||||
|
@Inject ConfigFactory configFactory;
|
||||||
CallMessageProcessor callMessageProcessor;
|
CallMessageProcessor callMessageProcessor;
|
||||||
MessagingModuleConfiguration messagingModuleConfiguration;
|
MessagingModuleConfiguration messagingModuleConfiguration;
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
}
|
}
|
||||||
|
|
||||||
public TextSecurePreferences getPrefs() {
|
public TextSecurePreferences getPrefs() {
|
||||||
return textSecurePreferences;
|
return EntryPoints.get(getApplicationContext(), AppComponent.class).getPrefs();
|
||||||
}
|
}
|
||||||
|
|
||||||
public DatabaseComponent getDatabaseComponent() {
|
public DatabaseComponent getDatabaseComponent() {
|
||||||
@@ -196,15 +196,30 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
return this.persistentLogger;
|
return this.persistentLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void notifyUpdates(@NonNull ConfigBase forConfigObject, long messageTimestamp) {
|
||||||
|
// forward to the config factory / storage ig
|
||||||
|
if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) {
|
||||||
|
textSecurePreferences.setConfigurationMessageSynced(true);
|
||||||
|
}
|
||||||
|
storage.notifyConfigUpdates(forConfigObject, messageTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
|
TextSecurePreferences.setPushSuffix(BuildConfig.PUSH_KEY_SUFFIX);
|
||||||
|
|
||||||
DatabaseModule.init(this);
|
DatabaseModule.init(this);
|
||||||
MessagingModuleConfiguration.configure(this);
|
MessagingModuleConfiguration.configure(this);
|
||||||
super.onCreate();
|
super.onCreate();
|
||||||
messagingModuleConfiguration = new MessagingModuleConfiguration(this,
|
messagingModuleConfiguration = new MessagingModuleConfiguration(
|
||||||
|
this,
|
||||||
storage,
|
storage,
|
||||||
|
device,
|
||||||
messageDataProvider,
|
messageDataProvider,
|
||||||
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this));
|
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this),
|
||||||
|
configFactory
|
||||||
|
);
|
||||||
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
|
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
|
||||||
Log.i(TAG, "onCreate()");
|
Log.i(TAG, "onCreate()");
|
||||||
startKovenant();
|
startKovenant();
|
||||||
@@ -218,10 +233,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
broadcaster = new Broadcaster(this);
|
broadcaster = new Broadcaster(this);
|
||||||
LokiAPIDatabase apiDB = getDatabaseComponent().lokiAPIDatabase();
|
LokiAPIDatabase apiDB = getDatabaseComponent().lokiAPIDatabase();
|
||||||
SnodeModule.Companion.configure(apiDB, broadcaster);
|
SnodeModule.Companion.configure(apiDB, broadcaster);
|
||||||
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
|
|
||||||
if (userPublicKey != null) {
|
|
||||||
registerForFCMIfNeeded(false);
|
|
||||||
}
|
|
||||||
initializeExpiringMessageManager();
|
initializeExpiringMessageManager();
|
||||||
initializeTypingStatusRepository();
|
initializeTypingStatusRepository();
|
||||||
initializeTypingStatusSender();
|
initializeTypingStatusSender();
|
||||||
@@ -229,7 +240,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
initializeProfileManager();
|
initializeProfileManager();
|
||||||
initializePeriodicTasks();
|
initializePeriodicTasks();
|
||||||
SSKEnvironment.Companion.configure(getTypingStatusRepository(), getReadReceiptManager(), getProfileManager(), messageNotifier, getExpiringMessageManager());
|
SSKEnvironment.Companion.configure(getTypingStatusRepository(), getReadReceiptManager(), getProfileManager(), messageNotifier, getExpiringMessageManager());
|
||||||
initializeJobManager();
|
|
||||||
initializeWebRtc();
|
initializeWebRtc();
|
||||||
initializeBlobProvider();
|
initializeBlobProvider();
|
||||||
resubmitProfilePictureIfNeeded();
|
resubmitProfilePictureIfNeeded();
|
||||||
@@ -272,7 +282,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
if (poller != null) {
|
if (poller != null) {
|
||||||
poller.stopIfNeeded();
|
poller.stopIfNeeded();
|
||||||
}
|
}
|
||||||
ClosedGroupPollerV2.getShared().stop();
|
ClosedGroupPollerV2.getShared().stopAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -286,10 +296,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
LocaleParser.Companion.configure(new LocaleParseHelper());
|
LocaleParser.Companion.configure(new LocaleParseHelper());
|
||||||
}
|
}
|
||||||
|
|
||||||
public JobManager getJobManager() {
|
|
||||||
return jobManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ExpiringMessageManager getExpiringMessageManager() {
|
public ExpiringMessageManager getExpiringMessageManager() {
|
||||||
return expiringMessageManager;
|
return expiringMessageManager;
|
||||||
}
|
}
|
||||||
@@ -352,16 +358,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionLogger(originalHandler));
|
Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionLogger(originalHandler));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initializeJobManager() {
|
|
||||||
this.jobManager = new JobManager(this, new JobManager.Configuration.Builder()
|
|
||||||
.setDataSerializer(new JsonDataSerializer())
|
|
||||||
.setJobFactories(JobManagerFactories.getJobFactories(this))
|
|
||||||
.setConstraintFactories(JobManagerFactories.getConstraintFactories(this))
|
|
||||||
.setConstraintObservers(JobManagerFactories.getConstraintObservers(this))
|
|
||||||
.setJobStorage(new FastJobStorage(jobDatabase))
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void initializeExpiringMessageManager() {
|
private void initializeExpiringMessageManager() {
|
||||||
this.expiringMessageManager = new ExpiringMessageManager(this);
|
this.expiringMessageManager = new ExpiringMessageManager(this);
|
||||||
}
|
}
|
||||||
@@ -375,7 +371,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void initializeProfileManager() {
|
private void initializeProfileManager() {
|
||||||
this.profileManager = new ProfileManager();
|
this.profileManager = new ProfileManager(this, configFactory);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initializeTypingStatusSender() {
|
private void initializeTypingStatusSender() {
|
||||||
@@ -384,10 +380,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
|
|
||||||
private void initializePeriodicTasks() {
|
private void initializePeriodicTasks() {
|
||||||
BackgroundPollWorker.schedulePeriodic(this);
|
BackgroundPollWorker.schedulePeriodic(this);
|
||||||
|
|
||||||
if (BuildConfig.PLAY_STORE_DISABLED) {
|
|
||||||
UpdateApkRefreshListener.schedule(this);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initializeWebRtc() {
|
private void initializeWebRtc() {
|
||||||
@@ -438,29 +430,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static class ProviderInitializationException extends RuntimeException { }
|
private static class ProviderInitializationException extends RuntimeException { }
|
||||||
|
|
||||||
public void registerForFCMIfNeeded(final Boolean force) {
|
|
||||||
if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive() && !force) return;
|
|
||||||
if (force && firebaseInstanceIdJob != null) {
|
|
||||||
firebaseInstanceIdJob.cancel(null);
|
|
||||||
}
|
|
||||||
firebaseInstanceIdJob = FcmUtils.getFcmInstanceId(task->{
|
|
||||||
if (!task.isSuccessful()) {
|
|
||||||
Log.w("Loki", "FirebaseInstanceId.getInstance().getInstanceId() failed." + task.getException());
|
|
||||||
return Unit.INSTANCE;
|
|
||||||
}
|
|
||||||
String token = task.getResult().getToken();
|
|
||||||
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
|
|
||||||
if (userPublicKey == null) return Unit.INSTANCE;
|
|
||||||
if (TextSecurePreferences.isUsingFCM(this)) {
|
|
||||||
LokiPushNotificationManager.register(token, userPublicKey, this, force);
|
|
||||||
} else {
|
|
||||||
LokiPushNotificationManager.unregister(token, this);
|
|
||||||
}
|
|
||||||
return Unit.INSTANCE;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setUpPollingIfNeeded() {
|
private void setUpPollingIfNeeded() {
|
||||||
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
|
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
|
||||||
if (userPublicKey == null) return;
|
if (userPublicKey == null) return;
|
||||||
@@ -468,7 +437,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
poller.setUserPublicKey(userPublicKey);
|
poller.setUserPublicKey(userPublicKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
poller = new Poller();
|
poller = new Poller(configFactory, new Timer());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void startPollingIfNeeded() {
|
public void startPollingIfNeeded() {
|
||||||
@@ -509,8 +478,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
Log.d("Loki-Avatar", "Uploading Avatar Finished");
|
Log.d("Loki-Avatar", "Uploading Avatar Finished");
|
||||||
return Unit.INSTANCE;
|
return Unit.INSTANCE;
|
||||||
});
|
});
|
||||||
} catch (Exception exception) {
|
} catch (Exception e) {
|
||||||
// Do nothing
|
Log.e("Loki-Avatar", "Uploading avatar failed.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -530,24 +499,21 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void clearAllData(boolean isMigratingToV2KeyPair) {
|
public void clearAllData(boolean isMigratingToV2KeyPair) {
|
||||||
String token = TextSecurePreferences.getFCMToken(this);
|
|
||||||
if (token != null && !token.isEmpty()) {
|
|
||||||
LokiPushNotificationManager.unregister(token, this);
|
|
||||||
}
|
|
||||||
if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive()) {
|
if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive()) {
|
||||||
firebaseInstanceIdJob.cancel(null);
|
firebaseInstanceIdJob.cancel(null);
|
||||||
}
|
}
|
||||||
String displayName = TextSecurePreferences.getProfileName(this);
|
String displayName = TextSecurePreferences.getProfileName(this);
|
||||||
boolean isUsingFCM = TextSecurePreferences.isUsingFCM(this);
|
boolean isUsingFCM = TextSecurePreferences.isPushEnabled(this);
|
||||||
TextSecurePreferences.clearAll(this);
|
TextSecurePreferences.clearAll(this);
|
||||||
if (isMigratingToV2KeyPair) {
|
if (isMigratingToV2KeyPair) {
|
||||||
TextSecurePreferences.setIsUsingFCM(this, isUsingFCM);
|
TextSecurePreferences.setPushEnabled(this, isUsingFCM);
|
||||||
TextSecurePreferences.setProfileName(this, displayName);
|
TextSecurePreferences.setProfileName(this, displayName);
|
||||||
}
|
}
|
||||||
getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit();
|
getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit();
|
||||||
if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) {
|
if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) {
|
||||||
Log.d("Loki", "Failed to delete database.");
|
Log.d("Loki", "Failed to delete database.");
|
||||||
}
|
}
|
||||||
|
configFactory.keyPairChanged();
|
||||||
Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200));
|
Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.thoughtcrime.securesms;
|
package org.thoughtcrime.securesms;
|
||||||
|
|
||||||
|
import static android.os.Build.VERSION.SDK_INT;
|
||||||
import static org.session.libsession.utilities.TextSecurePreferences.SELECTED_ACCENT_COLOR;
|
import static org.session.libsession.utilities.TextSecurePreferences.SELECTED_ACCENT_COLOR;
|
||||||
|
|
||||||
import android.app.ActivityManager;
|
import android.app.ActivityManager;
|
||||||
@@ -18,6 +19,7 @@ import androidx.appcompat.app.AppCompatActivity;
|
|||||||
import org.session.libsession.utilities.TextSecurePreferences;
|
import org.session.libsession.utilities.TextSecurePreferences;
|
||||||
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageActivityHelper;
|
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageActivityHelper;
|
||||||
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
|
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||||
|
import org.thoughtcrime.securesms.conversation.v2.WindowUtil;
|
||||||
import org.thoughtcrime.securesms.util.ActivityUtilitiesKt;
|
import org.thoughtcrime.securesms.util.ActivityUtilitiesKt;
|
||||||
import org.thoughtcrime.securesms.util.ThemeState;
|
import org.thoughtcrime.securesms.util.ThemeState;
|
||||||
import org.thoughtcrime.securesms.util.UiModeUtilities;
|
import org.thoughtcrime.securesms.util.UiModeUtilities;
|
||||||
@@ -92,6 +94,11 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
|
|||||||
if (!currentThemeState.equals(ActivityUtilitiesKt.themeState(getPreferences()))) {
|
if (!currentThemeState.equals(ActivityUtilitiesKt.themeState(getPreferences()))) {
|
||||||
recreate();
|
recreate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// apply lightStatusBar manually as API 26 does not update properly via applyTheme
|
||||||
|
// https://issuetracker.google.com/issues/65883460?pli=1
|
||||||
|
if (SDK_INT >= 26 && SDK_INT <= 27) WindowUtil.setLightStatusBarFromTheme(this);
|
||||||
|
if (SDK_INT == 27) WindowUtil.setLightNavigationBarFromTheme(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package org.thoughtcrime.securesms
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import network.loki.messenger.R
|
||||||
|
|
||||||
|
class DeleteMediaDialog {
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
fun show(context: Context, recordCount: Int, doDelete: Runnable) = context.showSessionDialog {
|
||||||
|
iconAttribute(R.attr.dialog_alert_icon)
|
||||||
|
title(
|
||||||
|
context.resources.getQuantityString(
|
||||||
|
R.plurals.MediaOverviewActivity_Media_delete_confirm_title,
|
||||||
|
recordCount,
|
||||||
|
recordCount
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text(
|
||||||
|
context.resources.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message,
|
||||||
|
recordCount,
|
||||||
|
recordCount
|
||||||
|
)
|
||||||
|
)
|
||||||
|
button(R.string.delete) { doDelete.run() }
|
||||||
|
cancelButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package org.thoughtcrime.securesms
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import network.loki.messenger.R
|
||||||
|
|
||||||
|
class DeleteMediaPreviewDialog {
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
fun show(context: Context, doDelete: Runnable) {
|
||||||
|
context.showSessionDialog {
|
||||||
|
iconAttribute(R.attr.dialog_alert_icon)
|
||||||
|
title(R.string.MediaPreviewActivity_media_delete_confirmation_title)
|
||||||
|
text(R.string.MediaPreviewActivity_media_delete_confirmation_message)
|
||||||
|
button(R.string.delete) { doDelete.run() }
|
||||||
|
cancelButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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,88 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.appcompat.app.AlertDialog;
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.View;
|
|
||||||
import android.widget.TextView;
|
|
||||||
|
|
||||||
import org.session.libsession.utilities.ExpirationUtil;
|
|
||||||
|
|
||||||
import cn.carbswang.android.numberpickerview.library.NumberPickerView;
|
|
||||||
import network.loki.messenger.R;
|
|
||||||
|
|
||||||
public class ExpirationDialog extends AlertDialog {
|
|
||||||
|
|
||||||
protected ExpirationDialog(Context context) {
|
|
||||||
super(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected ExpirationDialog(Context context, int theme) {
|
|
||||||
super(context, theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected ExpirationDialog(Context context, boolean cancelable, OnCancelListener cancelListener) {
|
|
||||||
super(context, cancelable, cancelListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void show(final Context context,
|
|
||||||
final int currentExpiration,
|
|
||||||
final @NonNull OnClickListener listener)
|
|
||||||
{
|
|
||||||
final View view = createNumberPickerView(context, currentExpiration);
|
|
||||||
|
|
||||||
AlertDialog.Builder builder = new AlertDialog.Builder(context);
|
|
||||||
builder.setTitle(context.getString(R.string.ExpirationDialog_disappearing_messages));
|
|
||||||
builder.setView(view);
|
|
||||||
builder.setPositiveButton(android.R.string.ok, (dialog, which) -> {
|
|
||||||
int selected = ((NumberPickerView)view.findViewById(R.id.expiration_number_picker)).getValue();
|
|
||||||
listener.onClick(context.getResources().getIntArray(R.array.expiration_times)[selected]);
|
|
||||||
});
|
|
||||||
builder.setNegativeButton(android.R.string.cancel, null);
|
|
||||||
builder.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static View createNumberPickerView(final Context context, final int currentExpiration) {
|
|
||||||
final LayoutInflater inflater = LayoutInflater.from(context);
|
|
||||||
final View view = inflater.inflate(R.layout.expiration_dialog, null);
|
|
||||||
final NumberPickerView numberPickerView = view.findViewById(R.id.expiration_number_picker);
|
|
||||||
final TextView textView = view.findViewById(R.id.expiration_details);
|
|
||||||
final int[] expirationTimes = context.getResources().getIntArray(R.array.expiration_times);
|
|
||||||
final String[] expirationDisplayValues = new String[expirationTimes.length];
|
|
||||||
|
|
||||||
int selectedIndex = expirationTimes.length - 1;
|
|
||||||
|
|
||||||
for (int i=0;i<expirationTimes.length;i++) {
|
|
||||||
expirationDisplayValues[i] = ExpirationUtil.getExpirationDisplayValue(context, expirationTimes[i]);
|
|
||||||
|
|
||||||
if ((currentExpiration >= expirationTimes[i]) &&
|
|
||||||
(i == expirationTimes.length -1 || currentExpiration < expirationTimes[i+1])) {
|
|
||||||
selectedIndex = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
numberPickerView.setDisplayedValues(expirationDisplayValues);
|
|
||||||
numberPickerView.setMinValue(0);
|
|
||||||
numberPickerView.setMaxValue(expirationTimes.length-1);
|
|
||||||
|
|
||||||
NumberPickerView.OnValueChangeListener listener = (picker, oldVal, newVal) -> {
|
|
||||||
if (newVal == 0) {
|
|
||||||
textView.setText(R.string.ExpirationDialog_your_messages_will_not_expire);
|
|
||||||
} else {
|
|
||||||
textView.setText(context.getString(R.string.ExpirationDialog_your_messages_will_disappear_s_after_they_have_been_seen, picker.getDisplayedValues()[newVal]));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
numberPickerView.setOnValueChangedListener(listener);
|
|
||||||
numberPickerView.setValue(selectedIndex);
|
|
||||||
listener.onValueChange(numberPickerView, selectedIndex, selectedIndex);
|
|
||||||
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface OnClickListener {
|
|
||||||
public void onClick(int expirationTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -54,6 +54,7 @@ import com.google.android.material.tabs.TabLayout;
|
|||||||
|
|
||||||
import org.session.libsession.messaging.messages.control.DataExtractionNotification;
|
import org.session.libsession.messaging.messages.control.DataExtractionNotification;
|
||||||
import org.session.libsession.messaging.sending_receiving.MessageSender;
|
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.Address;
|
||||||
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
|
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
|
||||||
import org.thoughtcrime.securesms.database.MediaDatabase;
|
import org.thoughtcrime.securesms.database.MediaDatabase;
|
||||||
@@ -75,6 +76,7 @@ import java.util.LinkedList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
|
import kotlin.Unit;
|
||||||
import network.loki.messenger.R;
|
import network.loki.messenger.R;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -317,9 +319,9 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity {
|
|||||||
@SuppressWarnings("CodeBlock2Expr")
|
@SuppressWarnings("CodeBlock2Expr")
|
||||||
@SuppressLint({"InlinedApi", "StaticFieldLeak"})
|
@SuppressLint({"InlinedApi", "StaticFieldLeak"})
|
||||||
private void handleSaveMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
|
private void handleSaveMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
|
||||||
final Context context = getContext();
|
final Context context = requireContext();
|
||||||
|
|
||||||
SaveAttachmentTask.showWarningDialog(context, (dialogInterface, which) -> {
|
SaveAttachmentTask.showWarningDialog(context, mediaRecords.size(), () -> {
|
||||||
Permissions.with(this)
|
Permissions.with(this)
|
||||||
.request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
.request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||||
.maxSdkVersion(Build.VERSION_CODES.P)
|
.maxSdkVersion(Build.VERSION_CODES.P)
|
||||||
@@ -361,53 +363,39 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity {
|
|||||||
}.execute();
|
}.execute();
|
||||||
})
|
})
|
||||||
.execute();
|
.execute();
|
||||||
}, mediaRecords.size());
|
return Unit.INSTANCE;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendMediaSavedNotificationIfNeeded() {
|
private void sendMediaSavedNotificationIfNeeded() {
|
||||||
if (recipient.isGroupRecipient()) return;
|
if (recipient.isGroupRecipient()) return;
|
||||||
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(System.currentTimeMillis()));
|
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset()));
|
||||||
MessageSender.send(message, recipient.getAddress());
|
MessageSender.send(message, recipient.getAddress());
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("StaticFieldLeak")
|
@SuppressLint("StaticFieldLeak")
|
||||||
private void handleDeleteMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
|
private void handleDeleteMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
|
||||||
int recordCount = mediaRecords.size();
|
int recordCount = mediaRecords.size();
|
||||||
Resources res = getContext().getResources();
|
|
||||||
String confirmTitle = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_title,
|
|
||||||
recordCount,
|
|
||||||
recordCount);
|
|
||||||
String confirmMessage = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message,
|
|
||||||
recordCount,
|
|
||||||
recordCount);
|
|
||||||
|
|
||||||
AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
|
DeleteMediaDialog.show(
|
||||||
builder.setIconAttribute(R.attr.dialog_alert_icon);
|
requireContext(),
|
||||||
builder.setTitle(confirmTitle);
|
recordCount,
|
||||||
builder.setMessage(confirmMessage);
|
() -> new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>(
|
||||||
builder.setCancelable(true);
|
requireContext(),
|
||||||
|
R.string.MediaOverviewActivity_Media_delete_progress_title,
|
||||||
builder.setPositiveButton(R.string.delete, (dialogInterface, i) -> {
|
R.string.MediaOverviewActivity_Media_delete_progress_message) {
|
||||||
new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>(getContext(),
|
@Override
|
||||||
R.string.MediaOverviewActivity_Media_delete_progress_title,
|
protected Void doInBackground(MediaDatabase.MediaRecord... records) {
|
||||||
R.string.MediaOverviewActivity_Media_delete_progress_message)
|
if (records == null || records.length == 0) {
|
||||||
{
|
|
||||||
@Override
|
|
||||||
protected Void doInBackground(MediaDatabase.MediaRecord... records) {
|
|
||||||
if (records == null || records.length == 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (MediaDatabase.MediaRecord record : records) {
|
|
||||||
AttachmentUtil.deleteAttachment(getContext(), record.getAttachment());
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
}.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()]));
|
for (MediaDatabase.MediaRecord record : records) {
|
||||||
});
|
AttachmentUtil.deleteAttachment(getContext(), record.getAttachment());
|
||||||
builder.setNegativeButton(android.R.string.cancel, null);
|
}
|
||||||
builder.show();
|
return null;
|
||||||
|
}
|
||||||
|
}.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()])));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleSelectAllMedia() {
|
private void handleSelectAllMedia() {
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ import android.widget.Toast;
|
|||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.appcompat.app.ActionBar;
|
import androidx.appcompat.app.ActionBar;
|
||||||
import androidx.appcompat.app.AlertDialog;
|
|
||||||
import androidx.core.util.Pair;
|
import androidx.core.util.Pair;
|
||||||
import androidx.lifecycle.ViewModelProvider;
|
import androidx.lifecycle.ViewModelProvider;
|
||||||
import androidx.loader.app.LoaderManager;
|
import androidx.loader.app.LoaderManager;
|
||||||
@@ -60,6 +59,7 @@ import androidx.viewpager.widget.ViewPager;
|
|||||||
import org.session.libsession.messaging.messages.control.DataExtractionNotification;
|
import org.session.libsession.messaging.messages.control.DataExtractionNotification;
|
||||||
import org.session.libsession.messaging.sending_receiving.MessageSender;
|
import org.session.libsession.messaging.sending_receiving.MessageSender;
|
||||||
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment;
|
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment;
|
||||||
|
import org.session.libsession.snode.SnodeAPI;
|
||||||
import org.session.libsession.utilities.Address;
|
import org.session.libsession.utilities.Address;
|
||||||
import org.session.libsession.utilities.Util;
|
import org.session.libsession.utilities.Util;
|
||||||
import org.session.libsession.utilities.recipients.Recipient;
|
import org.session.libsession.utilities.recipients.Recipient;
|
||||||
@@ -84,6 +84,7 @@ import java.io.IOException;
|
|||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.WeakHashMap;
|
import java.util.WeakHashMap;
|
||||||
|
|
||||||
|
import kotlin.Unit;
|
||||||
import network.loki.messenger.R;
|
import network.loki.messenger.R;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,6 +146,10 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public static Intent getPreviewIntent(Context context, MediaPreviewArgs args) {
|
||||||
|
return getPreviewIntent(context, args.getSlide(), args.getMmsRecord(), args.getThread());
|
||||||
|
}
|
||||||
|
|
||||||
public static Intent getPreviewIntent(Context context, Slide slide, MmsMessageRecord mms, Recipient threadRecipient) {
|
public static Intent getPreviewIntent(Context context, Slide slide, MmsMessageRecord mms, Recipient threadRecipient) {
|
||||||
Intent previewIntent = null;
|
Intent previewIntent = null;
|
||||||
if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) {
|
if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) {
|
||||||
@@ -415,7 +420,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
|
|||||||
MediaItem mediaItem = getCurrentMediaItem();
|
MediaItem mediaItem = getCurrentMediaItem();
|
||||||
if (mediaItem == null) return;
|
if (mediaItem == null) return;
|
||||||
|
|
||||||
SaveAttachmentTask.showWarningDialog(this, (dialogInterface, i) -> {
|
SaveAttachmentTask.showWarningDialog(this, 1, () -> {
|
||||||
Permissions.with(this)
|
Permissions.with(this)
|
||||||
.request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
.request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||||
.maxSdkVersion(Build.VERSION_CODES.P)
|
.maxSdkVersion(Build.VERSION_CODES.P)
|
||||||
@@ -423,7 +428,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
|
|||||||
.onAnyDenied(() -> Toast.makeText(this, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show())
|
.onAnyDenied(() -> Toast.makeText(this, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show())
|
||||||
.onAllGranted(() -> {
|
.onAllGranted(() -> {
|
||||||
SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this);
|
SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this);
|
||||||
long saveDate = (mediaItem.date > 0) ? mediaItem.date : System.currentTimeMillis();
|
long saveDate = (mediaItem.date > 0) ? mediaItem.date : SnodeAPI.getNowWithOffset();
|
||||||
saveTask.executeOnExecutor(
|
saveTask.executeOnExecutor(
|
||||||
AsyncTask.THREAD_POOL_EXECUTOR,
|
AsyncTask.THREAD_POOL_EXECUTOR,
|
||||||
new Attachment(mediaItem.uri, mediaItem.type, saveDate, null));
|
new Attachment(mediaItem.uri, mediaItem.type, saveDate, null));
|
||||||
@@ -432,12 +437,13 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.execute();
|
.execute();
|
||||||
|
return Unit.INSTANCE;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendMediaSavedNotificationIfNeeded() {
|
private void sendMediaSavedNotificationIfNeeded() {
|
||||||
if (conversationRecipient.isGroupRecipient()) return;
|
if (conversationRecipient.isGroupRecipient()) return;
|
||||||
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(System.currentTimeMillis()));
|
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset()));
|
||||||
MessageSender.send(message, conversationRecipient.getAddress());
|
MessageSender.send(message, conversationRecipient.getAddress());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,29 +454,20 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AlertDialog.Builder builder = new AlertDialog.Builder(this);
|
DeleteMediaPreviewDialog.show(this, () -> {
|
||||||
builder.setIconAttribute(R.attr.dialog_alert_icon);
|
new AsyncTask<Void, Void, Void>() {
|
||||||
builder.setTitle(R.string.MediaPreviewActivity_media_delete_confirmation_title);
|
@Override
|
||||||
builder.setMessage(R.string.MediaPreviewActivity_media_delete_confirmation_message);
|
protected Void doInBackground(Void... voids) {
|
||||||
builder.setCancelable(true);
|
DatabaseAttachment attachment = mediaItem.attachment;
|
||||||
|
if (attachment != null) {
|
||||||
builder.setPositiveButton(R.string.delete, (dialogInterface, which) -> {
|
AttachmentUtil.deleteAttachment(getApplicationContext(), attachment);
|
||||||
new AsyncTask<Void, Void, Void>() {
|
}
|
||||||
@Override
|
return null;
|
||||||
protected Void doInBackground(Void... voids) {
|
}
|
||||||
if (mediaItem.attachment == null) {
|
}.execute();
|
||||||
return null;
|
|
||||||
}
|
|
||||||
AttachmentUtil.deleteAttachment(MediaPreviewActivity.this.getApplicationContext(),
|
|
||||||
mediaItem.attachment);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}.execute();
|
|
||||||
|
|
||||||
finish();
|
finish();
|
||||||
});
|
});
|
||||||
builder.setNegativeButton(android.R.string.cancel, null);
|
|
||||||
builder.show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -530,18 +527,21 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
|
|||||||
@Override
|
@Override
|
||||||
public void onLoadFinished(@NonNull Loader<Pair<Cursor, Integer>> loader, @Nullable Pair<Cursor, Integer> data) {
|
public void onLoadFinished(@NonNull Loader<Pair<Cursor, Integer>> loader, @Nullable Pair<Cursor, Integer> data) {
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
@SuppressWarnings("ConstantConditions")
|
|
||||||
CursorPagerAdapter adapter = new CursorPagerAdapter(this, GlideApp.with(this), getWindow(), data.first, data.second, leftIsRecent);
|
CursorPagerAdapter adapter = new CursorPagerAdapter(this, GlideApp.with(this), getWindow(), data.first, data.second, leftIsRecent);
|
||||||
mediaPager.setAdapter(adapter);
|
mediaPager.setAdapter(adapter);
|
||||||
adapter.setActive(true);
|
adapter.setActive(true);
|
||||||
|
|
||||||
viewModel.setCursor(this, data.first, leftIsRecent);
|
viewModel.setCursor(this, data.first, leftIsRecent);
|
||||||
|
|
||||||
int item = restartItem >= 0 ? restartItem : data.second;
|
if (restartItem >= 0 || data.second >= 0) {
|
||||||
mediaPager.setCurrentItem(item);
|
int item = restartItem >= 0 ? restartItem : data.second;
|
||||||
|
mediaPager.setCurrentItem(item);
|
||||||
|
|
||||||
if (item == 0) {
|
if (item == 0) {
|
||||||
viewPagerListener.onPageSelected(0);
|
viewPagerListener.onPageSelected(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "one of restartItem "+restartItem+" and data.second "+data.second+" would cause OOB exception");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.thoughtcrime.securesms
|
||||||
|
|
||||||
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
|
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||||
|
import org.thoughtcrime.securesms.mms.Slide
|
||||||
|
|
||||||
|
data class MediaPreviewArgs(
|
||||||
|
val slide: Slide,
|
||||||
|
val mmsRecord: MmsMessageRecord?,
|
||||||
|
val thread: Recipient?,
|
||||||
|
)
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.DialogInterface;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.appcompat.app.AlertDialog;
|
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
import network.loki.messenger.R;
|
|
||||||
|
|
||||||
public class MuteDialog extends AlertDialog {
|
|
||||||
|
|
||||||
|
|
||||||
protected MuteDialog(Context context) {
|
|
||||||
super(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected MuteDialog(Context context, boolean cancelable, OnCancelListener cancelListener) {
|
|
||||||
super(context, cancelable, cancelListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected MuteDialog(Context context, int theme) {
|
|
||||||
super(context, theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void show(final Context context, final @NonNull MuteSelectionListener listener) {
|
|
||||||
AlertDialog.Builder builder = new AlertDialog.Builder(context);
|
|
||||||
builder.setTitle(R.string.MuteDialog_mute_notifications);
|
|
||||||
builder.setItems(R.array.mute_durations, new DialogInterface.OnClickListener() {
|
|
||||||
@Override
|
|
||||||
public void onClick(DialogInterface dialog, final int which) {
|
|
||||||
final long muteUntil;
|
|
||||||
|
|
||||||
switch (which) {
|
|
||||||
case 1: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(2); break;
|
|
||||||
case 2: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1); break;
|
|
||||||
case 3: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7); break;
|
|
||||||
case 4: muteUntil = Long.MAX_VALUE; break;
|
|
||||||
default: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
|
|
||||||
}
|
|
||||||
|
|
||||||
listener.onMuted(muteUntil);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.show();
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface MuteSelectionListener {
|
|
||||||
public void onMuted(long until);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
27
app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt
Normal file
27
app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package org.thoughtcrime.securesms
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import network.loki.messenger.R
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
fun showMuteDialog(
|
||||||
|
context: Context,
|
||||||
|
onMuteDuration: (Long) -> Unit
|
||||||
|
): AlertDialog = context.showSessionDialog {
|
||||||
|
title(R.string.MuteDialog_mute_notifications)
|
||||||
|
items(Option.values().map { it.stringRes }.map(context::getString).toTypedArray()) {
|
||||||
|
onMuteDuration(Option.values()[it].getTime())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class Option(@StringRes val stringRes: Int, val getTime: () -> Long) {
|
||||||
|
ONE_HOUR(R.string.arrays__mute_for_one_hour, duration = TimeUnit.HOURS.toMillis(1)),
|
||||||
|
TWO_HOURS(R.string.arrays__mute_for_two_hours, duration = TimeUnit.DAYS.toMillis(2)),
|
||||||
|
ONE_DAY(R.string.arrays__mute_for_one_day, duration = TimeUnit.DAYS.toMillis(1)),
|
||||||
|
SEVEN_DAYS(R.string.arrays__mute_for_seven_days, duration = TimeUnit.DAYS.toMillis(7)),
|
||||||
|
FOREVER(R.string.arrays__mute_forever, getTime = { Long.MAX_VALUE });
|
||||||
|
|
||||||
|
constructor(@StringRes stringRes: Int, duration: Long): this(stringRes, { System.currentTimeMillis() + duration })
|
||||||
|
}
|
||||||
@@ -9,13 +9,14 @@ import android.os.Bundle;
|
|||||||
import androidx.annotation.IdRes;
|
import androidx.annotation.IdRes;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
import androidx.fragment.app.Fragment;
|
import androidx.fragment.app.Fragment;
|
||||||
|
|
||||||
|
import org.session.libsession.utilities.TextSecurePreferences;
|
||||||
import org.session.libsignal.utilities.Log;
|
import org.session.libsignal.utilities.Log;
|
||||||
import org.thoughtcrime.securesms.home.HomeActivity;
|
import org.thoughtcrime.securesms.home.HomeActivity;
|
||||||
import org.thoughtcrime.securesms.onboarding.LandingActivity;
|
import org.thoughtcrime.securesms.onboarding.LandingActivity;
|
||||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||||
import org.session.libsession.utilities.TextSecurePreferences;
|
|
||||||
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
@@ -168,7 +169,13 @@ public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarA
|
|||||||
};
|
};
|
||||||
|
|
||||||
IntentFilter filter = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT);
|
IntentFilter filter = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT);
|
||||||
registerReceiver(clearKeyReceiver, filter, KeyCachingService.KEY_PERMISSION, null);
|
ContextCompat.registerReceiver(
|
||||||
|
this,
|
||||||
|
clearKeyReceiver, filter,
|
||||||
|
KeyCachingService.KEY_PERMISSION,
|
||||||
|
null,
|
||||||
|
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void removeClearKeyReceiver(Context context) {
|
private void removeClearKeyReceiver(Context context) {
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package org.thoughtcrime.securesms
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.LinearLayout.VERTICAL
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.annotation.LayoutRes
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.annotation.StyleRes
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.view.setMargins
|
||||||
|
import androidx.core.view.setPadding
|
||||||
|
import androidx.core.view.updateMargins
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import network.loki.messenger.R
|
||||||
|
import org.thoughtcrime.securesms.util.toPx
|
||||||
|
|
||||||
|
|
||||||
|
@DslMarker
|
||||||
|
@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE)
|
||||||
|
annotation class DialogDsl
|
||||||
|
|
||||||
|
@DialogDsl
|
||||||
|
class SessionDialogBuilder(val context: Context) {
|
||||||
|
|
||||||
|
private val dp20 = toPx(20, context.resources)
|
||||||
|
private val dp40 = toPx(40, context.resources)
|
||||||
|
|
||||||
|
private val dialogBuilder: AlertDialog.Builder = AlertDialog.Builder(context)
|
||||||
|
|
||||||
|
private var dialog: AlertDialog? = null
|
||||||
|
private fun dismiss() = dialog?.dismiss()
|
||||||
|
|
||||||
|
private val topView = LinearLayout(context).apply { orientation = VERTICAL }
|
||||||
|
.also(dialogBuilder::setCustomTitle)
|
||||||
|
private val contentView = LinearLayout(context).apply { orientation = VERTICAL }
|
||||||
|
private val buttonLayout = LinearLayout(context)
|
||||||
|
|
||||||
|
private val root = LinearLayout(context).apply { orientation = VERTICAL }
|
||||||
|
.also(dialogBuilder::setView)
|
||||||
|
.apply {
|
||||||
|
addView(contentView)
|
||||||
|
addView(buttonLayout)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun title(@StringRes id: Int) = title(context.getString(id))
|
||||||
|
|
||||||
|
fun title(text: CharSequence?) = title(text?.toString())
|
||||||
|
fun title(text: String?) {
|
||||||
|
text(text, R.style.TextAppearance_AppCompat_Title) { setPadding(dp20) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun text(@StringRes id: Int, style: Int = 0) = text(context.getString(id), style)
|
||||||
|
fun text(text: CharSequence?, @StyleRes style: Int = 0) {
|
||||||
|
text(text, style) {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
|
||||||
|
.apply { updateMargins(dp40, 0, dp40, dp20) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun text(text: CharSequence?, @StyleRes style: Int, modify: TextView.() -> Unit) {
|
||||||
|
text ?: return
|
||||||
|
TextView(context, null, 0, style)
|
||||||
|
.apply {
|
||||||
|
setText(text)
|
||||||
|
textAlignment = View.TEXT_ALIGNMENT_CENTER
|
||||||
|
modify()
|
||||||
|
}.let(topView::addView)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun view(view: View) = contentView.addView(view)
|
||||||
|
|
||||||
|
fun view(@LayoutRes layout: Int): View = LayoutInflater.from(context).inflate(layout, contentView)
|
||||||
|
|
||||||
|
fun iconAttribute(@AttrRes icon: Int): AlertDialog.Builder = dialogBuilder.setIconAttribute(icon)
|
||||||
|
|
||||||
|
fun singleChoiceItems(
|
||||||
|
options: Collection<String>,
|
||||||
|
currentSelected: Int = 0,
|
||||||
|
onSelect: (Int) -> Unit
|
||||||
|
) = singleChoiceItems(options.toTypedArray(), currentSelected, onSelect)
|
||||||
|
|
||||||
|
fun singleChoiceItems(
|
||||||
|
options: Array<String>,
|
||||||
|
currentSelected: Int = 0,
|
||||||
|
onSelect: (Int) -> Unit
|
||||||
|
): AlertDialog.Builder = dialogBuilder.setSingleChoiceItems(
|
||||||
|
options,
|
||||||
|
currentSelected
|
||||||
|
) { dialog, it -> onSelect(it); dialog.dismiss() }
|
||||||
|
|
||||||
|
fun items(
|
||||||
|
options: Array<String>,
|
||||||
|
onSelect: (Int) -> Unit
|
||||||
|
): AlertDialog.Builder = dialogBuilder.setItems(
|
||||||
|
options,
|
||||||
|
) { dialog, it -> onSelect(it); dialog.dismiss() }
|
||||||
|
|
||||||
|
fun destructiveButton(
|
||||||
|
@StringRes text: Int,
|
||||||
|
@StringRes contentDescription: Int = text,
|
||||||
|
listener: () -> Unit = {}
|
||||||
|
) = button(
|
||||||
|
text,
|
||||||
|
contentDescription,
|
||||||
|
R.style.Widget_Session_Button_Dialog_DestructiveText,
|
||||||
|
) { listener() }
|
||||||
|
|
||||||
|
fun okButton(listener: (() -> Unit) = {}) = button(android.R.string.ok) { listener() }
|
||||||
|
fun cancelButton(listener: (() -> Unit) = {}) = button(android.R.string.cancel, R.string.AccessibilityId_cancel_button) { listener() }
|
||||||
|
|
||||||
|
fun button(
|
||||||
|
@StringRes text: Int,
|
||||||
|
@StringRes contentDescriptionRes: Int = text,
|
||||||
|
@StyleRes style: Int = R.style.Widget_Session_Button_Dialog_UnimportantText,
|
||||||
|
dismiss: Boolean = true,
|
||||||
|
listener: (() -> Unit) = {}
|
||||||
|
) = Button(context, null, 0, style).apply {
|
||||||
|
setText(text)
|
||||||
|
contentDescription = resources.getString(contentDescriptionRes)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, 1f)
|
||||||
|
.apply { setMargins(toPx(20, resources)) }
|
||||||
|
setOnClickListener {
|
||||||
|
listener.invoke()
|
||||||
|
if (dismiss) dismiss()
|
||||||
|
}
|
||||||
|
}.let(buttonLayout::addView)
|
||||||
|
|
||||||
|
fun create(): AlertDialog = dialogBuilder.create().also { dialog = it }
|
||||||
|
fun show(): AlertDialog = dialogBuilder.show().also { dialog = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Context.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog =
|
||||||
|
SessionDialogBuilder(this).apply { build() }.show()
|
||||||
|
|
||||||
|
fun Fragment.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog =
|
||||||
|
SessionDialogBuilder(requireContext()).apply { build() }.show()
|
||||||
|
fun Fragment.createSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog =
|
||||||
|
SessionDialogBuilder(requireContext()).apply { build() }.create()
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms;
|
|
||||||
|
|
||||||
public interface Unbindable {
|
|
||||||
public void unbind();
|
|
||||||
}
|
|
||||||
@@ -184,18 +184,23 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
|
|||||||
override fun deleteMessage(messageID: Long, isSms: Boolean) {
|
override fun deleteMessage(messageID: Long, isSms: Boolean) {
|
||||||
val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase()
|
val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase()
|
||||||
else DatabaseComponent.get(context).mmsDatabase()
|
else DatabaseComponent.get(context).mmsDatabase()
|
||||||
|
|
||||||
messagingDatabase.deleteMessage(messageID)
|
messagingDatabase.deleteMessage(messageID)
|
||||||
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessage(messageID, isSms)
|
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessage(messageID, isSms)
|
||||||
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID)
|
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID, mms = !isSms)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deleteMessages(messageIDs: List<Long>, threadId: Long, isSms: Boolean) {
|
override fun deleteMessages(messageIDs: List<Long>, threadId: Long, isSms: Boolean) {
|
||||||
|
|
||||||
val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase()
|
val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase()
|
||||||
else DatabaseComponent.get(context).mmsDatabase()
|
else DatabaseComponent.get(context).mmsDatabase()
|
||||||
|
|
||||||
|
// Perform local delete
|
||||||
messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId)
|
messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId)
|
||||||
|
|
||||||
|
// Perform online delete
|
||||||
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs)
|
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs)
|
||||||
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs)
|
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs, mms = !isSms)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? {
|
override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? {
|
||||||
@@ -212,15 +217,12 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
|
|||||||
return message.id
|
return message.id
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getServerHashForMessage(messageID: Long): String? {
|
override fun getServerHashForMessage(messageID: Long, mms: Boolean): String? =
|
||||||
val messageDB = DatabaseComponent.get(context).lokiMessageDatabase()
|
DatabaseComponent.get(context).lokiMessageDatabase().getMessageServerHash(messageID, mms)
|
||||||
return messageDB.getMessageServerHash(messageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? {
|
override fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? =
|
||||||
val attachmentDatabase = DatabaseComponent.get(context).attachmentDatabase()
|
DatabaseComponent.get(context).attachmentDatabase()
|
||||||
return attachmentDatabase.getAttachment(AttachmentId(attachmentId, 0))
|
.getAttachment(AttachmentId(attachmentId, 0))
|
||||||
}
|
|
||||||
|
|
||||||
private fun scaleAndStripExif(attachmentDatabase: AttachmentDatabase, constraints: MediaConstraints, attachment: Attachment): Attachment? {
|
private fun scaleAndStripExif(attachmentDatabase: AttachmentDatabase, constraints: MediaConstraints, attachment: Attachment): Attachment? {
|
||||||
return try {
|
return try {
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import android.os.Build
|
|||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
import org.session.libsignal.utilities.Log
|
||||||
|
import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer
|
||||||
|
|
||||||
|
private const val TAG = "ScreenshotObserver"
|
||||||
|
|
||||||
class ScreenshotObserver(private val context: Context, handler: Handler, private val screenshotTriggered: ()->Unit): ContentObserver(handler) {
|
class ScreenshotObserver(private val context: Context, handler: Handler, private val screenshotTriggered: ()->Unit): ContentObserver(handler) {
|
||||||
|
|
||||||
@@ -31,22 +35,26 @@ class ScreenshotObserver(private val context: Context, handler: Handler, private
|
|||||||
val projection = arrayOf(
|
val projection = arrayOf(
|
||||||
MediaStore.Images.Media.DATA
|
MediaStore.Images.Media.DATA
|
||||||
)
|
)
|
||||||
context.contentResolver.query(
|
try {
|
||||||
uri,
|
context.contentResolver.query(
|
||||||
projection,
|
uri,
|
||||||
null,
|
projection,
|
||||||
null,
|
null,
|
||||||
null
|
null,
|
||||||
)?.use { cursor ->
|
null
|
||||||
val dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA)
|
)?.use { cursor ->
|
||||||
while (cursor.moveToNext()) {
|
val dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA)
|
||||||
val path = cursor.getString(dataColumn)
|
while (cursor.moveToNext()) {
|
||||||
if (path.contains("screenshot", true)) {
|
val path = cursor.getString(dataColumn)
|
||||||
if (cache.add(uri.hashCode())) {
|
if (path.contains("screenshot", true)) {
|
||||||
screenshotTriggered()
|
if (cache.add(uri.hashCode())) {
|
||||||
|
screenshotTriggered()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,28 +64,32 @@ class ScreenshotObserver(private val context: Context, handler: Handler, private
|
|||||||
MediaStore.Images.Media.DISPLAY_NAME,
|
MediaStore.Images.Media.DISPLAY_NAME,
|
||||||
MediaStore.Images.Media.RELATIVE_PATH
|
MediaStore.Images.Media.RELATIVE_PATH
|
||||||
)
|
)
|
||||||
context.contentResolver.query(
|
|
||||||
uri,
|
try {
|
||||||
projection,
|
context.contentResolver.query(
|
||||||
null,
|
uri,
|
||||||
null,
|
projection,
|
||||||
null
|
null,
|
||||||
)?.use { cursor ->
|
null,
|
||||||
val relativePathColumn =
|
null
|
||||||
cursor.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH)
|
)?.use { cursor ->
|
||||||
val displayNameColumn =
|
val relativePathColumn =
|
||||||
cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
|
cursor.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH)
|
||||||
while (cursor.moveToNext()) {
|
val displayNameColumn =
|
||||||
val name = cursor.getString(displayNameColumn)
|
cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
|
||||||
val relativePath = cursor.getString(relativePathColumn)
|
while (cursor.moveToNext()) {
|
||||||
if (name.contains("screenshot", true) or
|
val name = cursor.getString(displayNameColumn)
|
||||||
relativePath.contains("screenshot", true)) {
|
val relativePath = cursor.getString(relativePathColumn)
|
||||||
if (cache.add(uri.hashCode())) {
|
if (name.contains("screenshot", true) or
|
||||||
screenshotTriggered()
|
relativePath.contains("screenshot", true)) {
|
||||||
|
if (cache.add(uri.hashCode())) {
|
||||||
|
screenshotTriggered()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
Log.e(TAG, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms.backup
|
|
||||||
|
|
||||||
data class BackupEvent constructor(val type: Type, val count: Int, val exception: Exception?) {
|
|
||||||
|
|
||||||
enum class Type {
|
|
||||||
PROGRESS, FINISHED
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
@JvmStatic fun createProgress(count: Int) = BackupEvent(Type.PROGRESS, count, null)
|
|
||||||
@JvmStatic fun createFinished() = BackupEvent(Type.FINISHED, 0, null)
|
|
||||||
@JvmStatic fun createFinished(e: Exception?) = BackupEvent(Type.FINISHED, 0, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms.backup;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.os.Build;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.crypto.KeyStoreHelper;
|
|
||||||
import org.session.libsignal.utilities.Log;
|
|
||||||
import org.session.libsession.utilities.TextSecurePreferences;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allows the getting and setting of the backup passphrase, which is stored encrypted on API >= 23.
|
|
||||||
*/
|
|
||||||
public class BackupPassphrase {
|
|
||||||
|
|
||||||
private static final String TAG = BackupPassphrase.class.getSimpleName();
|
|
||||||
|
|
||||||
public static @Nullable String get(@NonNull Context context) {
|
|
||||||
String passphrase = TextSecurePreferences.getBackupPassphrase(context);
|
|
||||||
String encryptedPassphrase = TextSecurePreferences.getEncryptedBackupPassphrase(context);
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT < 23 || (passphrase == null && encryptedPassphrase == null)) {
|
|
||||||
return passphrase;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (encryptedPassphrase == null) {
|
|
||||||
Log.i(TAG, "Migrating to encrypted passphrase.");
|
|
||||||
set(context, passphrase);
|
|
||||||
encryptedPassphrase = TextSecurePreferences.getEncryptedBackupPassphrase(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyStoreHelper.SealedData data = KeyStoreHelper.SealedData.fromString(encryptedPassphrase);
|
|
||||||
return new String(KeyStoreHelper.unseal(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void set(@NonNull Context context, @Nullable String passphrase) {
|
|
||||||
if (passphrase == null || Build.VERSION.SDK_INT < 23) {
|
|
||||||
TextSecurePreferences.setBackupPassphrase(context, passphrase);
|
|
||||||
TextSecurePreferences.setEncryptedBackupPassphrase(context, null);
|
|
||||||
} else {
|
|
||||||
KeyStoreHelper.SealedData encryptedPassphrase = KeyStoreHelper.seal(passphrase.getBytes());
|
|
||||||
TextSecurePreferences.setEncryptedBackupPassphrase(context, encryptedPassphrase.serialize());
|
|
||||||
TextSecurePreferences.setBackupPassphrase(context, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms.backup
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.os.Build
|
|
||||||
import android.preference.PreferenceManager
|
|
||||||
import android.preference.PreferenceManager.getDefaultSharedPreferencesName
|
|
||||||
import org.session.libsession.utilities.TextSecurePreferences
|
|
||||||
import org.session.libsignal.utilities.Log
|
|
||||||
import org.thoughtcrime.securesms.backup.FullBackupImporter.PREF_PREFIX_TYPE_BOOLEAN
|
|
||||||
import org.thoughtcrime.securesms.backup.FullBackupImporter.PREF_PREFIX_TYPE_INT
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
object BackupPreferences {
|
|
||||||
// region Backup related
|
|
||||||
fun getBackupRecords(context: Context): List<BackupProtos.SharedPreference> {
|
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
|
||||||
val prefsFileName: String
|
|
||||||
prefsFileName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
||||||
getDefaultSharedPreferencesName(context)
|
|
||||||
} else {
|
|
||||||
context.packageName + "_preferences"
|
|
||||||
}
|
|
||||||
val prefList: LinkedList<BackupProtos.SharedPreference> = LinkedList<BackupProtos.SharedPreference>()
|
|
||||||
addBackupEntryInt(prefList, preferences, prefsFileName, TextSecurePreferences.LOCAL_REGISTRATION_ID_PREF)
|
|
||||||
addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.LOCAL_NUMBER_PREF)
|
|
||||||
addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_NAME_PREF)
|
|
||||||
addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_AVATAR_URL_PREF)
|
|
||||||
addBackupEntryInt(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_AVATAR_ID_PREF)
|
|
||||||
addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_KEY_PREF)
|
|
||||||
addBackupEntryBoolean(prefList, preferences, prefsFileName, TextSecurePreferences.IS_USING_FCM)
|
|
||||||
return prefList
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addBackupEntryString(
|
|
||||||
outPrefList: MutableList<BackupProtos.SharedPreference>,
|
|
||||||
prefs: SharedPreferences,
|
|
||||||
prefFileName: String,
|
|
||||||
prefKey: String,
|
|
||||||
) {
|
|
||||||
val value = prefs.getString(prefKey, null)
|
|
||||||
if (value == null) {
|
|
||||||
logBackupEntry(prefKey, false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
outPrefList.add(BackupProtos.SharedPreference.newBuilder()
|
|
||||||
.setFile(prefFileName)
|
|
||||||
.setKey(prefKey)
|
|
||||||
.setValue(value)
|
|
||||||
.build())
|
|
||||||
logBackupEntry(prefKey, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addBackupEntryInt(
|
|
||||||
outPrefList: MutableList<BackupProtos.SharedPreference>,
|
|
||||||
prefs: SharedPreferences,
|
|
||||||
prefFileName: String,
|
|
||||||
prefKey: String,
|
|
||||||
) {
|
|
||||||
val value = prefs.getInt(prefKey, -1)
|
|
||||||
if (value == -1) {
|
|
||||||
logBackupEntry(prefKey, false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
outPrefList.add(BackupProtos.SharedPreference.newBuilder()
|
|
||||||
.setFile(prefFileName)
|
|
||||||
.setKey(PREF_PREFIX_TYPE_INT + prefKey) // The prefix denotes the type of the preference.
|
|
||||||
.setValue(value.toString())
|
|
||||||
.build())
|
|
||||||
logBackupEntry(prefKey, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addBackupEntryBoolean(
|
|
||||||
outPrefList: MutableList<BackupProtos.SharedPreference>,
|
|
||||||
prefs: SharedPreferences,
|
|
||||||
prefFileName: String,
|
|
||||||
prefKey: String,
|
|
||||||
) {
|
|
||||||
if (!prefs.contains(prefKey)) {
|
|
||||||
logBackupEntry(prefKey, false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
outPrefList.add(BackupProtos.SharedPreference.newBuilder()
|
|
||||||
.setFile(prefFileName)
|
|
||||||
.setKey(PREF_PREFIX_TYPE_BOOLEAN + prefKey) // The prefix denotes the type of the preference.
|
|
||||||
.setValue(prefs.getBoolean(prefKey, false).toString())
|
|
||||||
.build())
|
|
||||||
logBackupEntry(prefKey, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun logBackupEntry(prefName: String, wasIncluded: Boolean) {
|
|
||||||
val sb = StringBuilder()
|
|
||||||
sb.append("Backup preference ")
|
|
||||||
sb.append(if (wasIncluded) "+ " else "- ")
|
|
||||||
sb.append('\"').append(prefName).append("\" ")
|
|
||||||
if (!wasIncluded) {
|
|
||||||
sb.append("(is empty and not included)")
|
|
||||||
}
|
|
||||||
Log.d("Loki", sb.toString())
|
|
||||||
} // endregion
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,447 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms.backup
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.Cursor
|
|
||||||
import android.net.Uri
|
|
||||||
import android.text.TextUtils
|
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import com.annimon.stream.function.Consumer
|
|
||||||
import com.annimon.stream.function.Predicate
|
|
||||||
import com.google.protobuf.ByteString
|
|
||||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
|
||||||
import org.greenrobot.eventbus.EventBus
|
|
||||||
import org.session.libsession.avatars.AvatarHelper
|
|
||||||
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
|
|
||||||
import org.session.libsession.utilities.Conversions
|
|
||||||
import org.session.libsession.utilities.Util
|
|
||||||
import org.session.libsignal.crypto.kdf.HKDFv3
|
|
||||||
import org.session.libsignal.utilities.ByteUtil
|
|
||||||
import org.session.libsignal.utilities.Log
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.Attachment
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.Avatar
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.Header
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.Sticker
|
|
||||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret
|
|
||||||
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream
|
|
||||||
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream
|
|
||||||
import org.thoughtcrime.securesms.database.AttachmentDatabase
|
|
||||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase
|
|
||||||
import org.thoughtcrime.securesms.database.JobDatabase
|
|
||||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase
|
|
||||||
import org.thoughtcrime.securesms.database.LokiBackupFilesDatabase
|
|
||||||
import org.thoughtcrime.securesms.database.MmsDatabase
|
|
||||||
import org.thoughtcrime.securesms.database.MmsSmsColumns
|
|
||||||
import org.thoughtcrime.securesms.database.PushDatabase
|
|
||||||
import org.thoughtcrime.securesms.database.SearchDatabase
|
|
||||||
import org.thoughtcrime.securesms.database.SmsDatabase
|
|
||||||
import org.thoughtcrime.securesms.util.BackupUtil
|
|
||||||
import java.io.Closeable
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileInputStream
|
|
||||||
import java.io.Flushable
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.security.InvalidAlgorithmParameterException
|
|
||||||
import java.security.InvalidKeyException
|
|
||||||
import java.security.NoSuchAlgorithmException
|
|
||||||
import java.util.LinkedList
|
|
||||||
import javax.crypto.BadPaddingException
|
|
||||||
import javax.crypto.Cipher
|
|
||||||
import javax.crypto.IllegalBlockSizeException
|
|
||||||
import javax.crypto.Mac
|
|
||||||
import javax.crypto.NoSuchPaddingException
|
|
||||||
import javax.crypto.spec.IvParameterSpec
|
|
||||||
import javax.crypto.spec.SecretKeySpec
|
|
||||||
|
|
||||||
object FullBackupExporter {
|
|
||||||
private val TAG = FullBackupExporter::class.java.simpleName
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
@WorkerThread
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun export(context: Context,
|
|
||||||
attachmentSecret: AttachmentSecret,
|
|
||||||
input: SQLiteDatabase,
|
|
||||||
fileUri: Uri,
|
|
||||||
passphrase: String) {
|
|
||||||
|
|
||||||
val baseOutputStream = context.contentResolver.openOutputStream(fileUri)
|
|
||||||
?: throw IOException("Cannot open an output stream for the file URI: $fileUri")
|
|
||||||
|
|
||||||
var count = 0
|
|
||||||
try {
|
|
||||||
BackupFrameOutputStream(baseOutputStream, passphrase).use { outputStream ->
|
|
||||||
outputStream.writeDatabaseVersion(input.version)
|
|
||||||
val tables = exportSchema(input, outputStream)
|
|
||||||
for (table in tables) if (shouldExportTable(table)) {
|
|
||||||
count = when (table) {
|
|
||||||
SmsDatabase.TABLE_NAME, MmsDatabase.TABLE_NAME -> {
|
|
||||||
exportTable(table, input, outputStream,
|
|
||||||
{ cursor: Cursor ->
|
|
||||||
cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
count)
|
|
||||||
}
|
|
||||||
GroupReceiptDatabase.TABLE_NAME -> {
|
|
||||||
exportTable(table, input, outputStream,
|
|
||||||
{ cursor: Cursor ->
|
|
||||||
isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID)))
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
count)
|
|
||||||
}
|
|
||||||
AttachmentDatabase.TABLE_NAME -> {
|
|
||||||
exportTable(table, input, outputStream,
|
|
||||||
{ cursor: Cursor ->
|
|
||||||
isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID)))
|
|
||||||
},
|
|
||||||
{ cursor: Cursor ->
|
|
||||||
exportAttachment(attachmentSecret, cursor, outputStream)
|
|
||||||
},
|
|
||||||
count)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
exportTable(table, input, outputStream, null, null, count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (preference in BackupUtil.getBackupRecords(context)) {
|
|
||||||
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
|
||||||
outputStream.writePreferenceEntry(preference)
|
|
||||||
}
|
|
||||||
for (preference in BackupPreferences.getBackupRecords(context)) {
|
|
||||||
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
|
||||||
outputStream.writePreferenceEntry(preference)
|
|
||||||
}
|
|
||||||
for (avatar in AvatarHelper.getAvatarFiles(context)) {
|
|
||||||
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
|
||||||
outputStream.writeAvatar(avatar.name, FileInputStream(avatar), avatar.length())
|
|
||||||
}
|
|
||||||
outputStream.writeEnd()
|
|
||||||
}
|
|
||||||
EventBus.getDefault().post(BackupEvent.createFinished())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to make full backup.", e)
|
|
||||||
EventBus.getDefault().post(BackupEvent.createFinished(e))
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun shouldExportTable(table: String): Boolean {
|
|
||||||
return table != PushDatabase.TABLE_NAME &&
|
|
||||||
|
|
||||||
table != LokiBackupFilesDatabase.TABLE_NAME &&
|
|
||||||
table != LokiAPIDatabase.openGroupProfilePictureTable &&
|
|
||||||
|
|
||||||
table != JobDatabase.Jobs.TABLE_NAME &&
|
|
||||||
table != JobDatabase.Constraints.TABLE_NAME &&
|
|
||||||
table != JobDatabase.Dependencies.TABLE_NAME &&
|
|
||||||
|
|
||||||
!table.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME) &&
|
|
||||||
!table.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME) &&
|
|
||||||
!table.startsWith("sqlite_")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
private fun exportSchema(input: SQLiteDatabase, outputStream: BackupFrameOutputStream): List<String> {
|
|
||||||
val tables: MutableList<String> = LinkedList()
|
|
||||||
input.rawQuery("SELECT sql, name, type FROM sqlite_master", null).use { cursor ->
|
|
||||||
while (cursor != null && cursor.moveToNext()) {
|
|
||||||
val sql = cursor.getString(0)
|
|
||||||
val name = cursor.getString(1)
|
|
||||||
val type = cursor.getString(2)
|
|
||||||
if (sql != null) {
|
|
||||||
val isSmsFtsSecretTable = name != null && name != SearchDatabase.SMS_FTS_TABLE_NAME && name.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME)
|
|
||||||
val isMmsFtsSecretTable = name != null && name != SearchDatabase.MMS_FTS_TABLE_NAME && name.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME)
|
|
||||||
if (!isSmsFtsSecretTable && !isMmsFtsSecretTable) {
|
|
||||||
if ("table" == type) {
|
|
||||||
tables.add(name)
|
|
||||||
}
|
|
||||||
outputStream.writeSql(SqlStatement.newBuilder().setStatement(cursor.getString(0)).build())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tables
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
private fun exportTable(table: String,
|
|
||||||
input: SQLiteDatabase,
|
|
||||||
outputStream: BackupFrameOutputStream,
|
|
||||||
predicate: Predicate<Cursor>?,
|
|
||||||
postProcess: Consumer<Cursor>?,
|
|
||||||
count: Int): Int {
|
|
||||||
var count = count
|
|
||||||
val template = "INSERT INTO $table VALUES "
|
|
||||||
input.rawQuery("SELECT * FROM $table", null).use { cursor ->
|
|
||||||
while (cursor != null && cursor.moveToNext()) {
|
|
||||||
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
|
||||||
if (predicate != null && !predicate.test(cursor)) continue
|
|
||||||
|
|
||||||
val statement = StringBuilder(template)
|
|
||||||
val statementBuilder = SqlStatement.newBuilder()
|
|
||||||
statement.append('(')
|
|
||||||
for (i in 0 until cursor.columnCount) {
|
|
||||||
statement.append('?')
|
|
||||||
when (cursor.getType(i)) {
|
|
||||||
Cursor.FIELD_TYPE_STRING -> {
|
|
||||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
|
||||||
.setStringParamter(cursor.getString(i)))
|
|
||||||
}
|
|
||||||
Cursor.FIELD_TYPE_FLOAT -> {
|
|
||||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
|
||||||
.setDoubleParameter(cursor.getDouble(i)))
|
|
||||||
}
|
|
||||||
Cursor.FIELD_TYPE_INTEGER -> {
|
|
||||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
|
||||||
.setIntegerParameter(cursor.getLong(i)))
|
|
||||||
}
|
|
||||||
Cursor.FIELD_TYPE_BLOB -> {
|
|
||||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
|
||||||
.setBlobParameter(ByteString.copyFrom(cursor.getBlob(i))))
|
|
||||||
}
|
|
||||||
Cursor.FIELD_TYPE_NULL -> {
|
|
||||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
|
||||||
.setNullparameter(true))
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
throw AssertionError("unknown type?" + cursor.getType(i))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (i < cursor.columnCount - 1) {
|
|
||||||
statement.append(',')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
statement.append(')')
|
|
||||||
outputStream.writeSql(statementBuilder.setStatement(statement.toString()).build())
|
|
||||||
postProcess?.accept(cursor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun exportAttachment(attachmentSecret: AttachmentSecret, cursor: Cursor, outputStream: BackupFrameOutputStream) {
|
|
||||||
try {
|
|
||||||
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.ROW_ID))
|
|
||||||
val uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.UNIQUE_ID))
|
|
||||||
var size = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.SIZE))
|
|
||||||
val data = cursor.getString(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA))
|
|
||||||
val random = cursor.getBlob(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA_RANDOM))
|
|
||||||
if (!TextUtils.isEmpty(data) && size <= 0) {
|
|
||||||
size = calculateVeryOldStreamLength(attachmentSecret, random, data)
|
|
||||||
}
|
|
||||||
if (!TextUtils.isEmpty(data) && size > 0) {
|
|
||||||
val inputStream: InputStream = if (random != null && random.size == 32) {
|
|
||||||
ModernDecryptingPartInputStream.createFor(attachmentSecret, random, File(data), 0)
|
|
||||||
} else {
|
|
||||||
ClassicDecryptingPartInputStream.createFor(attachmentSecret, File(data))
|
|
||||||
}
|
|
||||||
outputStream.writeAttachment(AttachmentId(rowId, uniqueId), inputStream, size)
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
Log.w(TAG, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
private fun calculateVeryOldStreamLength(attachmentSecret: AttachmentSecret, random: ByteArray?, data: String): Long {
|
|
||||||
var result: Long = 0
|
|
||||||
val inputStream: InputStream = if (random != null && random.size == 32) {
|
|
||||||
ModernDecryptingPartInputStream.createFor(attachmentSecret, random, File(data), 0)
|
|
||||||
} else {
|
|
||||||
ClassicDecryptingPartInputStream.createFor(attachmentSecret, File(data))
|
|
||||||
}
|
|
||||||
var read: Int
|
|
||||||
val buffer = ByteArray(8192)
|
|
||||||
while (inputStream.read(buffer, 0, buffer.size).also { read = it } != -1) {
|
|
||||||
result += read.toLong()
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isForNonExpiringMessage(db: SQLiteDatabase, mmsId: Long): Boolean {
|
|
||||||
val columns = arrayOf(MmsSmsColumns.EXPIRES_IN)
|
|
||||||
val where = MmsSmsColumns.ID + " = ?"
|
|
||||||
val args = arrayOf(mmsId.toString())
|
|
||||||
db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null).use { mmsCursor ->
|
|
||||||
if (mmsCursor != null && mmsCursor.moveToFirst()) {
|
|
||||||
return mmsCursor.getLong(0) == 0L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private class BackupFrameOutputStream : Closeable, Flushable {
|
|
||||||
|
|
||||||
private val outputStream: OutputStream
|
|
||||||
private var cipher: Cipher
|
|
||||||
private var mac: Mac
|
|
||||||
private val cipherKey: ByteArray
|
|
||||||
private val macKey: ByteArray
|
|
||||||
private val iv: ByteArray
|
|
||||||
|
|
||||||
private var counter: Int = 0
|
|
||||||
|
|
||||||
constructor(outputStream: OutputStream, passphrase: String) : super() {
|
|
||||||
try {
|
|
||||||
val salt = Util.getSecretBytes(32)
|
|
||||||
val key = BackupUtil.computeBackupKey(passphrase, salt)
|
|
||||||
val derived = HKDFv3().deriveSecrets(key, "Backup Export".toByteArray(), 64)
|
|
||||||
val split = ByteUtil.split(derived, 32, 32)
|
|
||||||
cipherKey = split[0]
|
|
||||||
macKey = split[1]
|
|
||||||
cipher = Cipher.getInstance("AES/CTR/NoPadding")
|
|
||||||
mac = Mac.getInstance("HmacSHA256")
|
|
||||||
this.outputStream = outputStream
|
|
||||||
iv = Util.getSecretBytes(16)
|
|
||||||
counter = Conversions.byteArrayToInt(iv)
|
|
||||||
mac.init(SecretKeySpec(macKey, "HmacSHA256"))
|
|
||||||
val header = BackupFrame.newBuilder().setHeader(Header.newBuilder()
|
|
||||||
.setIv(ByteString.copyFrom(iv))
|
|
||||||
.setSalt(ByteString.copyFrom(salt)))
|
|
||||||
.build().toByteArray()
|
|
||||||
outputStream.write(Conversions.intToByteArray(header.size))
|
|
||||||
outputStream.write(header)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
when (e) {
|
|
||||||
is NoSuchAlgorithmException,
|
|
||||||
is NoSuchPaddingException,
|
|
||||||
is InvalidKeyException -> {
|
|
||||||
throw AssertionError(e)
|
|
||||||
}
|
|
||||||
else -> throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun writeSql(statement: SqlStatement) {
|
|
||||||
write(outputStream, BackupFrame.newBuilder().setStatement(statement).build())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun writePreferenceEntry(preference: SharedPreference?) {
|
|
||||||
write(outputStream, BackupFrame.newBuilder().setPreference(preference).build())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun writeAvatar(avatarName: String, inputStream: InputStream, size: Long) {
|
|
||||||
write(outputStream, BackupFrame.newBuilder()
|
|
||||||
.setAvatar(Avatar.newBuilder()
|
|
||||||
.setName(avatarName)
|
|
||||||
.setLength(Util.toIntExact(size))
|
|
||||||
.build())
|
|
||||||
.build())
|
|
||||||
writeStream(inputStream)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun writeAttachment(attachmentId: AttachmentId, inputStream: InputStream, size: Long) {
|
|
||||||
write(outputStream, BackupFrame.newBuilder()
|
|
||||||
.setAttachment(Attachment.newBuilder()
|
|
||||||
.setRowId(attachmentId.rowId)
|
|
||||||
.setAttachmentId(attachmentId.uniqueId)
|
|
||||||
.setLength(Util.toIntExact(size))
|
|
||||||
.build())
|
|
||||||
.build())
|
|
||||||
writeStream(inputStream)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun writeSticker(rowId: Long, inputStream: InputStream, size: Long) {
|
|
||||||
write(outputStream, BackupFrame.newBuilder()
|
|
||||||
.setSticker(Sticker.newBuilder()
|
|
||||||
.setRowId(rowId)
|
|
||||||
.setLength(Util.toIntExact(size))
|
|
||||||
.build())
|
|
||||||
.build())
|
|
||||||
writeStream(inputStream)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun writeDatabaseVersion(version: Int) {
|
|
||||||
write(outputStream, BackupFrame.newBuilder()
|
|
||||||
.setVersion(DatabaseVersion.newBuilder().setVersion(version))
|
|
||||||
.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun writeEnd() {
|
|
||||||
write(outputStream, BackupFrame.newBuilder().setEnd(true).build())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
private fun writeStream(inputStream: InputStream) {
|
|
||||||
try {
|
|
||||||
Conversions.intToByteArray(iv, 0, counter++)
|
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
|
||||||
mac.update(iv)
|
|
||||||
val buffer = ByteArray(8192)
|
|
||||||
var read: Int
|
|
||||||
while (inputStream.read(buffer).also { read = it } != -1) {
|
|
||||||
val ciphertext = cipher.update(buffer, 0, read)
|
|
||||||
if (ciphertext != null) {
|
|
||||||
outputStream.write(ciphertext)
|
|
||||||
mac.update(ciphertext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val remainder = cipher.doFinal()
|
|
||||||
outputStream.write(remainder)
|
|
||||||
mac.update(remainder)
|
|
||||||
val attachmentDigest = mac.doFinal()
|
|
||||||
outputStream.write(attachmentDigest, 0, 10)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
when (e) {
|
|
||||||
is InvalidKeyException,
|
|
||||||
is InvalidAlgorithmParameterException,
|
|
||||||
is IllegalBlockSizeException,
|
|
||||||
is BadPaddingException -> {
|
|
||||||
throw AssertionError(e)
|
|
||||||
}
|
|
||||||
else -> throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
private fun write(out: OutputStream, frame: BackupFrame) {
|
|
||||||
try {
|
|
||||||
Conversions.intToByteArray(iv, 0, counter++)
|
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
|
||||||
val frameCiphertext = cipher.doFinal(frame.toByteArray())
|
|
||||||
val frameMac = mac.doFinal(frameCiphertext)
|
|
||||||
val length = Conversions.intToByteArray(frameCiphertext.size + 10)
|
|
||||||
out.write(length)
|
|
||||||
out.write(frameCiphertext)
|
|
||||||
out.write(frameMac, 0, 10)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
when (e) {
|
|
||||||
is InvalidKeyException,
|
|
||||||
is InvalidAlgorithmParameterException,
|
|
||||||
is IllegalBlockSizeException,
|
|
||||||
is BadPaddingException -> {
|
|
||||||
throw AssertionError(e)
|
|
||||||
}
|
|
||||||
else -> throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
override fun flush() {
|
|
||||||
outputStream.flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
override fun close() {
|
|
||||||
outputStream.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms.backup
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
|
||||||
import org.greenrobot.eventbus.EventBus
|
|
||||||
import org.session.libsession.avatars.AvatarHelper
|
|
||||||
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
|
|
||||||
import org.session.libsession.utilities.Address
|
|
||||||
import org.session.libsession.utilities.Conversions
|
|
||||||
import org.session.libsession.utilities.Util
|
|
||||||
import org.session.libsignal.crypto.kdf.HKDFv3
|
|
||||||
import org.session.libsignal.utilities.ByteUtil
|
|
||||||
import org.session.libsignal.utilities.Log
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.Attachment
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.Avatar
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement
|
|
||||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret
|
|
||||||
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream
|
|
||||||
import org.thoughtcrime.securesms.database.AttachmentDatabase
|
|
||||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase
|
|
||||||
import org.thoughtcrime.securesms.database.MmsDatabase
|
|
||||||
import org.thoughtcrime.securesms.database.MmsSmsColumns
|
|
||||||
import org.thoughtcrime.securesms.database.SearchDatabase
|
|
||||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
|
||||||
import org.thoughtcrime.securesms.util.BackupUtil
|
|
||||||
import java.io.Closeable
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.security.InvalidAlgorithmParameterException
|
|
||||||
import java.security.InvalidKeyException
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.NoSuchAlgorithmException
|
|
||||||
import java.util.LinkedList
|
|
||||||
import java.util.Locale
|
|
||||||
import javax.crypto.BadPaddingException
|
|
||||||
import javax.crypto.Cipher
|
|
||||||
import javax.crypto.IllegalBlockSizeException
|
|
||||||
import javax.crypto.Mac
|
|
||||||
import javax.crypto.NoSuchPaddingException
|
|
||||||
import javax.crypto.spec.IvParameterSpec
|
|
||||||
import javax.crypto.spec.SecretKeySpec
|
|
||||||
|
|
||||||
object FullBackupImporter {
|
|
||||||
/**
|
|
||||||
* Because BackupProtos.SharedPreference was made only to serialize string values,
|
|
||||||
* we use these 3-char prefixes to explicitly cast the values before inserting to a preference file.
|
|
||||||
*/
|
|
||||||
const val PREF_PREFIX_TYPE_INT = "i__"
|
|
||||||
const val PREF_PREFIX_TYPE_BOOLEAN = "b__"
|
|
||||||
|
|
||||||
private val TAG = FullBackupImporter::class.java.simpleName
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
@WorkerThread
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun importFromUri(context: Context,
|
|
||||||
attachmentSecret: AttachmentSecret,
|
|
||||||
db: SQLiteDatabase,
|
|
||||||
fileUri: Uri,
|
|
||||||
passphrase: String) {
|
|
||||||
|
|
||||||
val baseInputStream = context.contentResolver.openInputStream(fileUri)
|
|
||||||
?: throw IOException("Cannot open an input stream for the file URI: $fileUri")
|
|
||||||
|
|
||||||
var count = 0
|
|
||||||
try {
|
|
||||||
BackupRecordInputStream(baseInputStream, passphrase).use { inputStream ->
|
|
||||||
db.beginTransaction()
|
|
||||||
dropAllTables(db)
|
|
||||||
var frame: BackupFrame
|
|
||||||
while (!inputStream.readFrame().also { frame = it }.end) {
|
|
||||||
if (count++ % 100 == 0) EventBus.getDefault().post(BackupEvent.createProgress(count))
|
|
||||||
when {
|
|
||||||
frame.hasVersion() -> processVersion(db, frame.version)
|
|
||||||
frame.hasStatement() -> processStatement(db, frame.statement)
|
|
||||||
frame.hasPreference() -> processPreference(context, frame.preference)
|
|
||||||
frame.hasAttachment() -> processAttachment(context, attachmentSecret, db, frame.attachment, inputStream)
|
|
||||||
frame.hasAvatar() -> processAvatar(context, frame.avatar, inputStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
trimEntriesForExpiredMessages(context, db)
|
|
||||||
db.setTransactionSuccessful()
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (db.inTransaction()) {
|
|
||||||
db.endTransaction()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EventBus.getDefault().post(BackupEvent.createFinished())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
private fun processVersion(db: SQLiteDatabase, version: DatabaseVersion) {
|
|
||||||
if (version.version > db.version) {
|
|
||||||
throw DatabaseDowngradeException(db.version, version.version)
|
|
||||||
}
|
|
||||||
db.version = version.version
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun processStatement(db: SQLiteDatabase, statement: SqlStatement) {
|
|
||||||
val isForSmsFtsSecretTable = statement.statement.contains(SearchDatabase.SMS_FTS_TABLE_NAME + "_")
|
|
||||||
val isForMmsFtsSecretTable = statement.statement.contains(SearchDatabase.MMS_FTS_TABLE_NAME + "_")
|
|
||||||
val isForSqliteSecretTable = statement.statement.toLowerCase(Locale.ENGLISH).startsWith("create table sqlite_")
|
|
||||||
if (isForSmsFtsSecretTable || isForMmsFtsSecretTable || isForSqliteSecretTable) {
|
|
||||||
Log.i(TAG, "Ignoring import for statement: " + statement.statement)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val parameters: MutableList<Any?> = LinkedList()
|
|
||||||
for (parameter in statement.parametersList) {
|
|
||||||
when {
|
|
||||||
parameter.hasStringParamter() -> parameters.add(parameter.stringParamter)
|
|
||||||
parameter.hasDoubleParameter() -> parameters.add(parameter.doubleParameter)
|
|
||||||
parameter.hasIntegerParameter() -> parameters.add(parameter.integerParameter)
|
|
||||||
parameter.hasBlobParameter() -> parameters.add(parameter.blobParameter.toByteArray())
|
|
||||||
parameter.hasNullparameter() -> parameters.add(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (parameters.size > 0) {
|
|
||||||
db.execSQL(statement.statement, parameters.toTypedArray())
|
|
||||||
} else {
|
|
||||||
db.execSQL(statement.statement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
private fun processAttachment(context: Context, attachmentSecret: AttachmentSecret,
|
|
||||||
db: SQLiteDatabase, attachment: Attachment,
|
|
||||||
inputStream: BackupRecordInputStream) {
|
|
||||||
val partsDirectory = context.getDir(AttachmentDatabase.DIRECTORY, Context.MODE_PRIVATE)
|
|
||||||
val dataFile = File.createTempFile("part", ".mms", partsDirectory)
|
|
||||||
val output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false)
|
|
||||||
inputStream.readAttachmentTo(output.second, attachment.length)
|
|
||||||
val contentValues = ContentValues()
|
|
||||||
contentValues.put(AttachmentDatabase.DATA, dataFile.absolutePath)
|
|
||||||
contentValues.put(AttachmentDatabase.THUMBNAIL, null as String?)
|
|
||||||
contentValues.put(AttachmentDatabase.DATA_RANDOM, output.first)
|
|
||||||
db.update(AttachmentDatabase.TABLE_NAME, contentValues,
|
|
||||||
"${AttachmentDatabase.ROW_ID} = ? AND ${AttachmentDatabase.UNIQUE_ID} = ?",
|
|
||||||
arrayOf(attachment.rowId.toString(), attachment.attachmentId.toString()))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
private fun processAvatar(context: Context, avatar: Avatar, inputStream: BackupRecordInputStream) {
|
|
||||||
inputStream.readAttachmentTo(FileOutputStream(
|
|
||||||
AvatarHelper.getAvatarFile(context, Address.fromExternal(context, avatar.name))), avatar.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("ApplySharedPref")
|
|
||||||
private fun processPreference(context: Context, preference: SharedPreference) {
|
|
||||||
val preferences = context.getSharedPreferences(preference.file, 0)
|
|
||||||
val key = preference.key
|
|
||||||
val value = preference.value
|
|
||||||
|
|
||||||
// See the comment next to PREF_PREFIX_TYPE_* constants.
|
|
||||||
when {
|
|
||||||
key.startsWith(PREF_PREFIX_TYPE_INT) ->
|
|
||||||
preferences.edit().putInt(
|
|
||||||
key.substring(PREF_PREFIX_TYPE_INT.length),
|
|
||||||
value.toInt()
|
|
||||||
).commit()
|
|
||||||
key.startsWith(PREF_PREFIX_TYPE_BOOLEAN) ->
|
|
||||||
preferences.edit().putBoolean(
|
|
||||||
key.substring(PREF_PREFIX_TYPE_BOOLEAN.length),
|
|
||||||
value.toBoolean()
|
|
||||||
).commit()
|
|
||||||
else ->
|
|
||||||
preferences.edit().putString(key, value).commit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun dropAllTables(db: SQLiteDatabase) {
|
|
||||||
db.rawQuery("SELECT name, type FROM sqlite_master", null).use { cursor ->
|
|
||||||
while (cursor != null && cursor.moveToNext()) {
|
|
||||||
val name = cursor.getString(0)
|
|
||||||
val type = cursor.getString(1)
|
|
||||||
if ("table" == type && !name.startsWith("sqlite_")) {
|
|
||||||
db.execSQL("DROP TABLE IF EXISTS $name")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun trimEntriesForExpiredMessages(context: Context, db: SQLiteDatabase) {
|
|
||||||
val trimmedCondition = " NOT IN (SELECT ${MmsSmsColumns.ID} FROM ${MmsDatabase.TABLE_NAME})"
|
|
||||||
db.delete(GroupReceiptDatabase.TABLE_NAME, GroupReceiptDatabase.MMS_ID + trimmedCondition, null)
|
|
||||||
val columns = arrayOf(AttachmentDatabase.ROW_ID, AttachmentDatabase.UNIQUE_ID)
|
|
||||||
val where = AttachmentDatabase.MMS_ID + trimmedCondition
|
|
||||||
db.query(AttachmentDatabase.TABLE_NAME, columns, where, null, null, null, null).use { cursor ->
|
|
||||||
while (cursor != null && cursor.moveToNext()) {
|
|
||||||
DatabaseComponent.get(context).attachmentDatabase()
|
|
||||||
.deleteAttachment(AttachmentId(cursor.getLong(0), cursor.getLong(1)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
db.query(ThreadDatabase.TABLE_NAME, arrayOf(ThreadDatabase.ID),
|
|
||||||
ThreadDatabase.EXPIRES_IN + " > 0", null, null, null, null).use { cursor ->
|
|
||||||
while (cursor != null && cursor.moveToNext()) {
|
|
||||||
DatabaseComponent.get(context).threadDatabase().update(cursor.getLong(0), false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class BackupRecordInputStream : Closeable {
|
|
||||||
private val inputStream: InputStream
|
|
||||||
private val cipher: Cipher
|
|
||||||
private val mac: Mac
|
|
||||||
private val cipherKey: ByteArray
|
|
||||||
private val macKey: ByteArray
|
|
||||||
private val iv: ByteArray
|
|
||||||
|
|
||||||
private var counter = 0
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
constructor(inputStream: InputStream, passphrase: String) : super() {
|
|
||||||
try {
|
|
||||||
this.inputStream = inputStream
|
|
||||||
val headerLengthBytes = ByteArray(4)
|
|
||||||
Util.readFully(this.inputStream, headerLengthBytes)
|
|
||||||
val headerLength = Conversions.byteArrayToInt(headerLengthBytes)
|
|
||||||
val headerFrame = ByteArray(headerLength)
|
|
||||||
Util.readFully(this.inputStream, headerFrame)
|
|
||||||
val frame = BackupFrame.parseFrom(headerFrame)
|
|
||||||
if (!frame.hasHeader()) {
|
|
||||||
throw IOException("Backup stream does not start with header!")
|
|
||||||
}
|
|
||||||
val header = frame.header
|
|
||||||
iv = header.iv.toByteArray()
|
|
||||||
if (iv.size != 16) {
|
|
||||||
throw IOException("Invalid IV length!")
|
|
||||||
}
|
|
||||||
val key = BackupUtil.computeBackupKey(passphrase, if (header.hasSalt()) header.salt.toByteArray() else null)
|
|
||||||
val derived = HKDFv3().deriveSecrets(key, "Backup Export".toByteArray(), 64)
|
|
||||||
val split = ByteUtil.split(derived, 32, 32)
|
|
||||||
cipherKey = split[0]
|
|
||||||
macKey = split[1]
|
|
||||||
cipher = Cipher.getInstance("AES/CTR/NoPadding")
|
|
||||||
mac = Mac.getInstance("HmacSHA256")
|
|
||||||
mac.init(SecretKeySpec(macKey, "HmacSHA256"))
|
|
||||||
counter = Conversions.byteArrayToInt(iv)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
when (e) {
|
|
||||||
is NoSuchAlgorithmException,
|
|
||||||
is NoSuchPaddingException,
|
|
||||||
is InvalidKeyException -> {
|
|
||||||
throw AssertionError(e)
|
|
||||||
}
|
|
||||||
else -> throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun readFrame(): BackupFrame {
|
|
||||||
return readFrame(inputStream)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun readAttachmentTo(out: OutputStream, length: Int) {
|
|
||||||
var length = length
|
|
||||||
try {
|
|
||||||
Conversions.intToByteArray(iv, 0, counter++)
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
|
||||||
mac.update(iv)
|
|
||||||
val buffer = ByteArray(8192)
|
|
||||||
while (length > 0) {
|
|
||||||
val read = inputStream.read(buffer, 0, Math.min(buffer.size, length))
|
|
||||||
if (read == -1) throw IOException("File ended early!")
|
|
||||||
mac.update(buffer, 0, read)
|
|
||||||
val plaintext = cipher.update(buffer, 0, read)
|
|
||||||
if (plaintext != null) {
|
|
||||||
out.write(plaintext, 0, plaintext.size)
|
|
||||||
}
|
|
||||||
length -= read
|
|
||||||
}
|
|
||||||
val plaintext = cipher.doFinal()
|
|
||||||
if (plaintext != null) {
|
|
||||||
out.write(plaintext, 0, plaintext.size)
|
|
||||||
}
|
|
||||||
out.close()
|
|
||||||
val ourMac = ByteUtil.trim(mac.doFinal(), 10)
|
|
||||||
val theirMac = ByteArray(10)
|
|
||||||
try {
|
|
||||||
Util.readFully(inputStream, theirMac)
|
|
||||||
} catch (e: IOException) {
|
|
||||||
throw IOException(e)
|
|
||||||
}
|
|
||||||
if (!MessageDigest.isEqual(ourMac, theirMac)) {
|
|
||||||
throw IOException("Bad MAC")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
when (e) {
|
|
||||||
is InvalidKeyException,
|
|
||||||
is InvalidAlgorithmParameterException,
|
|
||||||
is IllegalBlockSizeException,
|
|
||||||
is BadPaddingException -> {
|
|
||||||
throw AssertionError(e)
|
|
||||||
}
|
|
||||||
else -> throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
private fun readFrame(`in`: InputStream?): BackupFrame {
|
|
||||||
return try {
|
|
||||||
val length = ByteArray(4)
|
|
||||||
Util.readFully(`in`, length)
|
|
||||||
val frame = ByteArray(Conversions.byteArrayToInt(length))
|
|
||||||
Util.readFully(`in`, frame)
|
|
||||||
val theirMac = ByteArray(10)
|
|
||||||
System.arraycopy(frame, frame.size - 10, theirMac, 0, theirMac.size)
|
|
||||||
mac.update(frame, 0, frame.size - 10)
|
|
||||||
val ourMac = ByteUtil.trim(mac.doFinal(), 10)
|
|
||||||
if (!MessageDigest.isEqual(ourMac, theirMac)) {
|
|
||||||
throw IOException("Bad MAC")
|
|
||||||
}
|
|
||||||
Conversions.intToByteArray(iv, 0, counter++)
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
|
||||||
val plaintext = cipher.doFinal(frame, 0, frame.size - 10)
|
|
||||||
BackupFrame.parseFrom(plaintext)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
when (e) {
|
|
||||||
is InvalidKeyException,
|
|
||||||
is InvalidAlgorithmParameterException,
|
|
||||||
is IllegalBlockSizeException,
|
|
||||||
is BadPaddingException -> {
|
|
||||||
throw AssertionError(e)
|
|
||||||
}
|
|
||||||
else -> throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
override fun close() {
|
|
||||||
inputStream.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class DatabaseDowngradeException internal constructor(currentVersion: Int, backupVersion: Int) :
|
|
||||||
IOException("Tried to import a backup with version $backupVersion into a database with version $currentVersion")
|
|
||||||
}
|
|
||||||
@@ -255,17 +255,12 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
|
|||||||
viewModel.callState.collect { state ->
|
viewModel.callState.collect { state ->
|
||||||
Log.d("Loki", "Consuming view model state $state")
|
Log.d("Loki", "Consuming view model state $state")
|
||||||
when (state) {
|
when (state) {
|
||||||
CALL_RINGING -> {
|
CALL_RINGING -> if (wantsToAnswer) {
|
||||||
if (wantsToAnswer) {
|
answerCall()
|
||||||
answerCall()
|
|
||||||
wantsToAnswer = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CALL_OUTGOING -> {
|
|
||||||
}
|
|
||||||
CALL_CONNECTED -> {
|
|
||||||
wantsToAnswer = false
|
wantsToAnswer = false
|
||||||
}
|
}
|
||||||
|
CALL_CONNECTED -> wantsToAnswer = false
|
||||||
|
else -> {}
|
||||||
}
|
}
|
||||||
updateControls(state)
|
updateControls(state)
|
||||||
}
|
}
|
||||||
@@ -345,6 +340,11 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
|
|||||||
binding.localRenderer.removeAllViews()
|
binding.localRenderer.removeAllViews()
|
||||||
if (isEnabled) {
|
if (isEnabled) {
|
||||||
viewModel.localRenderer?.let { surfaceView ->
|
viewModel.localRenderer?.let { surfaceView ->
|
||||||
|
surfaceView.setZOrderOnTop(true)
|
||||||
|
|
||||||
|
// Mirror the video preview of the person making the call to prevent disorienting them
|
||||||
|
surfaceView.setMirror(true)
|
||||||
|
|
||||||
binding.localRenderer.addView(surfaceView)
|
binding.localRenderer.addView(surfaceView)
|
||||||
}
|
}
|
||||||
viewModel.localFloatingRenderer?.let { surfaceView ->
|
viewModel.localFloatingRenderer?.let { surfaceView ->
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms.components;
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.res.TypedArray;
|
|
||||||
import android.os.AsyncTask;
|
|
||||||
import android.util.AttributeSet;
|
|
||||||
import android.view.View;
|
|
||||||
import android.widget.ImageView;
|
|
||||||
import android.widget.LinearLayout;
|
|
||||||
import android.widget.TextView;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.ApplicationContext;
|
|
||||||
import org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView;
|
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
|
||||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
|
|
||||||
import org.thoughtcrime.securesms.util.DateUtils;
|
|
||||||
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
import network.loki.messenger.R;
|
|
||||||
|
|
||||||
public class ConversationItemFooter extends LinearLayout {
|
|
||||||
|
|
||||||
private TextView dateView;
|
|
||||||
private ExpirationTimerView timerView;
|
|
||||||
private ImageView insecureIndicatorView;
|
|
||||||
private DeliveryStatusView deliveryStatusView;
|
|
||||||
|
|
||||||
public ConversationItemFooter(Context context) {
|
|
||||||
super(context);
|
|
||||||
init(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ConversationItemFooter(Context context, @Nullable AttributeSet attrs) {
|
|
||||||
super(context, attrs);
|
|
||||||
init(attrs);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ConversationItemFooter(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
|
||||||
super(context, attrs, defStyleAttr);
|
|
||||||
init(attrs);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void init(@Nullable AttributeSet attrs) {
|
|
||||||
inflate(getContext(), R.layout.conversation_item_footer, this);
|
|
||||||
|
|
||||||
dateView = findViewById(R.id.footer_date);
|
|
||||||
timerView = findViewById(R.id.footer_expiration_timer);
|
|
||||||
insecureIndicatorView = findViewById(R.id.footer_insecure_indicator);
|
|
||||||
deliveryStatusView = findViewById(R.id.footer_delivery_status);
|
|
||||||
|
|
||||||
if (attrs != null) {
|
|
||||||
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemFooter, 0, 0);
|
|
||||||
setTextColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_text_color, getResources().getColor(R.color.core_white)));
|
|
||||||
setIconColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_icon_color, getResources().getColor(R.color.core_white)));
|
|
||||||
typedArray.recycle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onDetachedFromWindow() {
|
|
||||||
super.onDetachedFromWindow();
|
|
||||||
timerView.stopAnimation();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setMessageRecord(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
|
|
||||||
presentDate(messageRecord, locale);
|
|
||||||
presentTimer(messageRecord);
|
|
||||||
presentInsecureIndicator(messageRecord);
|
|
||||||
presentDeliveryStatus(messageRecord);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setTextColor(int color) {
|
|
||||||
dateView.setTextColor(color);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setIconColor(int color) {
|
|
||||||
timerView.setColorFilter(color);
|
|
||||||
insecureIndicatorView.setColorFilter(color);
|
|
||||||
deliveryStatusView.setTint(color);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
|
|
||||||
dateView.forceLayout();
|
|
||||||
|
|
||||||
if (messageRecord.isFailed()) {
|
|
||||||
dateView.setText(R.string.ConversationItem_error_not_delivered);
|
|
||||||
} else {
|
|
||||||
dateView.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("StaticFieldLeak")
|
|
||||||
private void presentTimer(@NonNull final MessageRecord messageRecord) {
|
|
||||||
if (messageRecord.getExpiresIn() > 0 && !messageRecord.isPending()) {
|
|
||||||
this.timerView.setVisibility(View.VISIBLE);
|
|
||||||
this.timerView.setPercentComplete(0);
|
|
||||||
|
|
||||||
if (messageRecord.getExpireStarted() > 0) {
|
|
||||||
this.timerView.setExpirationTime(messageRecord.getExpireStarted(),
|
|
||||||
messageRecord.getExpiresIn());
|
|
||||||
this.timerView.startAnimation();
|
|
||||||
|
|
||||||
if (messageRecord.getExpireStarted() + messageRecord.getExpiresIn() <= System.currentTimeMillis()) {
|
|
||||||
ApplicationContext.getInstance(getContext()).getExpiringMessageManager().checkSchedule();
|
|
||||||
}
|
|
||||||
} else if (!messageRecord.isOutgoing() && !messageRecord.isMediaPending()) {
|
|
||||||
new AsyncTask<Void, Void, Void>() {
|
|
||||||
@Override
|
|
||||||
protected Void doInBackground(Void... params) {
|
|
||||||
ExpiringMessageManager expirationManager = ApplicationContext.getInstance(getContext()).getExpiringMessageManager();
|
|
||||||
long id = messageRecord.getId();
|
|
||||||
boolean mms = messageRecord.isMms();
|
|
||||||
|
|
||||||
if (mms) DatabaseComponent.get(getContext()).mmsDatabase().markExpireStarted(id);
|
|
||||||
else DatabaseComponent.get(getContext()).smsDatabase().markExpireStarted(id);
|
|
||||||
|
|
||||||
expirationManager.scheduleDeletion(id, mms, messageRecord.getExpiresIn());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.timerView.setVisibility(View.GONE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void presentInsecureIndicator(@NonNull MessageRecord messageRecord) {
|
|
||||||
insecureIndicatorView.setVisibility(View.GONE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void presentDeliveryStatus(@NonNull MessageRecord messageRecord) {
|
|
||||||
if (!messageRecord.isFailed()) {
|
|
||||||
if (!messageRecord.isOutgoing()) deliveryStatusView.setNone();
|
|
||||||
else if (messageRecord.isPending()) deliveryStatusView.setPending();
|
|
||||||
else if (messageRecord.isRead()) deliveryStatusView.setRead();
|
|
||||||
else if (messageRecord.isDelivered()) deliveryStatusView.setDelivered();
|
|
||||||
else deliveryStatusView.setSent();
|
|
||||||
} else {
|
|
||||||
deliveryStatusView.setNone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms.components
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Paint
|
|
||||||
import android.graphics.Path
|
|
||||||
import android.util.AttributeSet
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.widget.RelativeLayout
|
|
||||||
import network.loki.messenger.R
|
|
||||||
import network.loki.messenger.databinding.ViewSeparatorBinding
|
|
||||||
import org.thoughtcrime.securesms.util.toPx
|
|
||||||
import org.session.libsession.utilities.ThemeUtil
|
|
||||||
|
|
||||||
class LabeledSeparatorView : RelativeLayout {
|
|
||||||
|
|
||||||
private lateinit var binding: ViewSeparatorBinding
|
|
||||||
private val path = Path()
|
|
||||||
|
|
||||||
private val paint: Paint by lazy {
|
|
||||||
val result = Paint()
|
|
||||||
result.style = Paint.Style.STROKE
|
|
||||||
result.color = ThemeUtil.getThemedColor(context, R.attr.dividerHorizontal)
|
|
||||||
result.strokeWidth = toPx(1, resources).toFloat()
|
|
||||||
result.isAntiAlias = true
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
// region Lifecycle
|
|
||||||
constructor(context: Context) : super(context) {
|
|
||||||
setUpViewHierarchy()
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
|
|
||||||
setUpViewHierarchy()
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
|
|
||||||
setUpViewHierarchy()
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {
|
|
||||||
setUpViewHierarchy()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setUpViewHierarchy() {
|
|
||||||
binding = ViewSeparatorBinding.inflate(LayoutInflater.from(context))
|
|
||||||
val layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
|
||||||
addView(binding.root, layoutParams)
|
|
||||||
setWillNotDraw(false)
|
|
||||||
}
|
|
||||||
// endregion
|
|
||||||
|
|
||||||
// region Updating
|
|
||||||
override fun onDraw(c: Canvas) {
|
|
||||||
super.onDraw(c)
|
|
||||||
val w = width.toFloat()
|
|
||||||
val h = height.toFloat()
|
|
||||||
val hMargin = toPx(16, resources).toFloat()
|
|
||||||
path.reset()
|
|
||||||
path.moveTo(0.0f, h / 2)
|
|
||||||
path.lineTo(binding.titleTextView.left - hMargin, h / 2)
|
|
||||||
path.addRoundRect(binding.titleTextView.left - hMargin, toPx(1, resources).toFloat(), binding.titleTextView.right + hMargin, h - toPx(1, resources).toFloat(), h / 2, h / 2, Path.Direction.CCW)
|
|
||||||
path.moveTo(binding.titleTextView.right + hMargin, h / 2)
|
|
||||||
path.lineTo(w, h / 2)
|
|
||||||
path.close()
|
|
||||||
c.drawPath(path, paint)
|
|
||||||
}
|
|
||||||
// endregion
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,10 +2,10 @@ package org.thoughtcrime.securesms.components
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.RelativeLayout
|
import android.widget.RelativeLayout
|
||||||
import androidx.annotation.DimenRes
|
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import network.loki.messenger.databinding.ViewProfilePictureBinding
|
import network.loki.messenger.databinding.ViewProfilePictureBinding
|
||||||
@@ -18,27 +18,32 @@ import org.session.libsession.utilities.Address
|
|||||||
import org.session.libsession.utilities.GroupUtil
|
import org.session.libsession.utilities.GroupUtil
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||||
|
import org.thoughtcrime.securesms.mms.GlideApp
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||||
|
|
||||||
class ProfilePictureView @JvmOverloads constructor(
|
class ProfilePictureView @JvmOverloads constructor(
|
||||||
context: Context, attrs: AttributeSet? = null
|
context: Context, attrs: AttributeSet? = null
|
||||||
) : RelativeLayout(context, attrs) {
|
) : RelativeLayout(context, attrs) {
|
||||||
private val binding: ViewProfilePictureBinding by lazy { ViewProfilePictureBinding.bind(this) }
|
private val binding = ViewProfilePictureBinding.inflate(LayoutInflater.from(context), this)
|
||||||
lateinit var glide: GlideRequests
|
private val glide: GlideRequests = GlideApp.with(this)
|
||||||
var publicKey: String? = null
|
var publicKey: String? = null
|
||||||
var displayName: String? = null
|
var displayName: String? = null
|
||||||
var additionalPublicKey: String? = null
|
var additionalPublicKey: String? = null
|
||||||
var additionalDisplayName: String? = null
|
var additionalDisplayName: String? = null
|
||||||
var isLarge = false
|
var isLarge = false
|
||||||
|
|
||||||
private val profilePicturesCache = mutableMapOf<String, String?>()
|
private val profilePicturesCache = mutableMapOf<View, Recipient>()
|
||||||
private val unknownRecipientDrawable = ResourceContactPhoto(R.drawable.ic_profile_default)
|
private val unknownRecipientDrawable by lazy { ResourceContactPhoto(R.drawable.ic_profile_default)
|
||||||
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false)
|
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) }
|
||||||
private val unknownOpenGroupDrawable = ResourceContactPhoto(R.drawable.ic_notification)
|
private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification)
|
||||||
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false)
|
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) }
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
|
constructor(context: Context, sender: Recipient): this(context) {
|
||||||
|
update(sender)
|
||||||
|
}
|
||||||
|
|
||||||
// region Updating
|
// region Updating
|
||||||
fun update(recipient: Recipient) {
|
fun update(recipient: Recipient) {
|
||||||
fun getUserDisplayName(publicKey: String): String {
|
fun getUserDisplayName(publicKey: String): String {
|
||||||
@@ -52,14 +57,21 @@ class ProfilePictureView @JvmOverloads constructor(
|
|||||||
.sorted()
|
.sorted()
|
||||||
.take(2)
|
.take(2)
|
||||||
.toMutableList()
|
.toMutableList()
|
||||||
val pk = members.getOrNull(0)?.serialize() ?: ""
|
if (members.size <= 1) {
|
||||||
publicKey = pk
|
publicKey = ""
|
||||||
displayName = getUserDisplayName(pk)
|
displayName = ""
|
||||||
val apk = members.getOrNull(1)?.serialize() ?: ""
|
additionalPublicKey = ""
|
||||||
additionalPublicKey = apk
|
additionalDisplayName = ""
|
||||||
additionalDisplayName = getUserDisplayName(apk)
|
} else {
|
||||||
|
val pk = members.getOrNull(0)?.serialize() ?: ""
|
||||||
|
publicKey = pk
|
||||||
|
displayName = getUserDisplayName(pk)
|
||||||
|
val apk = members.getOrNull(1)?.serialize() ?: ""
|
||||||
|
additionalPublicKey = apk
|
||||||
|
additionalDisplayName = getUserDisplayName(apk)
|
||||||
|
}
|
||||||
} else if(recipient.isOpenGroupInboxRecipient) {
|
} else if(recipient.isOpenGroupInboxRecipient) {
|
||||||
val publicKey = GroupUtil.getDecodedOpenGroupInbox(recipient.address.serialize())
|
val publicKey = GroupUtil.getDecodedOpenGroupInboxSessionId(recipient.address.serialize())
|
||||||
this.publicKey = publicKey
|
this.publicKey = publicKey
|
||||||
displayName = getUserDisplayName(publicKey)
|
displayName = getUserDisplayName(publicKey)
|
||||||
additionalPublicKey = null
|
additionalPublicKey = null
|
||||||
@@ -73,12 +85,11 @@ class ProfilePictureView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun update() {
|
fun update() {
|
||||||
if (!this::glide.isInitialized) return
|
|
||||||
val publicKey = publicKey ?: return
|
val publicKey = publicKey ?: return
|
||||||
val additionalPublicKey = additionalPublicKey
|
val additionalPublicKey = additionalPublicKey
|
||||||
if (additionalPublicKey != null) {
|
if (additionalPublicKey != null) {
|
||||||
setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName, R.dimen.small_profile_picture_size)
|
setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName)
|
||||||
setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName, R.dimen.small_profile_picture_size)
|
setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName)
|
||||||
binding.doubleModeImageViewContainer.visibility = View.VISIBLE
|
binding.doubleModeImageViewContainer.visibility = View.VISIBLE
|
||||||
} else {
|
} else {
|
||||||
glide.clear(binding.doubleModeImageView1)
|
glide.clear(binding.doubleModeImageView1)
|
||||||
@@ -86,14 +97,14 @@ class ProfilePictureView @JvmOverloads constructor(
|
|||||||
binding.doubleModeImageViewContainer.visibility = View.INVISIBLE
|
binding.doubleModeImageViewContainer.visibility = View.INVISIBLE
|
||||||
}
|
}
|
||||||
if (additionalPublicKey == null && !isLarge) {
|
if (additionalPublicKey == null && !isLarge) {
|
||||||
setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName, R.dimen.medium_profile_picture_size)
|
setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName)
|
||||||
binding.singleModeImageView.visibility = View.VISIBLE
|
binding.singleModeImageView.visibility = View.VISIBLE
|
||||||
} else {
|
} else {
|
||||||
glide.clear(binding.singleModeImageView)
|
glide.clear(binding.singleModeImageView)
|
||||||
binding.singleModeImageView.visibility = View.INVISIBLE
|
binding.singleModeImageView.visibility = View.INVISIBLE
|
||||||
}
|
}
|
||||||
if (additionalPublicKey == null && isLarge) {
|
if (additionalPublicKey == null && isLarge) {
|
||||||
setProfilePictureIfNeeded(binding.largeSingleModeImageView, publicKey, displayName, R.dimen.large_profile_picture_size)
|
setProfilePictureIfNeeded(binding.largeSingleModeImageView, publicKey, displayName)
|
||||||
binding.largeSingleModeImageView.visibility = View.VISIBLE
|
binding.largeSingleModeImageView.visibility = View.VISIBLE
|
||||||
} else {
|
} else {
|
||||||
glide.clear(binding.largeSingleModeImageView)
|
glide.clear(binding.largeSingleModeImageView)
|
||||||
@@ -101,37 +112,43 @@ class ProfilePictureView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?, @DimenRes sizeResId: Int) {
|
private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?) {
|
||||||
if (publicKey.isNotEmpty()) {
|
if (publicKey.isNotEmpty()) {
|
||||||
val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false)
|
val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false)
|
||||||
if (profilePicturesCache.containsKey(publicKey) && profilePicturesCache[publicKey] == recipient.profileAvatar) return
|
if (profilePicturesCache[imageView] == recipient) return
|
||||||
|
profilePicturesCache[imageView] = recipient
|
||||||
val signalProfilePicture = recipient.contactPhoto
|
val signalProfilePicture = recipient.contactPhoto
|
||||||
val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject
|
val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject
|
||||||
|
|
||||||
|
glide.clear(imageView)
|
||||||
|
|
||||||
|
val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
|
||||||
|
|
||||||
if (signalProfilePicture != null && avatar != "0" && avatar != "") {
|
if (signalProfilePicture != null && avatar != "0" && avatar != "") {
|
||||||
glide.clear(imageView)
|
|
||||||
glide.load(signalProfilePicture)
|
glide.load(signalProfilePicture)
|
||||||
.placeholder(unknownRecipientDrawable)
|
.placeholder(unknownRecipientDrawable)
|
||||||
.centerCrop()
|
.centerCrop()
|
||||||
.error(unknownRecipientDrawable)
|
.error(glide.load(placeholder))
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
.circleCrop()
|
.circleCrop()
|
||||||
.into(imageView)
|
.into(imageView)
|
||||||
} else if (recipient.isOpenGroupRecipient && recipient.groupAvatarId == null) {
|
} else if (recipient.isCommunityRecipient && recipient.groupAvatarId == null) {
|
||||||
glide.clear(imageView)
|
glide.clear(imageView)
|
||||||
imageView.setImageDrawable(unknownOpenGroupDrawable)
|
glide.load(unknownOpenGroupDrawable)
|
||||||
|
.centerCrop()
|
||||||
|
.circleCrop()
|
||||||
|
.into(imageView)
|
||||||
} else {
|
} else {
|
||||||
val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
|
|
||||||
|
|
||||||
glide.clear(imageView)
|
|
||||||
glide.load(placeholder)
|
glide.load(placeholder)
|
||||||
.placeholder(unknownRecipientDrawable)
|
.placeholder(unknownRecipientDrawable)
|
||||||
.centerCrop()
|
.centerCrop()
|
||||||
|
.circleCrop()
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView)
|
.diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView)
|
||||||
}
|
}
|
||||||
profilePicturesCache[publicKey] = recipient.profileAvatar
|
|
||||||
} else {
|
} else {
|
||||||
imageView.setImageDrawable(null)
|
glide.load(unknownRecipientDrawable)
|
||||||
|
.centerCrop()
|
||||||
|
.into(imageView)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package org.thoughtcrime.securesms.components
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import androidx.viewpager.widget.ViewPager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An extension of ViewPager to swallow erroneous multi-touch exceptions.
|
||||||
|
*
|
||||||
|
* @see https://stackoverflow.com/questions/6919292/pointerindex-out-of-range-android-multitouch
|
||||||
|
*/
|
||||||
|
class SafeViewPager @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null
|
||||||
|
) : ViewPager(context, attrs) {
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
|
override fun onTouchEvent(event: MotionEvent?): Boolean = try {
|
||||||
|
super.onTouchEvent(event)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInterceptTouchEvent(event: MotionEvent?): Boolean = try {
|
||||||
|
super.onInterceptTouchEvent(event)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.thoughtcrime.securesms.components;
|
package org.thoughtcrime.securesms.components;
|
||||||
|
|
||||||
|
|
||||||
import android.animation.Animator;
|
import android.animation.Animator;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
@@ -68,9 +67,7 @@ public class SearchToolbar extends LinearLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onQueryTextChange(String newText) {
|
public boolean onQueryTextChange(String newText) { return onQueryTextSubmit(newText); }
|
||||||
return onQueryTextSubmit(newText);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() {
|
searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() {
|
||||||
|
|||||||
@@ -3,133 +3,95 @@ package org.thoughtcrime.securesms.components.emoji;
|
|||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.AsyncTask;
|
|
||||||
import android.preference.PreferenceManager;
|
import android.preference.PreferenceManager;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import com.annimon.stream.Stream;
|
import com.annimon.stream.Stream;
|
||||||
import com.fasterxml.jackson.databind.type.CollectionType;
|
|
||||||
import com.fasterxml.jackson.databind.type.TypeFactory;
|
|
||||||
|
|
||||||
import org.session.libsignal.utilities.JsonUtil;
|
import org.session.libsignal.utilities.JsonUtil;
|
||||||
import org.session.libsignal.utilities.Log;
|
import org.session.libsignal.utilities.Log;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Iterator;
|
import java.util.LinkedList;
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import network.loki.messenger.R;
|
import network.loki.messenger.R;
|
||||||
|
|
||||||
public class RecentEmojiPageModel implements EmojiPageModel {
|
public class RecentEmojiPageModel implements EmojiPageModel {
|
||||||
private static final String TAG = RecentEmojiPageModel.class.getSimpleName();
|
private static final String TAG = RecentEmojiPageModel.class.getSimpleName();
|
||||||
private static final String EMOJI_LRU_PREFERENCE = "pref_recent_emoji2";
|
public static final String RECENT_EMOJIS_KEY = "Recents";
|
||||||
private static final int EMOJI_LRU_SIZE = 50;
|
|
||||||
public static final String KEY = "Recents";
|
|
||||||
public static final List<String> DEFAULT_REACTIONS_LIST =
|
|
||||||
Arrays.asList("\ud83d\ude02", "\ud83e\udd70", "\ud83d\ude22", "\ud83d\ude21", "\ud83d\ude2e", "\ud83d\ude08");
|
|
||||||
|
|
||||||
private final SharedPreferences prefs;
|
public static final LinkedList<String> DEFAULT_REACTION_EMOJIS_LIST = new LinkedList<>(Arrays.asList(
|
||||||
private final LinkedHashSet<String> recentlyUsed;
|
"\ud83d\ude02",
|
||||||
|
"\ud83e\udd70",
|
||||||
|
"\ud83d\ude22",
|
||||||
|
"\ud83d\ude21",
|
||||||
|
"\ud83d\ude2e",
|
||||||
|
"\ud83d\ude08"));
|
||||||
|
|
||||||
|
public static final String DEFAULT_REACTION_EMOJIS_JSON_STRING = JsonUtil.toJson(new LinkedList<>(DEFAULT_REACTION_EMOJIS_LIST));
|
||||||
|
private static SharedPreferences prefs;
|
||||||
|
private static LinkedList<String> recentlyUsed;
|
||||||
|
|
||||||
public RecentEmojiPageModel(Context context) {
|
public RecentEmojiPageModel(Context context) {
|
||||||
this.prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||||
this.recentlyUsed = getPersistedCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
private LinkedHashSet<String> getPersistedCache() {
|
// Note: Do NOT try to populate or update the persisted recent emojis in the constructor - the
|
||||||
String serialized = prefs.getString(EMOJI_LRU_PREFERENCE, "[]");
|
// `getEmoji` method ends up getting called half-way through in a race-condition manner.
|
||||||
try {
|
|
||||||
CollectionType collectionType = TypeFactory.defaultInstance()
|
|
||||||
.constructCollectionType(LinkedHashSet.class, String.class);
|
|
||||||
return JsonUtil.getMapper().readValue(serialized, collectionType);
|
|
||||||
} catch (IOException e) {
|
|
||||||
Log.w(TAG, e);
|
|
||||||
return new LinkedHashSet<>();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getKey() {
|
public String getKey() { return RECENT_EMOJIS_KEY; }
|
||||||
return KEY;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public int getIconAttr() {
|
@Override public int getIconAttr() { return R.attr.emoji_category_recent; }
|
||||||
return R.attr.emoji_category_recent;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public List<String> getEmoji() {
|
@Override public List<String> getEmoji() {
|
||||||
List<String> recent = new ArrayList<>(recentlyUsed);
|
// Populate our recently used list if required (i.e., on first run)
|
||||||
List<String> out = new ArrayList<>(DEFAULT_REACTIONS_LIST.size());
|
if (recentlyUsed == null) {
|
||||||
|
try {
|
||||||
for (int i = 0; i < DEFAULT_REACTIONS_LIST.size(); i++) {
|
String recentlyUsedEmjoiJsonString = prefs.getString(RECENT_EMOJIS_KEY, DEFAULT_REACTION_EMOJIS_JSON_STRING);
|
||||||
if (recent.size() > i) {
|
recentlyUsed = JsonUtil.fromJson(recentlyUsedEmjoiJsonString, LinkedList.class);
|
||||||
out.add(recent.get(i));
|
} catch (Exception e) {
|
||||||
} else {
|
Log.w(TAG, e);
|
||||||
out.add(DEFAULT_REACTIONS_LIST.get(i));
|
Log.d(TAG, "Default reaction emoji data was corrupt (likely via key re-use on app upgrade) - rewriting fresh data.");
|
||||||
|
boolean writeSuccess = prefs.edit().putString(RECENT_EMOJIS_KEY, DEFAULT_REACTION_EMOJIS_JSON_STRING).commit();
|
||||||
|
if (!writeSuccess) { Log.w(TAG, "Failed to update recently used emojis in shared prefs."); }
|
||||||
|
recentlyUsed = DEFAULT_REACTION_EMOJIS_LIST;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return new ArrayList<>(recentlyUsed);
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public List<Emoji> getDisplayEmoji() {
|
@Override public List<Emoji> getDisplayEmoji() {
|
||||||
return Stream.of(getEmoji()).map(Emoji::new).toList();
|
return Stream.of(getEmoji()).map(Emoji::new).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public boolean hasSpriteMap() {
|
@Override public boolean hasSpriteMap() { return false; }
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
@Override
|
@Override
|
||||||
public Uri getSpriteUri() {
|
public Uri getSpriteUri() { return null; }
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public boolean isDynamic() {
|
@Override public boolean isDynamic() { return true; }
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void onCodePointSelected(String emoji) {
|
public static void onCodePointSelected(String emoji) {
|
||||||
recentlyUsed.remove(emoji);
|
// If the emoji is already in the recently used list then remove it..
|
||||||
recentlyUsed.add(emoji);
|
if (recentlyUsed.contains(emoji)) { recentlyUsed.removeFirstOccurrence(emoji); }
|
||||||
|
|
||||||
if (recentlyUsed.size() > EMOJI_LRU_SIZE) {
|
// ..and then regardless of whether the emoji used was already in the recently used list or not
|
||||||
Iterator<String> iterator = recentlyUsed.iterator();
|
// it gets placed as the first element in the list..
|
||||||
iterator.next();
|
recentlyUsed.addFirst(emoji);
|
||||||
iterator.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
final LinkedHashSet<String> latestRecentlyUsed = new LinkedHashSet<>(recentlyUsed);
|
// Ensure that we only ever store data for a maximum of 6 recently used emojis (this code will
|
||||||
new AsyncTask<Void, Void, Void>() {
|
// execute if if we did NOT remove any occurrence of a previously used emoji but then added the
|
||||||
|
// new emoji to the front of the list).
|
||||||
|
while (recentlyUsed.size() > 6) { recentlyUsed.removeLast(); }
|
||||||
|
|
||||||
@Override
|
// ..which we then save to shared prefs.
|
||||||
protected Void doInBackground(Void... params) {
|
String recentlyUsedAsJsonString = JsonUtil.toJson(recentlyUsed);
|
||||||
try {
|
boolean writeSuccess = prefs.edit().putString(RECENT_EMOJIS_KEY, recentlyUsedAsJsonString).commit();
|
||||||
String serialized = JsonUtil.toJsonThrows(latestRecentlyUsed);
|
if (!writeSuccess) { Log.w(TAG, "Failed to update recently used emojis in shared prefs."); }
|
||||||
prefs.edit()
|
|
||||||
.putString(EMOJI_LRU_PREFERENCE, serialized)
|
|
||||||
.apply();
|
|
||||||
} catch (IOException e) {
|
|
||||||
Log.w(TAG, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String[] toReversePrimitiveArray(@NonNull LinkedHashSet<String> emojiSet) {
|
|
||||||
String[] emojis = new String[emojiSet.size()];
|
|
||||||
int i = emojiSet.size() - 1;
|
|
||||||
for (String emoji : emojiSet) {
|
|
||||||
emojis[i--] = emoji;
|
|
||||||
}
|
|
||||||
return emojis;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +1,17 @@
|
|||||||
package org.thoughtcrime.securesms.components.menu
|
package org.thoughtcrime.securesms.components.menu
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.annotation.AttrRes
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.annotation.ColorRes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents an action to be rendered
|
* Represents an action to be rendered
|
||||||
*/
|
*/
|
||||||
data class ActionItem(
|
data class ActionItem(
|
||||||
@AttrRes val iconRes: Int,
|
@AttrRes val iconRes: Int,
|
||||||
val title: CharSequence,
|
val title: Int,
|
||||||
val action: Runnable
|
val action: Runnable,
|
||||||
|
val contentDescription: Int? = null,
|
||||||
|
val subtitle: ((Context) -> CharSequence?)? = null,
|
||||||
|
@ColorRes val color: Int? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
package org.thoughtcrime.securesms.components.menu
|
package org.thoughtcrime.securesms.components.menu
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.ColorStateList
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.isGone
|
||||||
|
import androidx.core.widget.ImageViewCompat
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||||
@@ -34,30 +43,23 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
|
|||||||
mappingAdapter.submitList(items.toAdapterItems())
|
mappingAdapter.submitList(items.toAdapterItems())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<ActionItem>.toAdapterItems(): List<DisplayItem> {
|
private fun List<ActionItem>.toAdapterItems(): List<DisplayItem> =
|
||||||
return this.mapIndexed { index, item ->
|
mapIndexed { index, item ->
|
||||||
val displayType: DisplayType = when {
|
when {
|
||||||
this.size == 1 -> DisplayType.ONLY
|
size == 1 -> DisplayType.ONLY
|
||||||
index == 0 -> DisplayType.TOP
|
index == 0 -> DisplayType.TOP
|
||||||
index == this.size - 1 -> DisplayType.BOTTOM
|
index == size - 1 -> DisplayType.BOTTOM
|
||||||
else -> DisplayType.MIDDLE
|
else -> DisplayType.MIDDLE
|
||||||
}
|
}.let { DisplayItem(item, it) }
|
||||||
|
|
||||||
DisplayItem(item, displayType)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private data class DisplayItem(
|
private data class DisplayItem(
|
||||||
val item: ActionItem,
|
val item: ActionItem,
|
||||||
val displayType: DisplayType
|
val displayType: DisplayType
|
||||||
) : MappingModel<DisplayItem> {
|
) : MappingModel<DisplayItem> {
|
||||||
override fun areItemsTheSame(newItem: DisplayItem): Boolean {
|
override fun areItemsTheSame(newItem: DisplayItem): Boolean = this == newItem
|
||||||
return this == newItem
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun areContentsTheSame(newItem: DisplayItem): Boolean {
|
override fun areContentsTheSame(newItem: DisplayItem): Boolean = this == newItem
|
||||||
return this == newItem
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum class DisplayType {
|
private enum class DisplayType {
|
||||||
@@ -68,27 +70,61 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
|
|||||||
itemView: View,
|
itemView: View,
|
||||||
private val onItemClick: () -> Unit,
|
private val onItemClick: () -> Unit,
|
||||||
) : MappingViewHolder<DisplayItem>(itemView) {
|
) : MappingViewHolder<DisplayItem>(itemView) {
|
||||||
|
private var subtitleJob: Job? = null
|
||||||
val icon: ImageView = itemView.findViewById(R.id.context_menu_item_icon)
|
val icon: ImageView = itemView.findViewById(R.id.context_menu_item_icon)
|
||||||
val title: TextView = itemView.findViewById(R.id.context_menu_item_title)
|
val title: TextView = itemView.findViewById(R.id.context_menu_item_title)
|
||||||
|
val subtitle: TextView = itemView.findViewById(R.id.context_menu_item_subtitle)
|
||||||
|
|
||||||
override fun bind(model: DisplayItem) {
|
override fun bind(model: DisplayItem) {
|
||||||
if (model.item.iconRes > 0) {
|
val item = model.item
|
||||||
|
val color = item.color?.let { ContextCompat.getColor(context, it) }
|
||||||
|
|
||||||
|
if (item.iconRes > 0) {
|
||||||
val typedValue = TypedValue()
|
val typedValue = TypedValue()
|
||||||
context.theme.resolveAttribute(model.item.iconRes, typedValue, true)
|
context.theme.resolveAttribute(item.iconRes, typedValue, true)
|
||||||
icon.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId))
|
icon.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId))
|
||||||
|
|
||||||
|
icon.imageTintList = color?.let(ColorStateList::valueOf)
|
||||||
}
|
}
|
||||||
title.text = model.item.title
|
item.contentDescription?.let(context.resources::getString)?.let { itemView.contentDescription = it }
|
||||||
|
title.setText(item.title)
|
||||||
|
color?.let(title::setTextColor)
|
||||||
|
color?.let(subtitle::setTextColor)
|
||||||
|
subtitle.isGone = true
|
||||||
|
item.subtitle?.let { startSubtitleJob(subtitle, it) }
|
||||||
itemView.setOnClickListener {
|
itemView.setOnClickListener {
|
||||||
model.item.action.run()
|
item.action.run()
|
||||||
onItemClick()
|
onItemClick()
|
||||||
}
|
}
|
||||||
|
|
||||||
when (model.displayType) {
|
when (model.displayType) {
|
||||||
DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_top)
|
DisplayType.TOP -> R.drawable.context_menu_item_background_top
|
||||||
DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_bottom)
|
DisplayType.BOTTOM -> R.drawable.context_menu_item_background_bottom
|
||||||
DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_middle)
|
DisplayType.MIDDLE -> R.drawable.context_menu_item_background_middle
|
||||||
DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_only)
|
DisplayType.ONLY -> R.drawable.context_menu_item_background_only
|
||||||
|
}.let(itemView::setBackgroundResource)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startSubtitleJob(textView: TextView, getSubtitle: (Context) -> CharSequence?) {
|
||||||
|
fun updateText() = getSubtitle(context).let {
|
||||||
|
textView.isGone = it == null
|
||||||
|
textView.text = it
|
||||||
}
|
}
|
||||||
|
updateText()
|
||||||
|
|
||||||
|
subtitleJob?.cancel()
|
||||||
|
subtitleJob = CoroutineScope(Dispatchers.Main).launch {
|
||||||
|
while (true) {
|
||||||
|
updateText()
|
||||||
|
delay(200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
// naive job cancellation, will break if many items are added to context menu.
|
||||||
|
subtitleJob?.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -58,7 +58,7 @@ class ContactSelectionListLoader(context: Context, val mode: Int, val filter: St
|
|||||||
|
|
||||||
private fun getOpenGroups(contacts: List<Recipient>): List<ContactSelectionListItem> {
|
private fun getOpenGroups(contacts: List<Recipient>): List<ContactSelectionListItem> {
|
||||||
return getItems(contacts, context.getString(R.string.fragment_contact_selection_open_groups_title)) {
|
return getItems(contacts, context.getString(R.string.fragment_contact_selection_open_groups_title)) {
|
||||||
it.address.isOpenGroup
|
it.address.isCommunity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.thoughtcrime.securesms.contactshare;
|
package org.thoughtcrime.securesms.contacts;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
@@ -24,7 +24,7 @@ public final class ContactUtil {
|
|||||||
return SpanUtil.italic(context.getString(R.string.MessageNotifier_unknown_contact_message));
|
return SpanUtil.italic(context.getString(R.string.MessageNotifier_unknown_contact_message));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static @NonNull String getDisplayName(@Nullable Contact contact) {
|
private static @NonNull String getDisplayName(@Nullable Contact contact) {
|
||||||
if (contact == null) {
|
if (contact == null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import android.view.View
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import network.loki.messenger.databinding.ViewUserBinding
|
import network.loki.messenger.databinding.ViewUserBinding
|
||||||
|
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||||
import org.session.libsession.messaging.contacts.Contact
|
import org.session.libsession.messaging.contacts.Contact
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities
|
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities
|
||||||
@@ -47,15 +48,14 @@ class UserView : LinearLayout {
|
|||||||
|
|
||||||
// region Updating
|
// region Updating
|
||||||
fun bind(user: Recipient, glide: GlideRequests, actionIndicator: ActionIndicator, isSelected: Boolean = false) {
|
fun bind(user: Recipient, glide: GlideRequests, actionIndicator: ActionIndicator, isSelected: Boolean = false) {
|
||||||
|
val isLocalUser = user.isLocalNumber
|
||||||
fun getUserDisplayName(publicKey: String): String {
|
fun getUserDisplayName(publicKey: String): String {
|
||||||
|
if (isLocalUser) return context.getString(R.string.MessageRecord_you)
|
||||||
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey)
|
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey)
|
||||||
return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey
|
return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey
|
||||||
}
|
}
|
||||||
val threadID = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(user)
|
|
||||||
MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(threadID, context) // FIXME: This is a bad place to do this
|
|
||||||
val address = user.address.serialize()
|
val address = user.address.serialize()
|
||||||
binding.profilePictureView.root.glide = glide
|
binding.profilePictureView.update(user)
|
||||||
binding.profilePictureView.root.update(user)
|
|
||||||
binding.actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24)
|
binding.actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24)
|
||||||
binding.nameTextView.text = if (user.isGroupRecipient) user.name else getUserDisplayName(address)
|
binding.nameTextView.text = if (user.isGroupRecipient) user.name else getUserDisplayName(address)
|
||||||
when (actionIndicator) {
|
when (actionIndicator) {
|
||||||
@@ -87,7 +87,7 @@ class UserView : LinearLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun unbind() {
|
fun unbind() {
|
||||||
binding.profilePictureView.root.recycle()
|
binding.profilePictureView.recycle()
|
||||||
}
|
}
|
||||||
// endregion
|
// endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.isCommunityRecipient) {
|
||||||
|
val userCount = openGroup?.let { lokiApiDb.getUserCount(it.room, it.server) } ?: 0
|
||||||
|
context.getString(R.string.ConversationActivity_active_member_count, userCount)
|
||||||
|
} else {
|
||||||
|
val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size
|
||||||
|
context.getString(R.string.ConversationActivity_member_count, userCount)
|
||||||
|
}
|
||||||
|
settings += ConversationSetting(title, ConversationSettingType.MEMBER_COUNT)
|
||||||
|
}
|
||||||
|
settingsAdapter.submitList(settings)
|
||||||
|
binding.settingsTabLayout.isVisible = settings.size > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConversationSettingsAdapter(
|
||||||
|
private val settingsListener: (ConversationSetting) -> Unit
|
||||||
|
) : ListAdapter<ConversationSetting, ConversationSettingsAdapter.SettingViewHolder>(SettingsDiffer()) {
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder {
|
||||||
|
val layoutInflater = LayoutInflater.from(parent.context)
|
||||||
|
return SettingViewHolder(ViewConversationSettingBinding.inflate(layoutInflater, parent, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: SettingViewHolder, position: Int) {
|
||||||
|
holder.bind(getItem(position), itemCount) {
|
||||||
|
settingsListener.invoke(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SettingViewHolder(
|
||||||
|
private val binding: ViewConversationSettingBinding
|
||||||
|
): RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
|
fun bind(setting: ConversationSetting, itemCount: Int, listener: (ConversationSetting) -> Unit) {
|
||||||
|
binding.root.setOnClickListener { listener.invoke(setting) }
|
||||||
|
binding.root.contentDescription = setting.contentDescription
|
||||||
|
binding.iconImageView.setImageResource(setting.iconResId)
|
||||||
|
binding.iconImageView.isVisible = setting.iconResId > 0
|
||||||
|
binding.titleView.text = setting.title
|
||||||
|
binding.leftArrowImageView.isVisible = itemCount > 1
|
||||||
|
binding.rightArrowImageView.isVisible = itemCount > 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SettingsDiffer: DiffUtil.ItemCallback<ConversationSetting>() {
|
||||||
|
override fun areItemsTheSame(oldItem: ConversationSetting, newItem: ConversationSetting): Boolean = oldItem.settingType === newItem.settingType
|
||||||
|
override fun areContentsTheSame(oldItem: ConversationSetting, newItem: ConversationSetting): Boolean = oldItem == newItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interface ConversationActionBarDelegate {
|
||||||
|
fun onDisappearingMessagesClicked()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ConversationSetting(
|
||||||
|
val title: String,
|
||||||
|
val settingType: ConversationSettingType,
|
||||||
|
val iconResId: Int = 0,
|
||||||
|
val contentDescription: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class ConversationSettingType {
|
||||||
|
EXPIRATION,
|
||||||
|
MEMBER_COUNT,
|
||||||
|
NOTIFICATION
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -32,14 +32,13 @@ class ContactListAdapter(
|
|||||||
|
|
||||||
class ContactViewHolder(private val binding: ViewContactBinding) : RecyclerView.ViewHolder(binding.root) {
|
class ContactViewHolder(private val binding: ViewContactBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(contact: ContactListItem.Contact, glide: GlideRequests, listener: (Recipient) -> Unit) {
|
fun bind(contact: ContactListItem.Contact, glide: GlideRequests, listener: (Recipient) -> Unit) {
|
||||||
binding.profilePictureView.root.glide = glide
|
binding.profilePictureView.update(contact.recipient)
|
||||||
binding.profilePictureView.root.update(contact.recipient)
|
|
||||||
binding.nameTextView.text = contact.displayName
|
binding.nameTextView.text = contact.displayName
|
||||||
binding.root.setOnClickListener { listener(contact.recipient) }
|
binding.root.setOnClickListener { listener(contact.recipient) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unbind() {
|
fun unbind() {
|
||||||
binding.profilePictureView.root.recycle()
|
binding.profilePictureView.recycle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class NewConversationHomeFragment : Fragment() {
|
|||||||
val displayName = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionId
|
val displayName = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionId
|
||||||
ContactListItem.Contact(it, displayName)
|
ContactListItem.Contact(it, displayName)
|
||||||
}.sortedBy { it.displayName }
|
}.sortedBy { it.displayName }
|
||||||
.groupBy { if (PublicKeyValidation.isValid(it.displayName)) unknownSectionTitle else it.displayName.first().uppercase() }
|
.groupBy { if (PublicKeyValidation.isValid(it.displayName)) unknownSectionTitle else it.displayName.firstOrNull()?.uppercase() ?: unknownSectionTitle }
|
||||||
.toMutableMap()
|
.toMutableMap()
|
||||||
contactGroups.remove(unknownSectionTitle)?.let { contactGroups.put(unknownSectionTitle, it) }
|
contactGroups.remove(unknownSectionTitle)?.let { contactGroups.put(unknownSectionTitle, it) }
|
||||||
adapter.items = contactGroups.flatMap { entry -> listOf(ContactListItem.Header(entry.key)) + entry.value }
|
adapter.items = contactGroups.flatMap { entry -> listOf(ContactListItem.Header(entry.key)) + entry.value }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
|||||||
package org.thoughtcrime.securesms.conversation.v2
|
package org.thoughtcrime.securesms.conversation.v2
|
||||||
|
|
||||||
import android.app.AlertDialog
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
@@ -23,18 +22,25 @@ import kotlinx.coroutines.launch
|
|||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import network.loki.messenger.databinding.ViewVisibleMessageBinding
|
import network.loki.messenger.databinding.ViewVisibleMessageBinding
|
||||||
import org.session.libsession.messaging.contacts.Contact
|
import org.session.libsession.messaging.contacts.Contact
|
||||||
|
import org.session.libsession.utilities.TextSecurePreferences
|
||||||
import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView
|
import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView
|
||||||
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
|
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
|
||||||
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate
|
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate
|
||||||
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter
|
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter
|
||||||
|
import org.thoughtcrime.securesms.database.MmsSmsColumns
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||||
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
|
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
|
||||||
|
import org.thoughtcrime.securesms.showSessionDialog
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
class ConversationAdapter(
|
class ConversationAdapter(
|
||||||
context: Context,
|
context: Context,
|
||||||
cursor: Cursor,
|
cursor: Cursor,
|
||||||
|
originalLastSeen: Long,
|
||||||
|
private val isReversed: Boolean,
|
||||||
private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit,
|
private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit,
|
||||||
private val onItemSwipeToReply: (MessageRecord, Int) -> Unit,
|
private val onItemSwipeToReply: (MessageRecord, Int) -> Unit,
|
||||||
private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit,
|
private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit,
|
||||||
@@ -52,6 +58,9 @@ class ConversationAdapter(
|
|||||||
private val updateQueue = Channel<String>(1024, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
private val updateQueue = Channel<String>(1024, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||||
private val contactCache = SparseArray<Contact>(100)
|
private val contactCache = SparseArray<Contact>(100)
|
||||||
private val contactLoadedCache = SparseBooleanArray(100)
|
private val contactLoadedCache = SparseBooleanArray(100)
|
||||||
|
private val lastSeen = AtomicLong(originalLastSeen)
|
||||||
|
private var lastSentMessageId: Long = -1L
|
||||||
|
|
||||||
init {
|
init {
|
||||||
lifecycleCoroutineScope.launch(IO) {
|
lifecycleCoroutineScope.launch(IO) {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
@@ -128,8 +137,10 @@ class ConversationAdapter(
|
|||||||
searchQuery,
|
searchQuery,
|
||||||
contact,
|
contact,
|
||||||
senderId,
|
senderId,
|
||||||
|
lastSeen.get(),
|
||||||
visibleMessageViewDelegate,
|
visibleMessageViewDelegate,
|
||||||
onAttachmentNeedsDownload
|
onAttachmentNeedsDownload,
|
||||||
|
lastSentMessageId
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!message.isDeleted) {
|
if (!message.isDeleted) {
|
||||||
@@ -146,17 +157,15 @@ class ConversationAdapter(
|
|||||||
viewHolder.view.bind(message, messageBefore)
|
viewHolder.view.bind(message, messageBefore)
|
||||||
if (message.isCallLog && message.isFirstMissedCall) {
|
if (message.isCallLog && message.isFirstMissedCall) {
|
||||||
viewHolder.view.setOnClickListener {
|
viewHolder.view.setOnClickListener {
|
||||||
AlertDialog.Builder(context)
|
context.showSessionDialog {
|
||||||
.setTitle(R.string.CallNotificationBuilder_first_call_title)
|
title(R.string.CallNotificationBuilder_first_call_title)
|
||||||
.setMessage(R.string.CallNotificationBuilder_first_call_message)
|
text(R.string.CallNotificationBuilder_first_call_message)
|
||||||
.setPositiveButton(R.string.activity_settings_title) { _, _ ->
|
button(R.string.activity_settings_title) {
|
||||||
val intent = Intent(context, PrivacySettingsActivity::class.java)
|
Intent(context, PrivacySettingsActivity::class.java)
|
||||||
context.startActivity(intent)
|
.let(context::startActivity)
|
||||||
}
|
}
|
||||||
.setNeutralButton(R.string.cancel) { d, _ ->
|
cancelButton()
|
||||||
d.dismiss()
|
}
|
||||||
}
|
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
viewHolder.view.setOnClickListener(null)
|
viewHolder.view.setOnClickListener(null)
|
||||||
@@ -185,19 +194,38 @@ class ConversationAdapter(
|
|||||||
private fun getMessageBefore(position: Int, cursor: Cursor): MessageRecord? {
|
private fun getMessageBefore(position: Int, cursor: Cursor): MessageRecord? {
|
||||||
// The message that's visually before the current one is actually after the current
|
// The message that's visually before the current one is actually after the current
|
||||||
// one for the cursor because the layout is reversed
|
// one for the cursor because the layout is reversed
|
||||||
if (!cursor.moveToPosition(position + 1)) { return null }
|
if (isReversed && !cursor.moveToPosition(position + 1)) { return null }
|
||||||
|
if (!isReversed && !cursor.moveToPosition(position - 1)) { return null }
|
||||||
|
|
||||||
return messageDB.readerFor(cursor).current
|
return messageDB.readerFor(cursor).current
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getMessageAfter(position: Int, cursor: Cursor): MessageRecord? {
|
private fun getMessageAfter(position: Int, cursor: Cursor): MessageRecord? {
|
||||||
// The message that's visually after the current one is actually before the current
|
// The message that's visually after the current one is actually before the current
|
||||||
// one for the cursor because the layout is reversed
|
// one for the cursor because the layout is reversed
|
||||||
if (!cursor.moveToPosition(position - 1)) { return null }
|
if (isReversed && !cursor.moveToPosition(position - 1)) { return null }
|
||||||
|
if (!isReversed && !cursor.moveToPosition(position + 1)) { return null }
|
||||||
|
|
||||||
return messageDB.readerFor(cursor).current
|
return messageDB.readerFor(cursor).current
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getLastSentMessageId(cursor: Cursor): Long {
|
||||||
|
// If we don't move to first (or at least step backwards) we can step off the end of the
|
||||||
|
// cursor and any query will return an "Index = -1" error.
|
||||||
|
val cursorHasContent = cursor.moveToFirst()
|
||||||
|
if (cursorHasContent) {
|
||||||
|
val thisThreadId = cursor.getLong(4) // Column index 4 is "thread_id"
|
||||||
|
if (thisThreadId != -1L) {
|
||||||
|
val thisUsersSessionId = TextSecurePreferences.getLocalNumber(context)
|
||||||
|
return messageDB.getLastSentMessageFromSender(thisThreadId, thisUsersSessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1L
|
||||||
|
}
|
||||||
|
|
||||||
override fun changeCursor(cursor: Cursor?) {
|
override fun changeCursor(cursor: Cursor?) {
|
||||||
super.changeCursor(cursor)
|
super.changeCursor(cursor)
|
||||||
|
|
||||||
val toRemove = mutableSetOf<MessageRecord>()
|
val toRemove = mutableSetOf<MessageRecord>()
|
||||||
val toDeselect = mutableSetOf<Pair<Int, MessageRecord>>()
|
val toDeselect = mutableSetOf<Pair<Int, MessageRecord>>()
|
||||||
for (selected in selectedItems) {
|
for (selected in selectedItems) {
|
||||||
@@ -215,15 +243,39 @@ class ConversationAdapter(
|
|||||||
toDeselect.iterator().forEach { (pos, record) ->
|
toDeselect.iterator().forEach { (pos, record) ->
|
||||||
onDeselect(record, pos)
|
onDeselect(record, pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This value gets updated here ONLY when the cursor changes, and the value is then passed
|
||||||
|
// through to `VisibleMessageView.bind` each time we bind via `onBindItemViewHolder`, above.
|
||||||
|
// If there are no messages then lastSentMessageId is assigned the value -1L.
|
||||||
|
if (cursor != null) { lastSentMessageId = getLastSentMessageId(cursor) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findLastSeenItemPosition(lastSeenTimestamp: Long): Int? {
|
fun findLastSeenItemPosition(lastSeenTimestamp: Long): Int? {
|
||||||
val cursor = this.cursor
|
val cursor = this.cursor
|
||||||
if (lastSeenTimestamp <= 0L || cursor == null || !isActiveCursor) return null
|
if (cursor == null || !isActiveCursor) return null
|
||||||
|
if (lastSeenTimestamp == 0L) {
|
||||||
|
if (isReversed && cursor.moveToLast()) { return cursor.position }
|
||||||
|
if (!isReversed && cursor.moveToFirst()) { return cursor.position }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop from the newest message to the oldest until we find one older (or equal to)
|
||||||
|
// the lastSeenTimestamp, then return that message index
|
||||||
for (i in 0 until itemCount) {
|
for (i in 0 until itemCount) {
|
||||||
cursor.moveToPosition(i)
|
if (isReversed) {
|
||||||
val message = messageDB.readerFor(cursor).current
|
cursor.moveToPosition(i)
|
||||||
if (message.isOutgoing || message.dateReceived <= lastSeenTimestamp) { return i }
|
val (outgoing, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor)
|
||||||
|
if (outgoing || dateSent <= lastSeenTimestamp) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
val index = ((itemCount - 1) - i)
|
||||||
|
cursor.moveToPosition(index)
|
||||||
|
val (outgoing, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor)
|
||||||
|
if (outgoing || dateSent <= lastSeenTimestamp) {
|
||||||
|
return min(itemCount - 1, (index + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -233,8 +285,8 @@ class ConversationAdapter(
|
|||||||
if (timestamp <= 0L || cursor == null || !isActiveCursor) return null
|
if (timestamp <= 0L || cursor == null || !isActiveCursor) return null
|
||||||
for (i in 0 until itemCount) {
|
for (i in 0 until itemCount) {
|
||||||
cursor.moveToPosition(i)
|
cursor.moveToPosition(i)
|
||||||
val message = messageDB.readerFor(cursor).current
|
val (_, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor)
|
||||||
if (message.dateSent == timestamp) { return i }
|
if (dateSent == timestamp) { return i }
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -243,4 +295,11 @@ class ConversationAdapter(
|
|||||||
this.searchQuery = query
|
this.searchQuery = query
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getTimestampForItemAt(firstVisiblePosition: Int): Long? {
|
||||||
|
val cursor = this.cursor ?: return null
|
||||||
|
if (!cursor.moveToPosition(firstVisiblePosition)) return null
|
||||||
|
val message = messageDB.readerFor(cursor).current ?: return null
|
||||||
|
return message.timestamp
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,888 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms.conversation.v2;
|
|
||||||
|
|
||||||
import android.animation.Animator;
|
|
||||||
import android.animation.AnimatorSet;
|
|
||||||
import android.animation.ObjectAnimator;
|
|
||||||
import android.animation.ValueAnimator;
|
|
||||||
import android.app.Activity;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.graphics.Bitmap;
|
|
||||||
import android.graphics.PointF;
|
|
||||||
import android.graphics.Rect;
|
|
||||||
import android.graphics.drawable.BitmapDrawable;
|
|
||||||
import android.util.AttributeSet;
|
|
||||||
import android.view.HapticFeedbackConstants;
|
|
||||||
import android.view.MotionEvent;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.Window;
|
|
||||||
import android.view.animation.DecelerateInterpolator;
|
|
||||||
import android.view.animation.Interpolator;
|
|
||||||
import android.widget.FrameLayout;
|
|
||||||
import android.widget.LinearLayout;
|
|
||||||
import android.widget.TextView;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
|
||||||
import androidx.core.content.ContextCompat;
|
|
||||||
import androidx.core.view.ViewKt;
|
|
||||||
import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat;
|
|
||||||
|
|
||||||
import com.annimon.stream.Stream;
|
|
||||||
|
|
||||||
import org.session.libsession.messaging.open_groups.OpenGroup;
|
|
||||||
import org.session.libsession.utilities.TextSecurePreferences;
|
|
||||||
import org.session.libsession.utilities.ThemeUtil;
|
|
||||||
import org.session.libsession.utilities.recipients.Recipient;
|
|
||||||
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
|
|
||||||
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel;
|
|
||||||
import org.thoughtcrime.securesms.components.menu.ActionItem;
|
|
||||||
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper;
|
|
||||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
|
||||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
|
|
||||||
import org.thoughtcrime.securesms.util.AnimationCompleteListener;
|
|
||||||
import org.thoughtcrime.securesms.util.DateUtils;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
import kotlin.Unit;
|
|
||||||
import network.loki.messenger.R;
|
|
||||||
|
|
||||||
public final class ConversationReactionOverlay extends FrameLayout {
|
|
||||||
|
|
||||||
public static final float LONG_PRESS_SCALE_FACTOR = 0.95f;
|
|
||||||
private static final Interpolator INTERPOLATOR = new DecelerateInterpolator();
|
|
||||||
|
|
||||||
private final Rect emojiViewGlobalRect = new Rect();
|
|
||||||
private final Rect emojiStripViewBounds = new Rect();
|
|
||||||
private float segmentSize;
|
|
||||||
|
|
||||||
private final Boundary horizontalEmojiBoundary = new Boundary();
|
|
||||||
private final Boundary verticalScrubBoundary = new Boundary();
|
|
||||||
private final PointF deadzoneTouchPoint = new PointF();
|
|
||||||
|
|
||||||
private Activity activity;
|
|
||||||
private MessageRecord messageRecord;
|
|
||||||
private SelectedConversationModel selectedConversationModel;
|
|
||||||
private String blindedPublicKey;
|
|
||||||
private OverlayState overlayState = OverlayState.HIDDEN;
|
|
||||||
private RecentEmojiPageModel recentEmojiPageModel;
|
|
||||||
|
|
||||||
private boolean downIsOurs;
|
|
||||||
private int selected = -1;
|
|
||||||
private int customEmojiIndex;
|
|
||||||
private int originalStatusBarColor;
|
|
||||||
private int originalNavigationBarColor;
|
|
||||||
|
|
||||||
private View dropdownAnchor;
|
|
||||||
private LinearLayout conversationItem;
|
|
||||||
private View backgroundView;
|
|
||||||
private ConstraintLayout foregroundView;
|
|
||||||
private EmojiImageView[] emojiViews;
|
|
||||||
|
|
||||||
private ConversationContextMenu contextMenu;
|
|
||||||
|
|
||||||
private float touchDownDeadZoneSize;
|
|
||||||
private float distanceFromTouchDownPointToBottomOfScrubberDeadZone;
|
|
||||||
private int scrubberWidth;
|
|
||||||
private int selectedVerticalTranslation;
|
|
||||||
private int scrubberHorizontalMargin;
|
|
||||||
private int animationEmojiStartDelayFactor;
|
|
||||||
private int statusBarHeight;
|
|
||||||
|
|
||||||
private OnReactionSelectedListener onReactionSelectedListener;
|
|
||||||
private OnActionSelectedListener onActionSelectedListener;
|
|
||||||
private OnHideListener onHideListener;
|
|
||||||
|
|
||||||
private AnimatorSet revealAnimatorSet = new AnimatorSet();
|
|
||||||
private AnimatorSet hideAnimatorSet = new AnimatorSet();
|
|
||||||
|
|
||||||
public ConversationReactionOverlay(@NonNull Context context) {
|
|
||||||
super(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ConversationReactionOverlay(@NonNull Context context, @Nullable AttributeSet attrs) {
|
|
||||||
super(context, attrs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onFinishInflate() {
|
|
||||||
super.onFinishInflate();
|
|
||||||
|
|
||||||
dropdownAnchor = findViewById(R.id.dropdown_anchor);
|
|
||||||
conversationItem = findViewById(R.id.conversation_item);
|
|
||||||
backgroundView = findViewById(R.id.conversation_reaction_scrubber_background);
|
|
||||||
foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground);
|
|
||||||
|
|
||||||
emojiViews = new EmojiImageView[] { findViewById(R.id.reaction_1),
|
|
||||||
findViewById(R.id.reaction_2),
|
|
||||||
findViewById(R.id.reaction_3),
|
|
||||||
findViewById(R.id.reaction_4),
|
|
||||||
findViewById(R.id.reaction_5),
|
|
||||||
findViewById(R.id.reaction_6),
|
|
||||||
findViewById(R.id.reaction_7) };
|
|
||||||
|
|
||||||
customEmojiIndex = emojiViews.length - 1;
|
|
||||||
|
|
||||||
distanceFromTouchDownPointToBottomOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom);
|
|
||||||
|
|
||||||
touchDownDeadZoneSize = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size);
|
|
||||||
scrubberWidth = getResources().getDimensionPixelOffset(R.dimen.reaction_scrubber_width);
|
|
||||||
selectedVerticalTranslation = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation);
|
|
||||||
scrubberHorizontalMargin = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin);
|
|
||||||
|
|
||||||
animationEmojiStartDelayFactor = getResources().getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor);
|
|
||||||
|
|
||||||
initAnimators();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void show(@NonNull Activity activity,
|
|
||||||
@NonNull MessageRecord messageRecord,
|
|
||||||
@NonNull PointF lastSeenDownPoint,
|
|
||||||
@NonNull SelectedConversationModel selectedConversationModel,
|
|
||||||
@Nullable String blindedPublicKey)
|
|
||||||
{
|
|
||||||
if (overlayState != OverlayState.HIDDEN) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.messageRecord = messageRecord;
|
|
||||||
this.selectedConversationModel = selectedConversationModel;
|
|
||||||
this.blindedPublicKey = blindedPublicKey;
|
|
||||||
overlayState = OverlayState.UNINITAILIZED;
|
|
||||||
selected = -1;
|
|
||||||
recentEmojiPageModel = new RecentEmojiPageModel(activity);
|
|
||||||
|
|
||||||
setupSelectedEmoji();
|
|
||||||
|
|
||||||
View statusBarBackground = activity.findViewById(android.R.id.statusBarBackground);
|
|
||||||
statusBarHeight = statusBarBackground == null ? 0 : statusBarBackground.getHeight();
|
|
||||||
|
|
||||||
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
|
|
||||||
|
|
||||||
View conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble);
|
|
||||||
conversationBubble.setLayoutParams(new LinearLayout.LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight()));
|
|
||||||
conversationBubble.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot));
|
|
||||||
TextView conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp);
|
|
||||||
conversationTimestamp.setText(DateUtils.getDisplayFormattedTimeSpanString(getContext(), Locale.getDefault(), messageRecord.getTimestamp()));
|
|
||||||
|
|
||||||
updateConversationTimestamp(messageRecord);
|
|
||||||
|
|
||||||
boolean isMessageOnLeft = selectedConversationModel.isOutgoing() ^ ViewUtil.isLtr(this);
|
|
||||||
|
|
||||||
conversationItem.setScaleX(LONG_PRESS_SCALE_FACTOR);
|
|
||||||
conversationItem.setScaleY(LONG_PRESS_SCALE_FACTOR);
|
|
||||||
|
|
||||||
setVisibility(View.INVISIBLE);
|
|
||||||
|
|
||||||
this.activity = activity;
|
|
||||||
updateSystemUiOnShow(activity);
|
|
||||||
|
|
||||||
ViewKt.doOnLayout(this, v -> {
|
|
||||||
showAfterLayout(messageRecord, lastSeenDownPoint, isMessageOnLeft);
|
|
||||||
return Unit.INSTANCE;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void updateConversationTimestamp(MessageRecord message) {
|
|
||||||
View bubble = conversationItem.findViewById(R.id.conversation_item_bubble);
|
|
||||||
View timestamp = conversationItem.findViewById(R.id.conversation_item_timestamp);
|
|
||||||
conversationItem.removeAllViewsInLayout();
|
|
||||||
conversationItem.addView(message.isOutgoing() ? timestamp : bubble);
|
|
||||||
conversationItem.addView(message.isOutgoing() ? bubble : timestamp);
|
|
||||||
conversationItem.requestLayout();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showAfterLayout(@NonNull MessageRecord messageRecord,
|
|
||||||
@NonNull PointF lastSeenDownPoint,
|
|
||||||
boolean isMessageOnLeft) {
|
|
||||||
contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord));
|
|
||||||
|
|
||||||
float itemX = isMessageOnLeft ? scrubberHorizontalMargin :
|
|
||||||
selectedConversationModel.getBubbleX() - conversationItem.getWidth() + selectedConversationModel.getBubbleWidth();
|
|
||||||
conversationItem.setX(itemX);
|
|
||||||
conversationItem.setY(selectedConversationModel.getBubbleY() - statusBarHeight);
|
|
||||||
|
|
||||||
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
|
|
||||||
boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth();
|
|
||||||
|
|
||||||
int overlayHeight = getHeight();
|
|
||||||
int bubbleWidth = selectedConversationModel.getBubbleWidth();
|
|
||||||
|
|
||||||
float endX = itemX;
|
|
||||||
float endY = conversationItem.getY();
|
|
||||||
float endApparentTop = endY;
|
|
||||||
float endScale = 1f;
|
|
||||||
|
|
||||||
float menuPadding = DimensionUnit.DP.toPixels(12f);
|
|
||||||
float reactionBarTopPadding = DimensionUnit.DP.toPixels(32f);
|
|
||||||
int reactionBarHeight = backgroundView.getHeight();
|
|
||||||
|
|
||||||
float reactionBarBackgroundY;
|
|
||||||
|
|
||||||
if (isWideLayout) {
|
|
||||||
boolean everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + conversationItemSnapshot.getHeight() < overlayHeight;
|
|
||||||
if (everythingFitsVertically) {
|
|
||||||
boolean reactionBarFitsAboveItem = conversationItem.getY() > reactionBarHeight + menuPadding + reactionBarTopPadding;
|
|
||||||
|
|
||||||
if (reactionBarFitsAboveItem) {
|
|
||||||
reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
|
|
||||||
} else {
|
|
||||||
endY = reactionBarHeight + menuPadding + reactionBarTopPadding;
|
|
||||||
reactionBarBackgroundY = reactionBarTopPadding;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
float spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding;
|
|
||||||
|
|
||||||
endScale = spaceAvailableForItem / conversationItem.getHeight();
|
|
||||||
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
|
|
||||||
endY = reactionBarHeight + menuPadding + reactionBarTopPadding - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
|
|
||||||
reactionBarBackgroundY = reactionBarTopPadding;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
float reactionBarOffset = DimensionUnit.DP.toPixels(48);
|
|
||||||
float spaceForReactionBar = Math.max(reactionBarHeight + reactionBarOffset, 0);
|
|
||||||
boolean everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.getHeight() + menuPadding + spaceForReactionBar < overlayHeight;
|
|
||||||
|
|
||||||
if (everythingFitsVertically) {
|
|
||||||
float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
|
|
||||||
boolean menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight;
|
|
||||||
|
|
||||||
if (menuFitsBelowItem) {
|
|
||||||
if (conversationItem.getY() < 0) {
|
|
||||||
endY = 0;
|
|
||||||
}
|
|
||||||
float contextMenuTop = endY + conversationItemSnapshot.getHeight();
|
|
||||||
reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
|
|
||||||
|
|
||||||
if (reactionBarBackgroundY <= reactionBarTopPadding) {
|
|
||||||
endY = backgroundView.getHeight() + menuPadding + reactionBarTopPadding;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.getHeight();
|
|
||||||
|
|
||||||
float contextMenuTop = endY + conversationItemSnapshot.getHeight();
|
|
||||||
reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
|
|
||||||
}
|
|
||||||
|
|
||||||
endApparentTop = endY;
|
|
||||||
} else if (reactionBarOffset + reactionBarHeight + contextMenu.getMaxHeight() + menuPadding < overlayHeight) {
|
|
||||||
float spaceAvailableForItem = (float) overlayHeight - contextMenu.getMaxHeight() - menuPadding - spaceForReactionBar;
|
|
||||||
|
|
||||||
endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
|
|
||||||
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
|
|
||||||
endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
|
|
||||||
|
|
||||||
float contextMenuTop = endY + (conversationItemSnapshot.getHeight() * endScale);
|
|
||||||
reactionBarBackgroundY = reactionBarTopPadding;//getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
|
|
||||||
endApparentTop = endY + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
|
|
||||||
} else {
|
|
||||||
contextMenu.setHeight(contextMenu.getMaxHeight() / 2);
|
|
||||||
|
|
||||||
int menuHeight = contextMenu.getHeight();
|
|
||||||
boolean fitsVertically = menuHeight + conversationItem.getHeight() + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight;
|
|
||||||
|
|
||||||
if (fitsVertically) {
|
|
||||||
float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
|
|
||||||
boolean menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + statusBarHeight;
|
|
||||||
|
|
||||||
if (menuFitsBelowItem) {
|
|
||||||
reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
|
|
||||||
|
|
||||||
if (reactionBarBackgroundY < reactionBarTopPadding) {
|
|
||||||
endY = reactionBarTopPadding + reactionBarHeight + menuPadding;
|
|
||||||
reactionBarBackgroundY = reactionBarTopPadding;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
endY = overlayHeight - menuHeight - menuPadding - conversationItemSnapshot.getHeight();
|
|
||||||
reactionBarBackgroundY = endY - reactionBarHeight - menuPadding;
|
|
||||||
}
|
|
||||||
endApparentTop = endY;
|
|
||||||
} else {
|
|
||||||
float spaceAvailableForItem = (float) overlayHeight - menuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding;
|
|
||||||
|
|
||||||
endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
|
|
||||||
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
|
|
||||||
endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale) + menuPadding + reactionBarTopPadding;
|
|
||||||
reactionBarBackgroundY = reactionBarTopPadding;
|
|
||||||
endApparentTop = reactionBarHeight + menuPadding + reactionBarTopPadding;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reactionBarBackgroundY = Math.max(reactionBarBackgroundY, -statusBarHeight);
|
|
||||||
|
|
||||||
hideAnimatorSet.end();
|
|
||||||
setVisibility(View.VISIBLE);
|
|
||||||
|
|
||||||
float scrubberX;
|
|
||||||
if (isMessageOnLeft) {
|
|
||||||
scrubberX = scrubberHorizontalMargin;
|
|
||||||
} else {
|
|
||||||
scrubberX = getWidth() - scrubberWidth - scrubberHorizontalMargin;
|
|
||||||
}
|
|
||||||
|
|
||||||
foregroundView.setX(scrubberX);
|
|
||||||
foregroundView.setY(reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.getHeight() / 2f);
|
|
||||||
|
|
||||||
backgroundView.setX(scrubberX);
|
|
||||||
backgroundView.setY(reactionBarBackgroundY);
|
|
||||||
|
|
||||||
verticalScrubBoundary.update(reactionBarBackgroundY,
|
|
||||||
lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone);
|
|
||||||
|
|
||||||
updateBoundsOnLayoutChanged();
|
|
||||||
|
|
||||||
revealAnimatorSet.start();
|
|
||||||
|
|
||||||
if (isWideLayout) {
|
|
||||||
float scrubberRight = scrubberX + scrubberWidth;
|
|
||||||
float offsetX = isMessageOnLeft ? scrubberRight + menuPadding : scrubberX - contextMenu.getMaxWidth() - menuPadding;
|
|
||||||
contextMenu.show((int) offsetX, (int) Math.min(backgroundView.getY(), overlayHeight - contextMenu.getMaxHeight()));
|
|
||||||
} else {
|
|
||||||
float contentX = isMessageOnLeft ? scrubberHorizontalMargin : selectedConversationModel.getBubbleX();
|
|
||||||
float offsetX = isMessageOnLeft ? contentX : -contextMenu.getMaxWidth() + contentX + bubbleWidth;
|
|
||||||
|
|
||||||
float menuTop = endApparentTop + (conversationItemSnapshot.getHeight() * endScale);
|
|
||||||
contextMenu.show((int) offsetX, (int) (menuTop + menuPadding));
|
|
||||||
}
|
|
||||||
|
|
||||||
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
|
|
||||||
|
|
||||||
conversationItem.animate()
|
|
||||||
.x(endX)
|
|
||||||
.y(endY)
|
|
||||||
.scaleX(endScale)
|
|
||||||
.scaleY(endScale)
|
|
||||||
.setDuration(revealDuration);
|
|
||||||
}
|
|
||||||
|
|
||||||
private float getReactionBarOffsetForTouch(float itemY,
|
|
||||||
float contextMenuTop,
|
|
||||||
float contextMenuPadding,
|
|
||||||
float reactionBarOffset,
|
|
||||||
int reactionBarHeight,
|
|
||||||
float spaceNeededBetweenTopOfScreenAndTopOfReactionBar,
|
|
||||||
float messageTop)
|
|
||||||
{
|
|
||||||
float adjustedTouchY = itemY - statusBarHeight;
|
|
||||||
float reactionStartingPoint = Math.min(adjustedTouchY, contextMenuTop);
|
|
||||||
|
|
||||||
float spaceBetweenTopOfMessageAndTopOfContextMenu = Math.abs(messageTop - contextMenuTop);
|
|
||||||
|
|
||||||
if (spaceBetweenTopOfMessageAndTopOfContextMenu < DimensionUnit.DP.toPixels(150)) {
|
|
||||||
float offsetToMakeReactionBarOffsetMatchMenuPadding = reactionBarOffset - contextMenuPadding;
|
|
||||||
reactionStartingPoint = messageTop + offsetToMakeReactionBarOffsetMatchMenuPadding;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.max(reactionStartingPoint - reactionBarOffset - reactionBarHeight, spaceNeededBetweenTopOfScreenAndTopOfReactionBar);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void updateSystemUiOnShow(@NonNull Activity activity) {
|
|
||||||
Window window = activity.getWindow();
|
|
||||||
int barColor = ContextCompat.getColor(getContext(), R.color.reactions_screen_dark_shade_color);
|
|
||||||
|
|
||||||
originalStatusBarColor = window.getStatusBarColor();
|
|
||||||
WindowUtil.setStatusBarColor(window, barColor);
|
|
||||||
|
|
||||||
originalNavigationBarColor = window.getNavigationBarColor();
|
|
||||||
WindowUtil.setNavigationBarColor(window, barColor);
|
|
||||||
|
|
||||||
if (!ThemeUtil.isDarkTheme(getContext())) {
|
|
||||||
WindowUtil.clearLightStatusBar(window);
|
|
||||||
WindowUtil.clearLightNavigationBar(window);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void hide() {
|
|
||||||
hideInternal(onHideListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void hideForReactWithAny() {
|
|
||||||
hideInternal(onHideListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void hideInternal(@Nullable OnHideListener onHideListener) {
|
|
||||||
overlayState = OverlayState.HIDDEN;
|
|
||||||
|
|
||||||
AnimatorSet animatorSet = newHideAnimatorSet();
|
|
||||||
hideAnimatorSet = animatorSet;
|
|
||||||
|
|
||||||
revealAnimatorSet.end();
|
|
||||||
animatorSet.start();
|
|
||||||
|
|
||||||
if (onHideListener != null) {
|
|
||||||
onHideListener.startHide();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedConversationModel.getFocusedView() != null) {
|
|
||||||
ViewUtil.focusAndShowKeyboard(selectedConversationModel.getFocusedView());
|
|
||||||
}
|
|
||||||
|
|
||||||
animatorSet.addListener(new AnimationCompleteListener() {
|
|
||||||
@Override public void onAnimationEnd(Animator animation) {
|
|
||||||
animatorSet.removeListener(this);
|
|
||||||
|
|
||||||
if (onHideListener != null) {
|
|
||||||
onHideListener.onHide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (contextMenu != null) {
|
|
||||||
contextMenu.dismiss();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isShowing() {
|
|
||||||
return overlayState != OverlayState.HIDDEN;
|
|
||||||
}
|
|
||||||
|
|
||||||
public @NonNull MessageRecord getMessageRecord() {
|
|
||||||
return messageRecord;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onLayout(boolean changed, int l, int t, int r, int b) {
|
|
||||||
super.onLayout(changed, l, t, r, b);
|
|
||||||
|
|
||||||
updateBoundsOnLayoutChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void updateBoundsOnLayoutChanged() {
|
|
||||||
backgroundView.getGlobalVisibleRect(emojiStripViewBounds);
|
|
||||||
emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect);
|
|
||||||
emojiStripViewBounds.left = getStart(emojiViewGlobalRect);
|
|
||||||
emojiViews[emojiViews.length - 1].getGlobalVisibleRect(emojiViewGlobalRect);
|
|
||||||
emojiStripViewBounds.right = getEnd(emojiViewGlobalRect);
|
|
||||||
|
|
||||||
segmentSize = emojiStripViewBounds.width() / (float) emojiViews.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int getStart(@NonNull Rect rect) {
|
|
||||||
if (ViewUtil.isLtr(this)) {
|
|
||||||
return rect.left;
|
|
||||||
} else {
|
|
||||||
return rect.right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private int getEnd(@NonNull Rect rect) {
|
|
||||||
if (ViewUtil.isLtr(this)) {
|
|
||||||
return rect.right;
|
|
||||||
} else {
|
|
||||||
return rect.left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean applyTouchEvent(@NonNull MotionEvent motionEvent) {
|
|
||||||
if (!isShowing()) {
|
|
||||||
throw new IllegalStateException("Touch events should only be propagated to this method if we are displaying the scrubber.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((motionEvent.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) != 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overlayState == OverlayState.UNINITAILIZED) {
|
|
||||||
downIsOurs = false;
|
|
||||||
|
|
||||||
deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY());
|
|
||||||
|
|
||||||
overlayState = OverlayState.DEADZONE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overlayState == OverlayState.DEADZONE) {
|
|
||||||
float deltaX = Math.abs(deadzoneTouchPoint.x - motionEvent.getX());
|
|
||||||
float deltaY = Math.abs(deadzoneTouchPoint.y - motionEvent.getY());
|
|
||||||
|
|
||||||
if (deltaX > touchDownDeadZoneSize || deltaY > touchDownDeadZoneSize) {
|
|
||||||
overlayState = OverlayState.SCRUB;
|
|
||||||
} else {
|
|
||||||
if (motionEvent.getAction() == MotionEvent.ACTION_UP) {
|
|
||||||
overlayState = OverlayState.TAP;
|
|
||||||
|
|
||||||
if (downIsOurs) {
|
|
||||||
handleUpEvent();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return MotionEvent.ACTION_MOVE == motionEvent.getAction();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (motionEvent.getAction()) {
|
|
||||||
case MotionEvent.ACTION_DOWN:
|
|
||||||
selected = getSelectedIndexViaDownEvent(motionEvent);
|
|
||||||
|
|
||||||
deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY());
|
|
||||||
overlayState = OverlayState.DEADZONE;
|
|
||||||
downIsOurs = true;
|
|
||||||
return true;
|
|
||||||
case MotionEvent.ACTION_MOVE:
|
|
||||||
selected = getSelectedIndexViaMoveEvent(motionEvent);
|
|
||||||
return true;
|
|
||||||
case MotionEvent.ACTION_UP:
|
|
||||||
handleUpEvent();
|
|
||||||
return downIsOurs;
|
|
||||||
case MotionEvent.ACTION_CANCEL:
|
|
||||||
hide();
|
|
||||||
return downIsOurs;
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setupSelectedEmoji() {
|
|
||||||
final List<String> emojis = recentEmojiPageModel.getEmoji();
|
|
||||||
|
|
||||||
for (int i = 0; i < emojiViews.length; i++) {
|
|
||||||
final EmojiImageView view = emojiViews[i];
|
|
||||||
|
|
||||||
view.setScaleX(1.0f);
|
|
||||||
view.setScaleY(1.0f);
|
|
||||||
view.setTranslationY(0);
|
|
||||||
|
|
||||||
boolean isAtCustomIndex = i == customEmojiIndex;
|
|
||||||
|
|
||||||
if (isAtCustomIndex) {
|
|
||||||
view.setImageDrawable(ContextCompat.getDrawable(getContext(), R.drawable.ic_baseline_add_24));
|
|
||||||
view.setTag(null);
|
|
||||||
} else {
|
|
||||||
view.setImageEmoji(emojis.get(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private int getSelectedIndexViaDownEvent(@NonNull MotionEvent motionEvent) {
|
|
||||||
return getSelectedIndexViaMotionEvent(motionEvent, new Boundary(emojiStripViewBounds.top, emojiStripViewBounds.bottom));
|
|
||||||
}
|
|
||||||
|
|
||||||
private int getSelectedIndexViaMoveEvent(@NonNull MotionEvent motionEvent) {
|
|
||||||
return getSelectedIndexViaMotionEvent(motionEvent, verticalScrubBoundary);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int getSelectedIndexViaMotionEvent(@NonNull MotionEvent motionEvent, @NonNull Boundary boundary) {
|
|
||||||
int selected = -1;
|
|
||||||
|
|
||||||
if (backgroundView.getVisibility() != View.VISIBLE) {
|
|
||||||
return selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < emojiViews.length; i++) {
|
|
||||||
final float emojiLeft = (segmentSize * i) + emojiStripViewBounds.left;
|
|
||||||
horizontalEmojiBoundary.update(emojiLeft, emojiLeft + segmentSize);
|
|
||||||
|
|
||||||
if (horizontalEmojiBoundary.contains(motionEvent.getX()) && boundary.contains(motionEvent.getY())) {
|
|
||||||
selected = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.selected != -1 && this.selected != selected) {
|
|
||||||
shrinkView(emojiViews[this.selected]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.selected != selected && selected != -1) {
|
|
||||||
growView(emojiViews[selected]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void growView(@NonNull View view) {
|
|
||||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
|
|
||||||
view.animate()
|
|
||||||
.scaleY(1.5f)
|
|
||||||
.scaleX(1.5f)
|
|
||||||
.translationY(-selectedVerticalTranslation)
|
|
||||||
.setDuration(200)
|
|
||||||
.setInterpolator(INTERPOLATOR)
|
|
||||||
.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void shrinkView(@NonNull View view) {
|
|
||||||
view.animate()
|
|
||||||
.scaleX(1.0f)
|
|
||||||
.scaleY(1.0f)
|
|
||||||
.translationY(0)
|
|
||||||
.setDuration(200)
|
|
||||||
.setInterpolator(INTERPOLATOR)
|
|
||||||
.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleUpEvent() {
|
|
||||||
if (selected != -1 && onReactionSelectedListener != null && backgroundView.getVisibility() == View.VISIBLE) {
|
|
||||||
if (selected == customEmojiIndex) {
|
|
||||||
onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].getTag() != null);
|
|
||||||
} else {
|
|
||||||
onReactionSelectedListener.onReactionSelected(messageRecord, recentEmojiPageModel.getEmoji().get(selected));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setOnReactionSelectedListener(@Nullable OnReactionSelectedListener onReactionSelectedListener) {
|
|
||||||
this.onReactionSelectedListener = onReactionSelectedListener;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setOnActionSelectedListener(@Nullable OnActionSelectedListener onActionSelectedListener) {
|
|
||||||
this.onActionSelectedListener = onActionSelectedListener;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setOnHideListener(@Nullable OnHideListener onHideListener) {
|
|
||||||
this.onHideListener = onHideListener;
|
|
||||||
}
|
|
||||||
|
|
||||||
private @Nullable String getOldEmoji(@NonNull MessageRecord messageRecord) {
|
|
||||||
return Stream.of(messageRecord.getReactions())
|
|
||||||
.filter(record -> record.getAuthor().equals(TextSecurePreferences.getLocalNumber(getContext())))
|
|
||||||
.findFirst()
|
|
||||||
.map(ReactionRecord::getEmoji)
|
|
||||||
.orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private @NonNull List<ActionItem> getMenuActionItems(@NonNull MessageRecord message) {
|
|
||||||
List<ActionItem> items = new ArrayList<>();
|
|
||||||
|
|
||||||
// Prepare
|
|
||||||
boolean containsControlMessage = message.isUpdate();
|
|
||||||
boolean hasText = !message.getBody().isEmpty();
|
|
||||||
OpenGroup openGroup = DatabaseComponent.get(getContext()).lokiThreadDatabase().getOpenGroupChat(message.getThreadId());
|
|
||||||
Recipient recipient = DatabaseComponent.get(getContext()).threadDatabase().getRecipientForThreadId(message.getThreadId());
|
|
||||||
if (recipient == null) return Collections.emptyList();
|
|
||||||
|
|
||||||
String userPublicKey = TextSecurePreferences.getLocalNumber(getContext());
|
|
||||||
// Select message
|
|
||||||
items.add(new ActionItem(R.attr.menu_select_icon, getContext().getResources().getString(R.string.conversation_context__menu_select), () -> handleActionItemClicked(Action.SELECT)));
|
|
||||||
// Reply
|
|
||||||
if (!message.isPending() && !message.isFailed()) {
|
|
||||||
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_reply), () -> handleActionItemClicked(Action.REPLY)));
|
|
||||||
}
|
|
||||||
// Copy message text
|
|
||||||
if (!containsControlMessage && hasText) {
|
|
||||||
items.add(new ActionItem(R.attr.menu_copy_icon, getContext().getResources().getString(R.string.copy), () -> handleActionItemClicked(Action.COPY_MESSAGE)));
|
|
||||||
}
|
|
||||||
// Copy Session ID
|
|
||||||
if (recipient.isGroupRecipient() && !recipient.isOpenGroupRecipient() && !message.getRecipient().getAddress().toString().equals(userPublicKey)) {
|
|
||||||
items.add(new ActionItem(R.attr.menu_copy_icon, getContext().getResources().getString(R.string.activity_conversation_menu_copy_session_id), () -> handleActionItemClicked(Action.COPY_SESSION_ID)));
|
|
||||||
}
|
|
||||||
// Delete message
|
|
||||||
if (ConversationMenuItemHelper.userCanDeleteSelectedItems(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) {
|
|
||||||
items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.delete), () -> handleActionItemClicked(Action.DELETE)));
|
|
||||||
}
|
|
||||||
// Ban user
|
|
||||||
if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) {
|
|
||||||
items.add(new ActionItem(R.attr.menu_block_icon, getContext().getResources().getString(R.string.conversation_context__menu_ban_user), () -> handleActionItemClicked(Action.BAN_USER)));
|
|
||||||
}
|
|
||||||
// Ban and delete all
|
|
||||||
if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) {
|
|
||||||
items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.conversation_context__menu_ban_and_delete_all), () -> handleActionItemClicked(Action.BAN_AND_DELETE_ALL)));
|
|
||||||
}
|
|
||||||
// Message detail
|
|
||||||
if (message.isFailed()) {
|
|
||||||
items.add(new ActionItem(R.attr.menu_info_icon, getContext().getResources().getString(R.string.conversation_context__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO)));
|
|
||||||
}
|
|
||||||
// Resend
|
|
||||||
if (message.isFailed()) {
|
|
||||||
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resend_message), () -> handleActionItemClicked(Action.RESEND)));
|
|
||||||
}
|
|
||||||
// Save media
|
|
||||||
if (message.isMms() && ((MediaMmsMessageRecord)message).containsMediaSlide()) {
|
|
||||||
items.add(new ActionItem(R.attr.menu_save_icon, getContext().getResources().getString(R.string.conversation_context_image__save_attachment), () -> handleActionItemClicked(Action.DOWNLOAD)));
|
|
||||||
}
|
|
||||||
|
|
||||||
backgroundView.setVisibility(View.VISIBLE);
|
|
||||||
foregroundView.setVisibility(View.VISIBLE);
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleActionItemClicked(@NonNull Action action) {
|
|
||||||
hideInternal(new OnHideListener() {
|
|
||||||
@Override public void startHide() {
|
|
||||||
if (onHideListener != null) {
|
|
||||||
onHideListener.startHide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onHide() {
|
|
||||||
if (onHideListener != null) {
|
|
||||||
onHideListener.onHide();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onActionSelectedListener != null) {
|
|
||||||
onActionSelectedListener.onActionSelected(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void initAnimators() {
|
|
||||||
|
|
||||||
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
|
|
||||||
int revealOffset = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_offset);
|
|
||||||
|
|
||||||
List<Animator> reveals = Stream.of(emojiViews)
|
|
||||||
.mapIndexed((idx, v) -> {
|
|
||||||
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_reveal);
|
|
||||||
anim.setTarget(v);
|
|
||||||
anim.setStartDelay(idx * animationEmojiStartDelayFactor);
|
|
||||||
return anim;
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
Animator backgroundRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
|
|
||||||
backgroundRevealAnim.setTarget(backgroundView);
|
|
||||||
backgroundRevealAnim.setDuration(revealDuration);
|
|
||||||
backgroundRevealAnim.setStartDelay(revealOffset);
|
|
||||||
reveals.add(backgroundRevealAnim);
|
|
||||||
|
|
||||||
revealAnimatorSet.setInterpolator(INTERPOLATOR);
|
|
||||||
revealAnimatorSet.playTogether(reveals);
|
|
||||||
}
|
|
||||||
|
|
||||||
private @NonNull AnimatorSet newHideAnimatorSet() {
|
|
||||||
AnimatorSet set = new AnimatorSet();
|
|
||||||
|
|
||||||
set.addListener(new AnimationCompleteListener() {
|
|
||||||
@Override
|
|
||||||
public void onAnimationEnd(Animator animation) {
|
|
||||||
setVisibility(View.GONE);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
set.setInterpolator(INTERPOLATOR);
|
|
||||||
|
|
||||||
set.playTogether(newHideAnimators());
|
|
||||||
|
|
||||||
return set;
|
|
||||||
}
|
|
||||||
|
|
||||||
private @NonNull List<Animator> newHideAnimators() {
|
|
||||||
int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_hide_duration);
|
|
||||||
|
|
||||||
List<Animator> animators = new ArrayList<>(Stream.of(emojiViews)
|
|
||||||
.mapIndexed((idx, v) -> {
|
|
||||||
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);
|
|
||||||
anim.setTarget(v);
|
|
||||||
return anim;
|
|
||||||
})
|
|
||||||
.toList());
|
|
||||||
|
|
||||||
Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
|
|
||||||
backgroundHideAnim.setTarget(backgroundView);
|
|
||||||
backgroundHideAnim.setDuration(duration);
|
|
||||||
animators.add(backgroundHideAnim);
|
|
||||||
|
|
||||||
ObjectAnimator itemScaleXAnim = new ObjectAnimator();
|
|
||||||
itemScaleXAnim.setProperty(View.SCALE_X);
|
|
||||||
itemScaleXAnim.setFloatValues(1f);
|
|
||||||
itemScaleXAnim.setTarget(conversationItem);
|
|
||||||
itemScaleXAnim.setDuration(duration);
|
|
||||||
animators.add(itemScaleXAnim);
|
|
||||||
|
|
||||||
ObjectAnimator itemScaleYAnim = new ObjectAnimator();
|
|
||||||
itemScaleYAnim.setProperty(View.SCALE_Y);
|
|
||||||
itemScaleYAnim.setFloatValues(1f);
|
|
||||||
itemScaleYAnim.setTarget(conversationItem);
|
|
||||||
itemScaleYAnim.setDuration(duration);
|
|
||||||
animators.add(itemScaleYAnim);
|
|
||||||
|
|
||||||
ObjectAnimator itemXAnim = new ObjectAnimator();
|
|
||||||
itemXAnim.setProperty(View.X);
|
|
||||||
itemXAnim.setFloatValues(selectedConversationModel.getBubbleX());
|
|
||||||
itemXAnim.setTarget(conversationItem);
|
|
||||||
itemXAnim.setDuration(duration);
|
|
||||||
animators.add(itemXAnim);
|
|
||||||
|
|
||||||
ObjectAnimator itemYAnim = new ObjectAnimator();
|
|
||||||
itemYAnim.setProperty(View.Y);
|
|
||||||
itemYAnim.setFloatValues(selectedConversationModel.getBubbleY() - statusBarHeight);
|
|
||||||
itemYAnim.setTarget(conversationItem);
|
|
||||||
itemYAnim.setDuration(duration);
|
|
||||||
animators.add(itemYAnim);
|
|
||||||
|
|
||||||
if (activity != null) {
|
|
||||||
ValueAnimator statusBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalStatusBarColor);
|
|
||||||
statusBarAnim.setDuration(duration);
|
|
||||||
statusBarAnim.addUpdateListener(animation -> {
|
|
||||||
WindowUtil.setStatusBarColor(activity.getWindow(), (int) animation.getAnimatedValue());
|
|
||||||
});
|
|
||||||
animators.add(statusBarAnim);
|
|
||||||
|
|
||||||
ValueAnimator navigationBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalNavigationBarColor);
|
|
||||||
navigationBarAnim.setDuration(duration);
|
|
||||||
navigationBarAnim.addUpdateListener(animation -> {
|
|
||||||
WindowUtil.setNavigationBarColor(activity.getWindow(), (int) animation.getAnimatedValue());
|
|
||||||
});
|
|
||||||
animators.add(navigationBarAnim);
|
|
||||||
}
|
|
||||||
|
|
||||||
return animators;
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface OnHideListener {
|
|
||||||
void startHide();
|
|
||||||
void onHide();
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface OnReactionSelectedListener {
|
|
||||||
void onReactionSelected(@NonNull MessageRecord messageRecord, String emoji);
|
|
||||||
void onCustomReactionSelected(@NonNull MessageRecord messageRecord, boolean hasAddedCustomEmoji);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface OnActionSelectedListener {
|
|
||||||
void onActionSelected(@NonNull Action action);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class Boundary {
|
|
||||||
private float min;
|
|
||||||
private float max;
|
|
||||||
|
|
||||||
Boundary() {}
|
|
||||||
|
|
||||||
Boundary(float min, float max) {
|
|
||||||
update(min, max);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void update(float min, float max) {
|
|
||||||
this.min = min;
|
|
||||||
this.max = max;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean contains(float value) {
|
|
||||||
if (min < max) {
|
|
||||||
return this.min < value && this.max > value;
|
|
||||||
} else {
|
|
||||||
return this.min > value && this.max < value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum OverlayState {
|
|
||||||
HIDDEN,
|
|
||||||
UNINITAILIZED,
|
|
||||||
DEADZONE,
|
|
||||||
SCRUB,
|
|
||||||
TAP
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum Action {
|
|
||||||
REPLY,
|
|
||||||
RESEND,
|
|
||||||
DOWNLOAD,
|
|
||||||
COPY_MESSAGE,
|
|
||||||
COPY_SESSION_ID,
|
|
||||||
VIEW_INFO,
|
|
||||||
SELECT,
|
|
||||||
DELETE,
|
|
||||||
BAN_USER,
|
|
||||||
BAN_AND_DELETE_ALL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,719 @@
|
|||||||
|
package org.thoughtcrime.securesms.conversation.v2
|
||||||
|
|
||||||
|
import android.animation.Animator
|
||||||
|
import android.animation.AnimatorSet
|
||||||
|
import android.animation.ObjectAnimator
|
||||||
|
import android.animation.ValueAnimator
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.PointF
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.graphics.drawable.BitmapDrawable
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.HapticFeedbackConstants
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.animation.DecelerateInterpolator
|
||||||
|
import android.view.animation.Interpolator
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.doOnLayout
|
||||||
|
import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import network.loki.messenger.R
|
||||||
|
import org.session.libsession.snode.SnodeAPI
|
||||||
|
import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber
|
||||||
|
import org.session.libsession.utilities.ThemeUtil
|
||||||
|
import org.thoughtcrime.securesms.components.emoji.EmojiImageView
|
||||||
|
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
|
||||||
|
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||||
|
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper.userCanBanSelectedUsers
|
||||||
|
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper.userCanDeleteSelectedItems
|
||||||
|
import org.thoughtcrime.securesms.database.MmsSmsDatabase
|
||||||
|
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
|
||||||
|
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||||
|
import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||||
|
import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
|
||||||
|
import org.thoughtcrime.securesms.repository.ConversationRepository
|
||||||
|
import org.thoughtcrime.securesms.util.AnimationCompleteListener
|
||||||
|
import org.thoughtcrime.securesms.util.DateUtils
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class ConversationReactionOverlay : FrameLayout {
|
||||||
|
private val emojiViewGlobalRect = Rect()
|
||||||
|
private val emojiStripViewBounds = Rect()
|
||||||
|
private var segmentSize = 0f
|
||||||
|
private val horizontalEmojiBoundary = Boundary()
|
||||||
|
private val verticalScrubBoundary = Boundary()
|
||||||
|
private val deadzoneTouchPoint = PointF()
|
||||||
|
private lateinit var activity: Activity
|
||||||
|
lateinit var messageRecord: MessageRecord
|
||||||
|
private lateinit var selectedConversationModel: SelectedConversationModel
|
||||||
|
private var blindedPublicKey: String? = null
|
||||||
|
private var overlayState = OverlayState.HIDDEN
|
||||||
|
private lateinit var recentEmojiPageModel: RecentEmojiPageModel
|
||||||
|
private var downIsOurs = false
|
||||||
|
private var selected = -1
|
||||||
|
private var customEmojiIndex = 0
|
||||||
|
private var originalStatusBarColor = 0
|
||||||
|
private var originalNavigationBarColor = 0
|
||||||
|
private lateinit var dropdownAnchor: View
|
||||||
|
private lateinit var conversationItem: LinearLayout
|
||||||
|
private lateinit var conversationBubble: View
|
||||||
|
private lateinit var conversationTimestamp: TextView
|
||||||
|
private lateinit var backgroundView: View
|
||||||
|
private lateinit var foregroundView: ConstraintLayout
|
||||||
|
private lateinit var emojiViews: List<EmojiImageView>
|
||||||
|
private var contextMenu: ConversationContextMenu? = null
|
||||||
|
private var touchDownDeadZoneSize = 0f
|
||||||
|
private var distanceFromTouchDownPointToBottomOfScrubberDeadZone = 0f
|
||||||
|
private var scrubberWidth = 0
|
||||||
|
private var selectedVerticalTranslation = 0
|
||||||
|
private var scrubberHorizontalMargin = 0
|
||||||
|
private var animationEmojiStartDelayFactor = 0
|
||||||
|
private var statusBarHeight = 0
|
||||||
|
private var onReactionSelectedListener: OnReactionSelectedListener? = null
|
||||||
|
private var onActionSelectedListener: OnActionSelectedListener? = null
|
||||||
|
private var onHideListener: OnHideListener? = null
|
||||||
|
private val revealAnimatorSet = AnimatorSet()
|
||||||
|
private var hideAnimatorSet = AnimatorSet()
|
||||||
|
|
||||||
|
@Inject lateinit var mmsSmsDatabase: MmsSmsDatabase
|
||||||
|
@Inject lateinit var repository: ConversationRepository
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Default)
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
constructor(context: Context) : super(context)
|
||||||
|
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||||
|
|
||||||
|
override fun onFinishInflate() {
|
||||||
|
super.onFinishInflate()
|
||||||
|
dropdownAnchor = findViewById(R.id.dropdown_anchor)
|
||||||
|
conversationItem = findViewById(R.id.conversation_item)
|
||||||
|
conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble)
|
||||||
|
conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp)
|
||||||
|
backgroundView = findViewById(R.id.conversation_reaction_scrubber_background)
|
||||||
|
foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground)
|
||||||
|
emojiViews = listOf(R.id.reaction_1, R.id.reaction_2, R.id.reaction_3, R.id.reaction_4, R.id.reaction_5, R.id.reaction_6, R.id.reaction_7).map { findViewById(it) }
|
||||||
|
customEmojiIndex = emojiViews.size - 1
|
||||||
|
distanceFromTouchDownPointToBottomOfScrubberDeadZone = resources.getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom).toFloat()
|
||||||
|
touchDownDeadZoneSize = resources.getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size).toFloat()
|
||||||
|
scrubberWidth = resources.getDimensionPixelOffset(R.dimen.reaction_scrubber_width)
|
||||||
|
selectedVerticalTranslation = resources.getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation)
|
||||||
|
scrubberHorizontalMargin = resources.getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin)
|
||||||
|
animationEmojiStartDelayFactor = resources.getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor)
|
||||||
|
initAnimators()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun show(activity: Activity,
|
||||||
|
messageRecord: MessageRecord,
|
||||||
|
lastSeenDownPoint: PointF,
|
||||||
|
selectedConversationModel: SelectedConversationModel,
|
||||||
|
blindedPublicKey: String?) {
|
||||||
|
job?.cancel()
|
||||||
|
if (overlayState != OverlayState.HIDDEN) return
|
||||||
|
this.messageRecord = messageRecord
|
||||||
|
this.selectedConversationModel = selectedConversationModel
|
||||||
|
this.blindedPublicKey = blindedPublicKey
|
||||||
|
overlayState = OverlayState.UNINITAILIZED
|
||||||
|
selected = -1
|
||||||
|
recentEmojiPageModel = RecentEmojiPageModel(activity)
|
||||||
|
setupSelectedEmoji()
|
||||||
|
val statusBarBackground = activity.findViewById<View>(android.R.id.statusBarBackground)
|
||||||
|
statusBarHeight = statusBarBackground?.height ?: 0
|
||||||
|
val conversationItemSnapshot = selectedConversationModel.bitmap
|
||||||
|
conversationBubble.layoutParams = LinearLayout.LayoutParams(conversationItemSnapshot.width, conversationItemSnapshot.height)
|
||||||
|
conversationBubble.background = BitmapDrawable(resources, conversationItemSnapshot)
|
||||||
|
conversationTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), messageRecord.timestamp)
|
||||||
|
updateConversationTimestamp(messageRecord)
|
||||||
|
val isMessageOnLeft = selectedConversationModel.isOutgoing xor ViewUtil.isLtr(this)
|
||||||
|
conversationItem.scaleX = LONG_PRESS_SCALE_FACTOR
|
||||||
|
conversationItem.scaleY = LONG_PRESS_SCALE_FACTOR
|
||||||
|
visibility = INVISIBLE
|
||||||
|
this.activity = activity
|
||||||
|
updateSystemUiOnShow(activity)
|
||||||
|
doOnLayout { showAfterLayout(messageRecord, lastSeenDownPoint, isMessageOnLeft) }
|
||||||
|
|
||||||
|
job = scope.launch(Dispatchers.IO) {
|
||||||
|
repository.changes(messageRecord.threadId)
|
||||||
|
.filter { mmsSmsDatabase.getMessageForTimestamp(messageRecord.timestamp) == null }
|
||||||
|
.collect { withContext(Dispatchers.Main) { hide() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateConversationTimestamp(message: MessageRecord) {
|
||||||
|
if (message.isOutgoing) conversationBubble.bringToFront() else conversationTimestamp.bringToFront()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showAfterLayout(messageRecord: MessageRecord,
|
||||||
|
lastSeenDownPoint: PointF,
|
||||||
|
isMessageOnLeft: Boolean) {
|
||||||
|
val contextMenu = ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord))
|
||||||
|
this.contextMenu = contextMenu
|
||||||
|
var endX = if (isMessageOnLeft) scrubberHorizontalMargin.toFloat() else selectedConversationModel.bubbleX - conversationItem.width + selectedConversationModel.bubbleWidth
|
||||||
|
var endY = selectedConversationModel.bubbleY - statusBarHeight
|
||||||
|
conversationItem.x = endX
|
||||||
|
conversationItem.y = endY
|
||||||
|
val conversationItemSnapshot = selectedConversationModel.bitmap
|
||||||
|
val isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < width
|
||||||
|
val overlayHeight = height
|
||||||
|
val bubbleWidth = selectedConversationModel.bubbleWidth
|
||||||
|
var endApparentTop = endY
|
||||||
|
var endScale = 1f
|
||||||
|
val menuPadding = DimensionUnit.DP.toPixels(12f)
|
||||||
|
val reactionBarTopPadding = DimensionUnit.DP.toPixels(32f)
|
||||||
|
val reactionBarHeight = backgroundView.height
|
||||||
|
var reactionBarBackgroundY: Float
|
||||||
|
if (isWideLayout) {
|
||||||
|
val everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + conversationItemSnapshot.height < overlayHeight
|
||||||
|
if (everythingFitsVertically) {
|
||||||
|
val reactionBarFitsAboveItem = conversationItem.y > reactionBarHeight + menuPadding + reactionBarTopPadding
|
||||||
|
if (reactionBarFitsAboveItem) {
|
||||||
|
reactionBarBackgroundY = conversationItem.y - menuPadding - reactionBarHeight
|
||||||
|
} else {
|
||||||
|
endY = reactionBarHeight + menuPadding + reactionBarTopPadding
|
||||||
|
reactionBarBackgroundY = reactionBarTopPadding
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding
|
||||||
|
endScale = spaceAvailableForItem / conversationItem.height
|
||||||
|
endX += Util.halfOffsetFromScale(conversationItemSnapshot.width, endScale) * if (isMessageOnLeft) -1 else 1
|
||||||
|
endY = reactionBarHeight + menuPadding + reactionBarTopPadding - Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale)
|
||||||
|
reactionBarBackgroundY = reactionBarTopPadding
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val reactionBarOffset = DimensionUnit.DP.toPixels(48f)
|
||||||
|
val spaceForReactionBar = Math.max(reactionBarHeight + reactionBarOffset, 0f)
|
||||||
|
val everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.height + menuPadding + spaceForReactionBar < overlayHeight
|
||||||
|
if (everythingFitsVertically) {
|
||||||
|
val bubbleBottom = selectedConversationModel.bubbleY + conversationItemSnapshot.height
|
||||||
|
val menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight
|
||||||
|
if (menuFitsBelowItem) {
|
||||||
|
if (conversationItem.y < 0) {
|
||||||
|
endY = 0f
|
||||||
|
}
|
||||||
|
val contextMenuTop = endY + conversationItemSnapshot.height
|
||||||
|
reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.bubbleY, contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY)
|
||||||
|
if (reactionBarBackgroundY <= reactionBarTopPadding) {
|
||||||
|
endY = backgroundView.height + menuPadding + reactionBarTopPadding
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.height
|
||||||
|
reactionBarBackgroundY = endY - reactionBarHeight - menuPadding
|
||||||
|
}
|
||||||
|
endApparentTop = endY
|
||||||
|
} else if (reactionBarOffset + reactionBarHeight + contextMenu.getMaxHeight() + menuPadding < overlayHeight) {
|
||||||
|
val spaceAvailableForItem = overlayHeight.toFloat() - contextMenu.getMaxHeight() - menuPadding - spaceForReactionBar
|
||||||
|
endScale = spaceAvailableForItem / conversationItemSnapshot.height
|
||||||
|
endX += Util.halfOffsetFromScale(conversationItemSnapshot.width, endScale) * if (isMessageOnLeft) -1 else 1
|
||||||
|
endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale)
|
||||||
|
val contextMenuTop = endY + conversationItemSnapshot.height * endScale
|
||||||
|
reactionBarBackgroundY = reactionBarTopPadding //getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
|
||||||
|
endApparentTop = endY + Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale)
|
||||||
|
} else {
|
||||||
|
contextMenu.height = contextMenu.getMaxHeight() / 2
|
||||||
|
val menuHeight = contextMenu.height
|
||||||
|
val fitsVertically = menuHeight + conversationItem.height + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight
|
||||||
|
if (fitsVertically) {
|
||||||
|
val bubbleBottom = selectedConversationModel.bubbleY + conversationItemSnapshot.height
|
||||||
|
val menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + statusBarHeight
|
||||||
|
if (menuFitsBelowItem) {
|
||||||
|
reactionBarBackgroundY = conversationItem.y - menuPadding - reactionBarHeight
|
||||||
|
if (reactionBarBackgroundY < reactionBarTopPadding) {
|
||||||
|
endY = reactionBarTopPadding + reactionBarHeight + menuPadding
|
||||||
|
reactionBarBackgroundY = reactionBarTopPadding
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
endY = overlayHeight - menuHeight - menuPadding - conversationItemSnapshot.height
|
||||||
|
reactionBarBackgroundY = endY - reactionBarHeight - menuPadding
|
||||||
|
}
|
||||||
|
endApparentTop = endY
|
||||||
|
} else {
|
||||||
|
val spaceAvailableForItem = overlayHeight.toFloat() - menuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding
|
||||||
|
endScale = spaceAvailableForItem / conversationItemSnapshot.height
|
||||||
|
endX += Util.halfOffsetFromScale(conversationItemSnapshot.width, endScale) * if (isMessageOnLeft) -1 else 1
|
||||||
|
endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) + menuPadding + reactionBarTopPadding
|
||||||
|
reactionBarBackgroundY = reactionBarTopPadding
|
||||||
|
endApparentTop = reactionBarHeight + menuPadding + reactionBarTopPadding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reactionBarBackgroundY = Math.max(reactionBarBackgroundY, -statusBarHeight.toFloat())
|
||||||
|
hideAnimatorSet.end()
|
||||||
|
visibility = VISIBLE
|
||||||
|
val scrubberX = if (isMessageOnLeft) {
|
||||||
|
scrubberHorizontalMargin.toFloat()
|
||||||
|
} else {
|
||||||
|
(width - scrubberWidth - scrubberHorizontalMargin).toFloat()
|
||||||
|
}
|
||||||
|
foregroundView.x = scrubberX
|
||||||
|
foregroundView.y = reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.height / 2f
|
||||||
|
backgroundView.x = scrubberX
|
||||||
|
backgroundView.y = reactionBarBackgroundY
|
||||||
|
verticalScrubBoundary.update(reactionBarBackgroundY,
|
||||||
|
lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone)
|
||||||
|
updateBoundsOnLayoutChanged()
|
||||||
|
revealAnimatorSet.start()
|
||||||
|
if (isWideLayout) {
|
||||||
|
val scrubberRight = scrubberX + scrubberWidth
|
||||||
|
val offsetX = if (isMessageOnLeft) scrubberRight + menuPadding else scrubberX - contextMenu.getMaxWidth() - menuPadding
|
||||||
|
contextMenu.show(offsetX.toInt(), Math.min(backgroundView.y, (overlayHeight - contextMenu.getMaxHeight()).toFloat()).toInt())
|
||||||
|
} else {
|
||||||
|
val contentX = if (isMessageOnLeft) scrubberHorizontalMargin.toFloat() else selectedConversationModel.bubbleX
|
||||||
|
val offsetX = if (isMessageOnLeft) contentX else -contextMenu.getMaxWidth() + contentX + bubbleWidth
|
||||||
|
val menuTop = endApparentTop + conversationItemSnapshot.height * endScale
|
||||||
|
contextMenu.show(offsetX.toInt(), (menuTop + menuPadding).toInt())
|
||||||
|
}
|
||||||
|
val revealDuration = context.resources.getInteger(R.integer.reaction_scrubber_reveal_duration)
|
||||||
|
conversationBubble.animate()
|
||||||
|
.scaleX(endScale)
|
||||||
|
.scaleY(endScale)
|
||||||
|
.setDuration(revealDuration.toLong())
|
||||||
|
conversationItem.animate()
|
||||||
|
.x(endX)
|
||||||
|
.y(endY)
|
||||||
|
.setDuration(revealDuration.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getReactionBarOffsetForTouch(itemY: Float,
|
||||||
|
contextMenuTop: Float,
|
||||||
|
contextMenuPadding: Float,
|
||||||
|
reactionBarOffset: Float,
|
||||||
|
reactionBarHeight: Int,
|
||||||
|
spaceNeededBetweenTopOfScreenAndTopOfReactionBar: Float,
|
||||||
|
messageTop: Float): Float {
|
||||||
|
val adjustedTouchY = itemY - statusBarHeight
|
||||||
|
var reactionStartingPoint = Math.min(adjustedTouchY, contextMenuTop)
|
||||||
|
val spaceBetweenTopOfMessageAndTopOfContextMenu = Math.abs(messageTop - contextMenuTop)
|
||||||
|
if (spaceBetweenTopOfMessageAndTopOfContextMenu < DimensionUnit.DP.toPixels(150f)) {
|
||||||
|
val offsetToMakeReactionBarOffsetMatchMenuPadding = reactionBarOffset - contextMenuPadding
|
||||||
|
reactionStartingPoint = messageTop + offsetToMakeReactionBarOffsetMatchMenuPadding
|
||||||
|
}
|
||||||
|
return Math.max(reactionStartingPoint - reactionBarOffset - reactionBarHeight, spaceNeededBetweenTopOfScreenAndTopOfReactionBar)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateSystemUiOnShow(activity: Activity) {
|
||||||
|
val window = activity.window
|
||||||
|
val barColor = ContextCompat.getColor(context, R.color.reactions_screen_dark_shade_color)
|
||||||
|
originalStatusBarColor = window.statusBarColor
|
||||||
|
WindowUtil.setStatusBarColor(window, barColor)
|
||||||
|
originalNavigationBarColor = window.navigationBarColor
|
||||||
|
WindowUtil.setNavigationBarColor(window, barColor)
|
||||||
|
if (!ThemeUtil.isDarkTheme(context)) {
|
||||||
|
WindowUtil.clearLightStatusBar(window)
|
||||||
|
WindowUtil.clearLightNavigationBar(window)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hide() {
|
||||||
|
hideInternal(onHideListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideForReactWithAny() {
|
||||||
|
hideInternal(onHideListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideInternal(onHideListener: OnHideListener?) {
|
||||||
|
job?.cancel()
|
||||||
|
overlayState = OverlayState.HIDDEN
|
||||||
|
val animatorSet = newHideAnimatorSet()
|
||||||
|
hideAnimatorSet = animatorSet
|
||||||
|
revealAnimatorSet.end()
|
||||||
|
animatorSet.start()
|
||||||
|
onHideListener?.startHide()
|
||||||
|
selectedConversationModel.focusedView?.let(ViewUtil::focusAndShowKeyboard)
|
||||||
|
animatorSet.addListener(object : AnimationCompleteListener() {
|
||||||
|
override fun onAnimationEnd(animation: Animator) {
|
||||||
|
animatorSet.removeListener(this)
|
||||||
|
onHideListener?.onHide()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
contextMenu?.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
val isShowing: Boolean
|
||||||
|
get() = overlayState != OverlayState.HIDDEN
|
||||||
|
|
||||||
|
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
||||||
|
super.onLayout(changed, l, t, r, b)
|
||||||
|
updateBoundsOnLayoutChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateBoundsOnLayoutChanged() {
|
||||||
|
backgroundView.getGlobalVisibleRect(emojiStripViewBounds)
|
||||||
|
emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect)
|
||||||
|
emojiStripViewBounds.left = getStart(emojiViewGlobalRect)
|
||||||
|
emojiViews[emojiViews.size - 1].getGlobalVisibleRect(emojiViewGlobalRect)
|
||||||
|
emojiStripViewBounds.right = getEnd(emojiViewGlobalRect)
|
||||||
|
segmentSize = emojiStripViewBounds.width() / emojiViews.size.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getStart(rect: Rect): Int = if (ViewUtil.isLtr(this)) rect.left else rect.right
|
||||||
|
|
||||||
|
private fun getEnd(rect: Rect): Int = if (ViewUtil.isLtr(this)) rect.right else rect.left
|
||||||
|
|
||||||
|
fun applyTouchEvent(motionEvent: MotionEvent): Boolean {
|
||||||
|
check(isShowing) { "Touch events should only be propagated to this method if we are displaying the scrubber." }
|
||||||
|
if (motionEvent.action and MotionEvent.ACTION_POINTER_INDEX_MASK != 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (overlayState == OverlayState.UNINITAILIZED) {
|
||||||
|
downIsOurs = false
|
||||||
|
deadzoneTouchPoint[motionEvent.x] = motionEvent.y
|
||||||
|
overlayState = OverlayState.DEADZONE
|
||||||
|
}
|
||||||
|
if (overlayState == OverlayState.DEADZONE) {
|
||||||
|
val deltaX = Math.abs(deadzoneTouchPoint.x - motionEvent.x)
|
||||||
|
val deltaY = Math.abs(deadzoneTouchPoint.y - motionEvent.y)
|
||||||
|
if (deltaX > touchDownDeadZoneSize || deltaY > touchDownDeadZoneSize) {
|
||||||
|
overlayState = OverlayState.SCRUB
|
||||||
|
} else {
|
||||||
|
if (motionEvent.action == MotionEvent.ACTION_UP) {
|
||||||
|
overlayState = OverlayState.TAP
|
||||||
|
if (downIsOurs) {
|
||||||
|
handleUpEvent()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MotionEvent.ACTION_MOVE == motionEvent.action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return when (motionEvent.action) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
selected = getSelectedIndexViaDownEvent(motionEvent)
|
||||||
|
deadzoneTouchPoint[motionEvent.x] = motionEvent.y
|
||||||
|
overlayState = OverlayState.DEADZONE
|
||||||
|
downIsOurs = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
selected = getSelectedIndexViaMoveEvent(motionEvent)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_UP -> {
|
||||||
|
handleUpEvent()
|
||||||
|
downIsOurs
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_CANCEL -> {
|
||||||
|
hide()
|
||||||
|
downIsOurs
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupSelectedEmoji() {
|
||||||
|
val emojis = recentEmojiPageModel.emoji
|
||||||
|
emojiViews.forEachIndexed { i, view ->
|
||||||
|
view.scaleX = 1.0f
|
||||||
|
view.scaleY = 1.0f
|
||||||
|
view.translationY = 0f
|
||||||
|
val isAtCustomIndex = i == customEmojiIndex
|
||||||
|
if (isAtCustomIndex) {
|
||||||
|
view.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_baseline_add_24))
|
||||||
|
view.tag = null
|
||||||
|
} else {
|
||||||
|
view.setImageEmoji(emojis[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSelectedIndexViaDownEvent(motionEvent: MotionEvent): Int =
|
||||||
|
getSelectedIndexViaMotionEvent(motionEvent, Boundary(emojiStripViewBounds.top.toFloat(), emojiStripViewBounds.bottom.toFloat()))
|
||||||
|
|
||||||
|
private fun getSelectedIndexViaMoveEvent(motionEvent: MotionEvent): Int =
|
||||||
|
getSelectedIndexViaMotionEvent(motionEvent, verticalScrubBoundary)
|
||||||
|
|
||||||
|
private fun getSelectedIndexViaMotionEvent(motionEvent: MotionEvent, boundary: Boundary): Int {
|
||||||
|
var selected = -1
|
||||||
|
if (backgroundView.visibility != VISIBLE) {
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
for (i in emojiViews.indices) {
|
||||||
|
val emojiLeft = segmentSize * i + emojiStripViewBounds.left
|
||||||
|
horizontalEmojiBoundary.update(emojiLeft, emojiLeft + segmentSize)
|
||||||
|
if (horizontalEmojiBoundary.contains(motionEvent.x) && boundary.contains(motionEvent.y)) {
|
||||||
|
selected = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.selected != -1 && this.selected != selected) {
|
||||||
|
shrinkView(emojiViews[this.selected])
|
||||||
|
}
|
||||||
|
if (this.selected != selected && selected != -1) {
|
||||||
|
growView(emojiViews[selected])
|
||||||
|
}
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun growView(view: View) {
|
||||||
|
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||||
|
view.animate()
|
||||||
|
.scaleY(1.5f)
|
||||||
|
.scaleX(1.5f)
|
||||||
|
.translationY(-selectedVerticalTranslation.toFloat())
|
||||||
|
.setDuration(200)
|
||||||
|
.setInterpolator(INTERPOLATOR)
|
||||||
|
.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shrinkView(view: View) {
|
||||||
|
view.animate()
|
||||||
|
.scaleX(1.0f)
|
||||||
|
.scaleY(1.0f)
|
||||||
|
.translationY(0f)
|
||||||
|
.setDuration(200)
|
||||||
|
.setInterpolator(INTERPOLATOR)
|
||||||
|
.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUpEvent() {
|
||||||
|
val onReactionSelectedListener = onReactionSelectedListener
|
||||||
|
if (selected != -1 && onReactionSelectedListener != null && backgroundView.visibility == VISIBLE) {
|
||||||
|
if (selected == customEmojiIndex) {
|
||||||
|
onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].tag != null)
|
||||||
|
} else {
|
||||||
|
onReactionSelectedListener.onReactionSelected(messageRecord, recentEmojiPageModel.emoji[selected])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOnReactionSelectedListener(onReactionSelectedListener: OnReactionSelectedListener?) {
|
||||||
|
this.onReactionSelectedListener = onReactionSelectedListener
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOnActionSelectedListener(onActionSelectedListener: OnActionSelectedListener?) {
|
||||||
|
this.onActionSelectedListener = onActionSelectedListener
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOnHideListener(onHideListener: OnHideListener?) {
|
||||||
|
this.onHideListener = onHideListener
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOldEmoji(messageRecord: MessageRecord): String? =
|
||||||
|
messageRecord.reactions
|
||||||
|
.filter { it.author == getLocalNumber(context) }
|
||||||
|
.firstOrNull()
|
||||||
|
?.let(ReactionRecord::emoji)
|
||||||
|
|
||||||
|
private fun getMenuActionItems(message: MessageRecord): List<ActionItem> {
|
||||||
|
val items: MutableList<ActionItem> = ArrayList()
|
||||||
|
|
||||||
|
// Prepare
|
||||||
|
val containsControlMessage = message.isUpdate
|
||||||
|
val hasText = !message.body.isEmpty()
|
||||||
|
val openGroup = get(context).lokiThreadDatabase().getOpenGroupChat(message.threadId)
|
||||||
|
val recipient = get(context).threadDatabase().getRecipientForThreadId(message.threadId)
|
||||||
|
?: return emptyList()
|
||||||
|
val userPublicKey = getLocalNumber(context)!!
|
||||||
|
// Select message
|
||||||
|
items += ActionItem(R.attr.menu_select_icon, R.string.conversation_context__menu_select, { handleActionItemClicked(Action.SELECT) }, R.string.AccessibilityId_select)
|
||||||
|
// Reply
|
||||||
|
val canWrite = openGroup == null || openGroup.canWrite
|
||||||
|
if (canWrite && !message.isPending && !message.isFailed) {
|
||||||
|
items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_reply, { handleActionItemClicked(Action.REPLY) }, R.string.AccessibilityId_reply_message)
|
||||||
|
}
|
||||||
|
// Copy message text
|
||||||
|
if (!containsControlMessage && hasText) {
|
||||||
|
items += ActionItem(R.attr.menu_copy_icon, R.string.copy, { handleActionItemClicked(Action.COPY_MESSAGE) })
|
||||||
|
}
|
||||||
|
// Copy Session ID
|
||||||
|
if (recipient.isGroupRecipient && !recipient.isCommunityRecipient && message.recipient.address.toString() != userPublicKey) {
|
||||||
|
items += ActionItem(R.attr.menu_copy_icon, R.string.activity_conversation_menu_copy_session_id, { handleActionItemClicked(Action.COPY_SESSION_ID) })
|
||||||
|
}
|
||||||
|
// Delete message
|
||||||
|
if (userCanDeleteSelectedItems(context, message, openGroup, userPublicKey, blindedPublicKey)) {
|
||||||
|
items += ActionItem(R.attr.menu_trash_icon, R.string.delete, { handleActionItemClicked(Action.DELETE) }, R.string.AccessibilityId_delete_message, message.subtitle, R.color.destructive)
|
||||||
|
}
|
||||||
|
// Ban user
|
||||||
|
if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) {
|
||||||
|
items += ActionItem(R.attr.menu_block_icon, R.string.conversation_context__menu_ban_user, { handleActionItemClicked(Action.BAN_USER) })
|
||||||
|
}
|
||||||
|
// Ban and delete all
|
||||||
|
if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) {
|
||||||
|
items += ActionItem(R.attr.menu_trash_icon, R.string.conversation_context__menu_ban_and_delete_all, { handleActionItemClicked(Action.BAN_AND_DELETE_ALL) })
|
||||||
|
}
|
||||||
|
// Message detail
|
||||||
|
items += ActionItem(R.attr.menu_info_icon, R.string.conversation_context__menu_message_details, { handleActionItemClicked(Action.VIEW_INFO) })
|
||||||
|
// Resend
|
||||||
|
if (message.isFailed) {
|
||||||
|
items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_resend_message, { handleActionItemClicked(Action.RESEND) })
|
||||||
|
}
|
||||||
|
// Resync
|
||||||
|
if (message.isSyncFailed) {
|
||||||
|
items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_resync_message, { handleActionItemClicked(Action.RESYNC) })
|
||||||
|
}
|
||||||
|
// Save media
|
||||||
|
if (message.isMms && (message as MediaMmsMessageRecord).containsMediaSlide()) {
|
||||||
|
items += ActionItem(R.attr.menu_save_icon, R.string.conversation_context_image__save_attachment, { handleActionItemClicked(Action.DOWNLOAD) }, R.string.AccessibilityId_save_attachment)
|
||||||
|
}
|
||||||
|
backgroundView.visibility = VISIBLE
|
||||||
|
foregroundView.visibility = VISIBLE
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleActionItemClicked(action: Action) {
|
||||||
|
hideInternal(object : OnHideListener {
|
||||||
|
override fun startHide() {
|
||||||
|
onHideListener?.startHide()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onHide() {
|
||||||
|
onHideListener?.onHide()
|
||||||
|
onActionSelectedListener?.onActionSelected(action)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initAnimators() {
|
||||||
|
val revealDuration = context.resources.getInteger(R.integer.reaction_scrubber_reveal_duration)
|
||||||
|
val revealOffset = context.resources.getInteger(R.integer.reaction_scrubber_reveal_offset)
|
||||||
|
val reveals = emojiViews.mapIndexed { idx: Int, v: EmojiImageView? ->
|
||||||
|
AnimatorInflaterCompat.loadAnimator(context, R.animator.reactions_scrubber_reveal).apply {
|
||||||
|
setTarget(v)
|
||||||
|
startDelay = (idx * animationEmojiStartDelayFactor).toLong()
|
||||||
|
}
|
||||||
|
} + AnimatorInflaterCompat.loadAnimator(context, android.R.animator.fade_in).apply {
|
||||||
|
setTarget(backgroundView)
|
||||||
|
setDuration(revealDuration.toLong())
|
||||||
|
startDelay = revealOffset.toLong()
|
||||||
|
}
|
||||||
|
revealAnimatorSet.interpolator = INTERPOLATOR
|
||||||
|
revealAnimatorSet.playTogether(reveals)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newHideAnimatorSet() = AnimatorSet().apply {
|
||||||
|
addListener(object : AnimationCompleteListener() {
|
||||||
|
override fun onAnimationEnd(animation: Animator) {
|
||||||
|
visibility = GONE
|
||||||
|
}
|
||||||
|
})
|
||||||
|
interpolator = INTERPOLATOR
|
||||||
|
playTogether(newHideAnimators())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newHideAnimators(): List<Animator> {
|
||||||
|
val duration = context.resources.getInteger(R.integer.reaction_scrubber_hide_duration).toLong()
|
||||||
|
fun conversationItemAnimator(configure: ObjectAnimator.() -> Unit) = ObjectAnimator().apply {
|
||||||
|
target = conversationItem
|
||||||
|
setDuration(duration)
|
||||||
|
configure()
|
||||||
|
}
|
||||||
|
return emojiViews.map {
|
||||||
|
AnimatorInflaterCompat.loadAnimator(context, R.animator.reactions_scrubber_hide).apply { setTarget(it) }
|
||||||
|
} + AnimatorInflaterCompat.loadAnimator(context, android.R.animator.fade_out).apply {
|
||||||
|
setTarget(backgroundView)
|
||||||
|
setDuration(duration)
|
||||||
|
} + conversationItemAnimator {
|
||||||
|
setProperty(SCALE_X)
|
||||||
|
setFloatValues(1f)
|
||||||
|
} + conversationItemAnimator {
|
||||||
|
setProperty(SCALE_Y)
|
||||||
|
setFloatValues(1f)
|
||||||
|
} + conversationItemAnimator {
|
||||||
|
setProperty(X)
|
||||||
|
setFloatValues(selectedConversationModel.bubbleX)
|
||||||
|
} + conversationItemAnimator {
|
||||||
|
setProperty(Y)
|
||||||
|
setFloatValues(selectedConversationModel.bubbleY - statusBarHeight)
|
||||||
|
} + ValueAnimator.ofArgb(activity.window.statusBarColor, originalStatusBarColor).apply {
|
||||||
|
setDuration(duration)
|
||||||
|
addUpdateListener { animation: ValueAnimator -> WindowUtil.setStatusBarColor(activity.window, animation.animatedValue as Int) }
|
||||||
|
} + ValueAnimator.ofArgb(activity.window.statusBarColor, originalNavigationBarColor).apply {
|
||||||
|
setDuration(duration)
|
||||||
|
addUpdateListener { animation: ValueAnimator -> WindowUtil.setNavigationBarColor(activity.window, animation.animatedValue as Int) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnHideListener {
|
||||||
|
fun startHide()
|
||||||
|
fun onHide()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnReactionSelectedListener {
|
||||||
|
fun onReactionSelected(messageRecord: MessageRecord, emoji: String)
|
||||||
|
fun onCustomReactionSelected(messageRecord: MessageRecord, hasAddedCustomEmoji: Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnActionSelectedListener {
|
||||||
|
fun onActionSelected(action: Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Boundary(private var min: Float = 0f, private var max: Float = 0f) {
|
||||||
|
|
||||||
|
fun update(min: Float, max: Float) {
|
||||||
|
this.min = min
|
||||||
|
this.max = max
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun contains(value: Float) = if (min < max) {
|
||||||
|
min < value && max > value
|
||||||
|
} else {
|
||||||
|
min > value && max < value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class OverlayState {
|
||||||
|
HIDDEN,
|
||||||
|
UNINITAILIZED,
|
||||||
|
DEADZONE,
|
||||||
|
SCRUB,
|
||||||
|
TAP
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Action {
|
||||||
|
REPLY,
|
||||||
|
RESEND,
|
||||||
|
RESYNC,
|
||||||
|
DOWNLOAD,
|
||||||
|
COPY_MESSAGE,
|
||||||
|
COPY_SESSION_ID,
|
||||||
|
VIEW_INFO,
|
||||||
|
SELECT,
|
||||||
|
DELETE,
|
||||||
|
BAN_USER,
|
||||||
|
BAN_AND_DELETE_ALL
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val LONG_PRESS_SCALE_FACTOR = 0.95f
|
||||||
|
private val INTERPOLATOR: Interpolator = DecelerateInterpolator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Duration.to2partString(): String? =
|
||||||
|
toComponents { days, hours, minutes, seconds, nanoseconds -> listOf(days.days, hours.hours, minutes.minutes, seconds.seconds) }
|
||||||
|
.filter { it.inWholeSeconds > 0L }.take(2).takeIf { it.isNotEmpty() }?.joinToString(" ")
|
||||||
|
|
||||||
|
private val MessageRecord.subtitle: ((Context) -> CharSequence?)?
|
||||||
|
get() = if (expiresIn <= 0) {
|
||||||
|
null
|
||||||
|
} else { context ->
|
||||||
|
(expiresIn - (SnodeAPI.nowWithOffset - (expireStarted.takeIf { it > 0 } ?: timestamp)))
|
||||||
|
.coerceAtLeast(0L)
|
||||||
|
.milliseconds
|
||||||
|
.to2partString()
|
||||||
|
?.let { context.getString(R.string.auto_deletes_in, it) }
|
||||||
|
}
|
||||||
@@ -1,27 +1,38 @@
|
|||||||
package org.thoughtcrime.securesms.conversation.v2
|
package org.thoughtcrime.securesms.conversation.v2
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
|
||||||
import com.goterl.lazysodium.utils.KeyPair
|
import com.goterl.lazysodium.utils.KeyPair
|
||||||
|
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||||
import org.session.libsession.messaging.open_groups.OpenGroup
|
import org.session.libsession.messaging.open_groups.OpenGroup
|
||||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||||
import org.session.libsession.messaging.utilities.SessionId
|
import org.session.libsession.messaging.utilities.SessionId
|
||||||
import org.session.libsession.messaging.utilities.SodiumUtilities
|
import org.session.libsession.messaging.utilities.SodiumUtilities
|
||||||
|
import org.session.libsession.utilities.Address
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.session.libsignal.utilities.IdPrefix
|
import org.session.libsignal.utilities.IdPrefix
|
||||||
import org.session.libsignal.utilities.Log
|
import org.session.libsignal.utilities.Log
|
||||||
|
import org.thoughtcrime.securesms.database.MmsSmsDatabase
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.database.Storage
|
import org.thoughtcrime.securesms.database.Storage
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||||
import org.thoughtcrime.securesms.repository.ConversationRepository
|
import org.thoughtcrime.securesms.repository.ConversationRepository
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class ConversationViewModel(
|
class ConversationViewModel(
|
||||||
@@ -31,14 +42,35 @@ class ConversationViewModel(
|
|||||||
private val storage: Storage
|
private val storage: Storage
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ConversationUiState())
|
val showSendAfterApprovalText: Boolean
|
||||||
|
get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(ConversationUiState(conversationExists = true))
|
||||||
val uiState: StateFlow<ConversationUiState> = _uiState
|
val uiState: StateFlow<ConversationUiState> = _uiState
|
||||||
|
|
||||||
val recipient: Recipient?
|
private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce {
|
||||||
get() = repository.maybeGetRecipientForThreadId(threadId)
|
repository.maybeGetRecipientForThreadId(threadId)
|
||||||
|
}
|
||||||
|
val expirationConfiguration: ExpirationConfiguration?
|
||||||
|
get() = storage.getExpirationConfiguration(threadId)
|
||||||
|
|
||||||
|
val recipient: Recipient?
|
||||||
|
get() = _recipient.value
|
||||||
|
|
||||||
|
val blindedRecipient: Recipient?
|
||||||
|
get() = _recipient.value?.let { recipient ->
|
||||||
|
when {
|
||||||
|
recipient.isOpenGroupOutboxRecipient -> recipient
|
||||||
|
recipient.isOpenGroupInboxRecipient -> repository.maybeGetBlindedRecipient(recipient)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _openGroup: RetrieveOnce<OpenGroup> = RetrieveOnce {
|
||||||
|
storage.getOpenGroup(threadId)
|
||||||
|
}
|
||||||
val openGroup: OpenGroup?
|
val openGroup: OpenGroup?
|
||||||
get() = storage.getOpenGroup(threadId)
|
get() = _openGroup.value
|
||||||
|
|
||||||
val serverCapabilities: List<String>
|
val serverCapabilities: List<String>
|
||||||
get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf()
|
get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf()
|
||||||
@@ -49,6 +81,28 @@ class ConversationViewModel(
|
|||||||
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
|
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isMessageRequestThread : Boolean
|
||||||
|
get() {
|
||||||
|
val recipient = recipient ?: return false
|
||||||
|
return !recipient.isLocalNumber && !recipient.isGroupRecipient && !recipient.isApproved
|
||||||
|
}
|
||||||
|
|
||||||
|
val canReactToMessages: Boolean
|
||||||
|
// allow reactions if the open group is null (normal conversations) or the open group's capabilities include reactions
|
||||||
|
get() = (openGroup == null || OpenGroupApi.Capability.REACTIONS.name.lowercase() in serverCapabilities)
|
||||||
|
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
repository.recipientUpdateFlow(threadId)
|
||||||
|
.collect { recipient ->
|
||||||
|
if (recipient == null && _uiState.value.conversationExists) {
|
||||||
|
_uiState.update { it.copy(conversationExists = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun saveDraft(text: String) {
|
fun saveDraft(text: String) {
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
repository.saveDraft(threadId, text)
|
repository.saveDraft(threadId, text)
|
||||||
@@ -98,9 +152,14 @@ class ConversationViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch {
|
fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch {
|
||||||
val recipient = recipient ?: return@launch
|
val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.")
|
||||||
|
|
||||||
repository.deleteForEveryone(threadId, recipient, message)
|
repository.deleteForEveryone(threadId, recipient, message)
|
||||||
|
.onSuccess {
|
||||||
|
Log.d("Loki", "Deleted message ${message.id} ")
|
||||||
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
|
Log.w("Loki", "FAILED TO delete message ${message.id} ")
|
||||||
showMessage("Couldn't delete message due to error: $it")
|
showMessage("Couldn't delete message due to error: $it")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,10 +181,15 @@ class ConversationViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun banAndDeleteAll(recipient: Recipient) = viewModelScope.launch {
|
fun banAndDeleteAll(messageRecord: MessageRecord) = viewModelScope.launch {
|
||||||
repository.banAndDeleteAll(threadId, recipient)
|
|
||||||
|
repository.banAndDeleteAll(threadId, messageRecord.individualRecipient)
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
|
// At this point the server side messages have been successfully deleted..
|
||||||
showMessage("Successfully banned user and deleted all their messages")
|
showMessage("Successfully banned user and deleted all their messages")
|
||||||
|
|
||||||
|
// ..so we can now delete all their messages in this thread from local storage & remove the views.
|
||||||
|
repository.deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord)
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
showMessage("Couldn't execute request due to error: $it")
|
showMessage("Couldn't execute request due to error: $it")
|
||||||
@@ -170,6 +234,17 @@ class ConversationViewModel(
|
|||||||
return repository.hasReceived(threadId)
|
return repository.hasReceived(threadId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateRecipient() {
|
||||||
|
_recipient.updateTo(repository.maybeGetRecipientForThreadId(threadId))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hidesInputBar(): Boolean = openGroup?.canWrite != true &&
|
||||||
|
blindedRecipient?.blocksCommunityMessageRequests == true
|
||||||
|
|
||||||
|
fun legacyBannerRecipient(context: Context): Recipient? = recipient?.run {
|
||||||
|
storage.getLastLegacyRecipient(address.serialize())?.let { Recipient.from(context, Address.fromSerialized(it), false) }
|
||||||
|
}
|
||||||
|
|
||||||
@dagger.assisted.AssistedFactory
|
@dagger.assisted.AssistedFactory
|
||||||
interface AssistedFactory {
|
interface AssistedFactory {
|
||||||
fun create(threadId: Long, edKeyPair: KeyPair?): Factory
|
fun create(threadId: Long, edKeyPair: KeyPair?): Factory
|
||||||
@@ -193,5 +268,22 @@ data class UiMessage(val id: Long, val message: String)
|
|||||||
|
|
||||||
data class ConversationUiState(
|
data class ConversationUiState(
|
||||||
val uiMessages: List<UiMessage> = emptyList(),
|
val uiMessages: List<UiMessage> = emptyList(),
|
||||||
val isMessageRequestAccepted: Boolean? = null
|
val isMessageRequestAccepted: Boolean? = null,
|
||||||
|
val conversationExists: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class RetrieveOnce<T>(val retrieval: () -> T?) {
|
||||||
|
private var triedToRetrieve: Boolean = false
|
||||||
|
private var _value: T? = null
|
||||||
|
|
||||||
|
val value: T?
|
||||||
|
get() {
|
||||||
|
if (triedToRetrieve) { return _value }
|
||||||
|
|
||||||
|
triedToRetrieve = true
|
||||||
|
_value = retrieval()
|
||||||
|
return _value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateTo(value: T?) { _value = value }
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen
|
|||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
super.onStart()
|
super.onStart()
|
||||||
val window = dialog?.window ?: return
|
val window = dialog?.window ?: return
|
||||||
val isLightMode = UiModeUtilities.isDayUiMode(requireContext())
|
window.setDimAmount(0.6f)
|
||||||
window.setDimAmount(if (isLightMode) 0.1f else 0.75f)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,98 +1,401 @@
|
|||||||
package org.thoughtcrime.securesms.conversation.v2
|
package org.thoughtcrime.securesms.conversation.v2
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.LayoutInflater
|
||||||
import androidx.core.view.isVisible
|
import android.view.MotionEvent.ACTION_UP
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.LocalTextStyle
|
||||||
|
import androidx.compose.material.Surface
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.ComposeView
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
|
||||||
|
import com.bumptech.glide.integration.compose.GlideImage
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import network.loki.messenger.databinding.ActivityMessageDetailBinding
|
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
|
||||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
|
||||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
|
||||||
import org.session.libsession.messaging.utilities.SessionId
|
|
||||||
import org.session.libsession.messaging.utilities.SodiumUtilities
|
|
||||||
import org.session.libsession.utilities.Address
|
|
||||||
import org.session.libsession.utilities.ExpirationUtil
|
|
||||||
import org.session.libsession.utilities.TextSecurePreferences
|
|
||||||
import org.session.libsignal.utilities.IdPrefix
|
|
||||||
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||||
import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities
|
|
||||||
import org.thoughtcrime.securesms.database.Storage
|
import org.thoughtcrime.securesms.database.Storage
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
import org.thoughtcrime.securesms.ui.AppTheme
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
import org.thoughtcrime.securesms.ui.Avatar
|
||||||
import org.thoughtcrime.securesms.util.DateUtils
|
import org.thoughtcrime.securesms.ui.CarouselNextButton
|
||||||
import java.text.SimpleDateFormat
|
import org.thoughtcrime.securesms.ui.CarouselPrevButton
|
||||||
import java.util.*
|
import org.thoughtcrime.securesms.ui.Cell
|
||||||
|
import org.thoughtcrime.securesms.ui.CellNoMargin
|
||||||
|
import org.thoughtcrime.securesms.ui.CellWithPaddingAndMargin
|
||||||
|
import org.thoughtcrime.securesms.ui.Divider
|
||||||
|
import org.thoughtcrime.securesms.ui.GetString
|
||||||
|
import org.thoughtcrime.securesms.ui.HorizontalPagerIndicator
|
||||||
|
import org.thoughtcrime.securesms.ui.ItemButton
|
||||||
|
import org.thoughtcrime.securesms.ui.PreviewTheme
|
||||||
|
import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider
|
||||||
|
import org.thoughtcrime.securesms.ui.TitledText
|
||||||
|
import org.thoughtcrime.securesms.ui.blackAlpha40
|
||||||
|
import org.thoughtcrime.securesms.ui.colorDestructive
|
||||||
|
import org.thoughtcrime.securesms.ui.destructiveButtonColors
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MessageDetailActivity: PassphraseRequiredActionBarActivity() {
|
class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
|
||||||
private lateinit var binding: ActivityMessageDetailBinding
|
|
||||||
var messageRecord: MessageRecord? = null
|
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var storage: Storage
|
lateinit var storage: Storage
|
||||||
|
|
||||||
// region Settings
|
private val viewModel: MessageDetailsViewModel by viewModels()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// Extras
|
// Extras
|
||||||
const val MESSAGE_TIMESTAMP = "message_timestamp"
|
const val MESSAGE_TIMESTAMP = "message_timestamp"
|
||||||
|
|
||||||
|
const val ON_REPLY = 1
|
||||||
|
const val ON_RESEND = 2
|
||||||
|
const val ON_DELETE = 3
|
||||||
}
|
}
|
||||||
// endregion
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||||
super.onCreate(savedInstanceState, ready)
|
super.onCreate(savedInstanceState, ready)
|
||||||
binding = ActivityMessageDetailBinding.inflate(layoutInflater)
|
|
||||||
setContentView(binding.root)
|
|
||||||
title = resources.getString(R.string.conversation_context__menu_message_details)
|
title = resources.getString(R.string.conversation_context__menu_message_details)
|
||||||
val timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L)
|
|
||||||
// We only show this screen for messages fail to send,
|
viewModel.timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L)
|
||||||
// so the author of the messages must be the current user.
|
|
||||||
val author = Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!)
|
ComposeView(this)
|
||||||
messageRecord = DatabaseComponent.get(this).mmsSmsDatabase().getMessageFor(timestamp, author) ?: run {
|
.apply { setContent { MessageDetailsScreen() } }
|
||||||
finish()
|
.let(::setContentView)
|
||||||
return
|
|
||||||
}
|
lifecycleScope.launch {
|
||||||
val threadId = messageRecord!!.threadId
|
viewModel.eventFlow.collect {
|
||||||
val openGroup = storage.getOpenGroup(threadId)
|
when (it) {
|
||||||
val blindedKey = openGroup?.let { group ->
|
Event.Finish -> finish()
|
||||||
val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return@let null
|
is Event.StartMediaPreview -> startActivity(
|
||||||
val blindingEnabled = storage.getServerCapabilities(group.server).contains(OpenGroupApi.Capability.BLIND.name.lowercase())
|
getPreviewIntent(this@MessageDetailActivity, it.args)
|
||||||
if (blindingEnabled) {
|
)
|
||||||
SodiumUtilities.blindedKeyPair(group.publicKey, userEdKeyPair)?.publicKey?.asBytes
|
}
|
||||||
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
|
}
|
||||||
} else null
|
|
||||||
}
|
|
||||||
updateContent()
|
|
||||||
binding.resendButton.setOnClickListener {
|
|
||||||
ResendMessageUtilities.resend(this, messageRecord!!, blindedKey)
|
|
||||||
finish()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateContent() {
|
@Composable
|
||||||
val dateLocale = Locale.getDefault()
|
private fun MessageDetailsScreen() {
|
||||||
val dateFormatter: SimpleDateFormat = DateUtils.getDetailedDateFormatter(this, dateLocale)
|
val state by viewModel.stateFlow.collectAsState()
|
||||||
binding.sentTime.text = dateFormatter.format(Date(messageRecord!!.dateSent))
|
AppTheme {
|
||||||
|
MessageDetails(
|
||||||
val errorMessage = DatabaseComponent.get(this).lokiMessageDatabase().getErrorMessage(messageRecord!!.getId())
|
state = state,
|
||||||
if (errorMessage != null) {
|
onReply = { setResultAndFinish(ON_REPLY) },
|
||||||
binding.errorMessage.text = errorMessage
|
onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } },
|
||||||
binding.resendContainer.isVisible = true
|
onDelete = { setResultAndFinish(ON_DELETE) },
|
||||||
binding.errorContainer.isVisible = true
|
onClickImage = { viewModel.onClickImage(it) },
|
||||||
} else {
|
onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload,
|
||||||
binding.errorContainer.isVisible = false
|
)
|
||||||
binding.resendContainer.isVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageRecord!!.expiresIn <= 0 || messageRecord!!.expireStarted <= 0) {
|
|
||||||
binding.expiresContainer.visibility = View.GONE
|
|
||||||
} else {
|
|
||||||
binding.expiresContainer.visibility = View.VISIBLE
|
|
||||||
val elapsed = System.currentTimeMillis() - messageRecord!!.expireStarted
|
|
||||||
val remaining = messageRecord!!.expiresIn - elapsed
|
|
||||||
|
|
||||||
val duration = ExpirationUtil.getExpirationDisplayValue(this, Math.max((remaining / 1000).toInt(), 1))
|
|
||||||
binding.expiresIn.text = duration
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private fun setResultAndFinish(code: Int) {
|
||||||
|
Bundle().apply { putLong(MESSAGE_TIMESTAMP, viewModel.timestamp) }
|
||||||
|
.let(Intent()::putExtras)
|
||||||
|
.let { setResult(code, it) }
|
||||||
|
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
|
@Composable
|
||||||
|
fun MessageDetails(
|
||||||
|
state: MessageDetailsState,
|
||||||
|
onReply: () -> Unit = {},
|
||||||
|
onResend: (() -> Unit)? = null,
|
||||||
|
onDelete: () -> Unit = {},
|
||||||
|
onClickImage: (Int) -> Unit = {},
|
||||||
|
onAttachmentNeedsDownload: (Long, Long) -> Unit = { _, _ -> }
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
state.record?.let { message ->
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.padding(horizontal = 32.dp),
|
||||||
|
factory = {
|
||||||
|
ViewVisibleMessageContentBinding.inflate(LayoutInflater.from(it)).mainContainerConstraint.apply {
|
||||||
|
bind(
|
||||||
|
message,
|
||||||
|
thread = state.thread!!,
|
||||||
|
onAttachmentNeedsDownload = onAttachmentNeedsDownload,
|
||||||
|
suppressThumbnails = true
|
||||||
|
)
|
||||||
|
|
||||||
|
setOnTouchListener { _, event ->
|
||||||
|
if (event.actionMasked == ACTION_UP) onContentClick(event)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Carousel(state.imageAttachments) { onClickImage(it) }
|
||||||
|
state.nonImageAttachmentFileDetails?.let { FileDetails(it) }
|
||||||
|
CellMetadata(state)
|
||||||
|
CellButtons(
|
||||||
|
onReply,
|
||||||
|
onResend,
|
||||||
|
onDelete,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CellMetadata(
|
||||||
|
state: MessageDetailsState,
|
||||||
|
) {
|
||||||
|
state.apply {
|
||||||
|
if (listOfNotNull(sent, received, error, senderInfo).isEmpty()) return
|
||||||
|
CellWithPaddingAndMargin {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
TitledText(sent)
|
||||||
|
TitledText(received)
|
||||||
|
TitledErrorText(error)
|
||||||
|
senderInfo?.let {
|
||||||
|
TitledView(state.fromTitle) {
|
||||||
|
Row {
|
||||||
|
sender?.let { Avatar(it) }
|
||||||
|
TitledMonospaceText(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CellButtons(
|
||||||
|
onReply: () -> Unit = {},
|
||||||
|
onResend: (() -> Unit)? = null,
|
||||||
|
onDelete: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
Cell {
|
||||||
|
Column {
|
||||||
|
ItemButton(
|
||||||
|
stringResource(R.string.reply),
|
||||||
|
R.drawable.ic_message_details__reply,
|
||||||
|
onClick = onReply
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
onResend?.let {
|
||||||
|
ItemButton(
|
||||||
|
stringResource(R.string.resend),
|
||||||
|
R.drawable.ic_message_details__refresh,
|
||||||
|
onClick = it
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
ItemButton(
|
||||||
|
stringResource(R.string.delete),
|
||||||
|
R.drawable.ic_message_details__trash,
|
||||||
|
colors = destructiveButtonColors(),
|
||||||
|
onClick = onDelete
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun Carousel(attachments: List<Attachment>, onClick: (Int) -> Unit) {
|
||||||
|
if (attachments.isEmpty()) return
|
||||||
|
|
||||||
|
val pagerState = rememberPagerState { attachments.size }
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Row {
|
||||||
|
CarouselPrevButton(pagerState)
|
||||||
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
|
CellCarousel(pagerState, attachments, onClick)
|
||||||
|
HorizontalPagerIndicator(pagerState)
|
||||||
|
ExpandButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(8.dp)
|
||||||
|
) { onClick(pagerState.currentPage) }
|
||||||
|
}
|
||||||
|
CarouselNextButton(pagerState)
|
||||||
|
}
|
||||||
|
attachments.getOrNull(pagerState.currentPage)?.fileDetails?.let { FileDetails(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(
|
||||||
|
ExperimentalFoundationApi::class,
|
||||||
|
ExperimentalGlideComposeApi::class
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
private fun CellCarousel(
|
||||||
|
pagerState: PagerState,
|
||||||
|
attachments: List<Attachment>,
|
||||||
|
onClick: (Int) -> Unit
|
||||||
|
) {
|
||||||
|
CellNoMargin {
|
||||||
|
HorizontalPager(state = pagerState) { i ->
|
||||||
|
GlideImage(
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.clickable { onClick(i) },
|
||||||
|
model = attachments[i].uri,
|
||||||
|
contentDescription = attachments[i].fileName ?: stringResource(id = R.string.image)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ExpandButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = blackAlpha40,
|
||||||
|
modifier = modifier,
|
||||||
|
contentColor = Color.White,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_expand),
|
||||||
|
contentDescription = stringResource(id = R.string.expand),
|
||||||
|
modifier = Modifier.clickable { onClick() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun PreviewMessageDetails(
|
||||||
|
@PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int
|
||||||
|
) {
|
||||||
|
PreviewTheme(themeResId) {
|
||||||
|
MessageDetails(
|
||||||
|
state = MessageDetailsState(
|
||||||
|
nonImageAttachmentFileDetails = listOf(
|
||||||
|
TitledText(R.string.message_details_header__file_id, "Screen Shot 2023-07-06 at 11.35.50 am.png"),
|
||||||
|
TitledText(R.string.message_details_header__file_type, "image/png"),
|
||||||
|
TitledText(R.string.message_details_header__file_size, "195.6kB"),
|
||||||
|
TitledText(R.string.message_details_header__resolution, "342x312"),
|
||||||
|
),
|
||||||
|
sent = TitledText(R.string.message_details_header__sent, "6:12 AM Tue, 09/08/2022"),
|
||||||
|
received = TitledText(R.string.message_details_header__received, "6:12 AM Tue, 09/08/2022"),
|
||||||
|
error = TitledText(R.string.message_details_header__error, "Message failed to send"),
|
||||||
|
senderInfo = TitledText("Connor", "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun FileDetails(fileDetails: List<TitledText>) {
|
||||||
|
if (fileDetails.isEmpty()) return
|
||||||
|
|
||||||
|
CellWithPaddingAndMargin(padding = 0.dp) {
|
||||||
|
FlowRow(
|
||||||
|
modifier = Modifier.padding(vertical = 24.dp, horizontal = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
fileDetails.forEach {
|
||||||
|
BoxWithConstraints {
|
||||||
|
TitledText(
|
||||||
|
it,
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(min = maxWidth.div(2))
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.width(IntrinsicSize.Max)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TitledErrorText(titledText: TitledText?) {
|
||||||
|
TitledText(
|
||||||
|
titledText,
|
||||||
|
valueStyle = LocalTextStyle.current.copy(color = colorDestructive)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TitledMonospaceText(titledText: TitledText?) {
|
||||||
|
TitledText(
|
||||||
|
titledText,
|
||||||
|
valueStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TitledText(
|
||||||
|
titledText: TitledText?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
valueStyle: TextStyle = LocalTextStyle.current,
|
||||||
|
) {
|
||||||
|
titledText?.apply {
|
||||||
|
TitledView(title, modifier) {
|
||||||
|
Text(text, style = valueStyle, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TitledView(title: GetString, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||||
|
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Title(title)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Title(title: GetString) {
|
||||||
|
Text(title.string(), fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package org.thoughtcrime.securesms.conversation.v2
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import network.loki.messenger.R
|
||||||
|
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
||||||
|
import org.session.libsession.messaging.jobs.JobQueue
|
||||||
|
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
|
||||||
|
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||||
|
import org.session.libsession.utilities.Util
|
||||||
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
|
import org.thoughtcrime.securesms.MediaPreviewArgs
|
||||||
|
import org.thoughtcrime.securesms.database.AttachmentDatabase
|
||||||
|
import org.thoughtcrime.securesms.database.LokiMessageDatabase
|
||||||
|
import org.thoughtcrime.securesms.database.MmsSmsDatabase
|
||||||
|
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||||
|
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||||
|
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||||
|
import org.thoughtcrime.securesms.mms.ImageSlide
|
||||||
|
import org.thoughtcrime.securesms.mms.Slide
|
||||||
|
import org.thoughtcrime.securesms.repository.ConversationRepository
|
||||||
|
import org.thoughtcrime.securesms.ui.GetString
|
||||||
|
import org.thoughtcrime.securesms.ui.TitledText
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class MessageDetailsViewModel @Inject constructor(
|
||||||
|
private val attachmentDb: AttachmentDatabase,
|
||||||
|
private val lokiMessageDatabase: LokiMessageDatabase,
|
||||||
|
private val mmsSmsDatabase: MmsSmsDatabase,
|
||||||
|
private val threadDb: ThreadDatabase,
|
||||||
|
private val repository: ConversationRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
private val state = MutableStateFlow(MessageDetailsState())
|
||||||
|
val stateFlow = state.asStateFlow()
|
||||||
|
|
||||||
|
private val event = Channel<Event>()
|
||||||
|
val eventFlow = event.receiveAsFlow()
|
||||||
|
|
||||||
|
var timestamp: Long = 0L
|
||||||
|
set(value) {
|
||||||
|
job?.cancel()
|
||||||
|
|
||||||
|
field = value
|
||||||
|
val record = mmsSmsDatabase.getMessageForTimestamp(timestamp)
|
||||||
|
|
||||||
|
if (record == null) {
|
||||||
|
viewModelScope.launch { event.send(Event.Finish) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val mmsRecord = record as? MmsMessageRecord
|
||||||
|
|
||||||
|
job = viewModelScope.launch {
|
||||||
|
repository.changes(record.threadId)
|
||||||
|
.filter { mmsSmsDatabase.getMessageForTimestamp(value) == null }
|
||||||
|
.collect { event.send(Event.Finish) }
|
||||||
|
}
|
||||||
|
|
||||||
|
state.value = record.run {
|
||||||
|
val slides = mmsRecord?.slideDeck?.slides ?: emptyList()
|
||||||
|
|
||||||
|
MessageDetailsState(
|
||||||
|
attachments = slides.map(::Attachment),
|
||||||
|
record = record,
|
||||||
|
sent = dateSent.let(::Date).toString().let { TitledText(R.string.message_details_header__sent, it) },
|
||||||
|
received = dateReceived.let(::Date).toString().let { TitledText(R.string.message_details_header__received, it) },
|
||||||
|
error = lokiMessageDatabase.getErrorMessage(id)?.let { TitledText(R.string.message_details_header__error, it) },
|
||||||
|
senderInfo = individualRecipient.run { name?.let { TitledText(it, address.serialize()) } },
|
||||||
|
sender = individualRecipient,
|
||||||
|
thread = threadDb.getRecipientForThreadId(threadId)!!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Slide.details: List<TitledText>
|
||||||
|
get() = listOfNotNull(
|
||||||
|
fileName.orNull()?.let { TitledText(R.string.message_details_header__file_id, it) },
|
||||||
|
TitledText(R.string.message_details_header__file_type, asAttachment().contentType),
|
||||||
|
TitledText(R.string.message_details_header__file_size, Util.getPrettyFileSize(fileSize)),
|
||||||
|
takeIf { it is ImageSlide }
|
||||||
|
?.let(Slide::asAttachment)
|
||||||
|
?.run { "${width}x$height" }
|
||||||
|
?.let { TitledText(R.string.message_details_header__resolution, it) },
|
||||||
|
attachmentDb.duration(this)?.let { TitledText(R.string.message_details_header__duration, it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun AttachmentDatabase.duration(slide: Slide): String? =
|
||||||
|
slide.takeIf { it.hasAudio() }
|
||||||
|
?.run { asAttachment() as? DatabaseAttachment }
|
||||||
|
?.run { getAttachmentAudioExtras(attachmentId)?.durationMs }
|
||||||
|
?.takeIf { it > 0 }
|
||||||
|
?.let {
|
||||||
|
String.format(
|
||||||
|
"%01d:%02d",
|
||||||
|
TimeUnit.MILLISECONDS.toMinutes(it),
|
||||||
|
TimeUnit.MILLISECONDS.toSeconds(it) % 60
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Attachment(slide: Slide): Attachment =
|
||||||
|
Attachment(slide.details, slide.fileName.orNull(), slide.uri, slide is ImageSlide)
|
||||||
|
|
||||||
|
fun onClickImage(index: Int) {
|
||||||
|
val state = state.value ?: return
|
||||||
|
val mmsRecord = state.mmsRecord ?: return
|
||||||
|
val slide = mmsRecord.slideDeck.slides[index] ?: return
|
||||||
|
// only open to downloaded images
|
||||||
|
if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) {
|
||||||
|
// Restart download here (on IO thread)
|
||||||
|
(slide.asAttachment() as? DatabaseAttachment)?.let { attachment ->
|
||||||
|
onAttachmentNeedsDownload(attachment.attachmentId.rowId, state.mmsRecord.getId())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slide.isInProgress) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
MediaPreviewArgs(slide, state.mmsRecord, state.thread)
|
||||||
|
.let(Event::StartMediaPreview)
|
||||||
|
.let { event.send(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onAttachmentNeedsDownload(attachmentId: Long, mmsId: Long) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MessageDetailsState(
|
||||||
|
val attachments: List<Attachment> = emptyList(),
|
||||||
|
val imageAttachments: List<Attachment> = attachments.filter { it.hasImage },
|
||||||
|
val nonImageAttachmentFileDetails: List<TitledText>? = attachments.firstOrNull { !it.hasImage }?.fileDetails,
|
||||||
|
val record: MessageRecord? = null,
|
||||||
|
val mmsRecord: MmsMessageRecord? = record as? MmsMessageRecord,
|
||||||
|
val sent: TitledText? = null,
|
||||||
|
val received: TitledText? = null,
|
||||||
|
val error: TitledText? = null,
|
||||||
|
val senderInfo: TitledText? = null,
|
||||||
|
val sender: Recipient? = null,
|
||||||
|
val thread: Recipient? = null,
|
||||||
|
) {
|
||||||
|
val fromTitle = GetString(R.string.message_details_header__from)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Attachment(
|
||||||
|
val fileDetails: List<TitledText>,
|
||||||
|
val fileName: String?,
|
||||||
|
val uri: Uri?,
|
||||||
|
val hasImage: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class Event {
|
||||||
|
object Finish: Event()
|
||||||
|
data class StartMediaPreview(val args: MediaPreviewArgs): Event()
|
||||||
|
}
|
||||||
@@ -60,8 +60,7 @@ class ModalUrlBottomSheet(private val url: String): BottomSheetDialogFragment(),
|
|||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
super.onStart()
|
super.onStart()
|
||||||
val window = dialog?.window ?: return
|
val window = dialog?.window ?: return
|
||||||
val isLightMode = UiModeUtilities.isDayUiMode(requireContext())
|
window.setDimAmount(0.6f)
|
||||||
window.setDimAmount(if (isLightMode) 0.1f else 0.75f)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClick(v: View?) {
|
override fun onClick(v: View?) {
|
||||||
|
|||||||
@@ -38,14 +38,10 @@ public final class WindowUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void setNavigationBarColor(@NonNull Window window, @ColorInt int color) {
|
public static void setNavigationBarColor(@NonNull Window window, @ColorInt int color) {
|
||||||
if (Build.VERSION.SDK_INT < 21) return;
|
|
||||||
|
|
||||||
window.setNavigationBarColor(color);
|
window.setNavigationBarColor(color);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void setLightStatusBarFromTheme(@NonNull Activity activity) {
|
public static void setLightStatusBarFromTheme(@NonNull Activity activity) {
|
||||||
if (Build.VERSION.SDK_INT < 23) return;
|
|
||||||
|
|
||||||
final boolean isLightStatusBar = ThemeUtil.getThemedBoolean(activity, android.R.attr.windowLightStatusBar);
|
final boolean isLightStatusBar = ThemeUtil.getThemedBoolean(activity, android.R.attr.windowLightStatusBar);
|
||||||
|
|
||||||
if (isLightStatusBar) setLightStatusBar(activity.getWindow());
|
if (isLightStatusBar) setLightStatusBar(activity.getWindow());
|
||||||
@@ -53,20 +49,14 @@ public final class WindowUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void clearLightStatusBar(@NonNull Window window) {
|
public static void clearLightStatusBar(@NonNull Window window) {
|
||||||
if (Build.VERSION.SDK_INT < 23) return;
|
|
||||||
|
|
||||||
clearSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
|
clearSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void setLightStatusBar(@NonNull Window window) {
|
public static void setLightStatusBar(@NonNull Window window) {
|
||||||
if (Build.VERSION.SDK_INT < 23) return;
|
|
||||||
|
|
||||||
setSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
|
setSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void setStatusBarColor(@NonNull Window window, @ColorInt int color) {
|
public static void setStatusBarColor(@NonNull Window window, @ColorInt int color) {
|
||||||
if (Build.VERSION.SDK_INT < 21) return;
|
|
||||||
|
|
||||||
window.setStatusBarColor(color);
|
window.setStatusBarColor(color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import android.util.AttributeSet
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
|
||||||
import android.widget.RelativeLayout
|
import android.widget.RelativeLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
@@ -41,7 +40,7 @@ class AlbumThumbnailView : RelativeLayout {
|
|||||||
private var slides: List<Slide> = listOf()
|
private var slides: List<Slide> = listOf()
|
||||||
private var slideSize: Int = 0
|
private var slideSize: Int = 0
|
||||||
|
|
||||||
override fun dispatchDraw(canvas: Canvas?) {
|
override fun dispatchDraw(canvas: Canvas) {
|
||||||
super.dispatchDraw(canvas)
|
super.dispatchDraw(canvas)
|
||||||
cornerMask.mask(canvas)
|
cornerMask.mask(canvas)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,11 +28,10 @@ class MentionCandidateView : LinearLayout {
|
|||||||
|
|
||||||
private fun update() = with(binding) {
|
private fun update() = with(binding) {
|
||||||
mentionCandidateNameTextView.text = mentionCandidate.displayName
|
mentionCandidateNameTextView.text = mentionCandidate.displayName
|
||||||
profilePictureView.root.publicKey = mentionCandidate.publicKey
|
profilePictureView.publicKey = mentionCandidate.publicKey
|
||||||
profilePictureView.root.displayName = mentionCandidate.displayName
|
profilePictureView.displayName = mentionCandidate.displayName
|
||||||
profilePictureView.root.additionalPublicKey = null
|
profilePictureView.additionalPublicKey = null
|
||||||
profilePictureView.root.glide = glide!!
|
profilePictureView.update()
|
||||||
profilePictureView.root.update()
|
|
||||||
if (openGroupServer != null && openGroupRoom != null) {
|
if (openGroupServer != null && openGroupRoom != null) {
|
||||||
val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", mentionCandidate.publicKey)
|
val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", mentionCandidate.publicKey)
|
||||||
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE
|
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE
|
||||||
|
|||||||
@@ -1,41 +1,42 @@
|
|||||||
package org.thoughtcrime.securesms.conversation.v2.dialogs
|
package org.thoughtcrime.securesms.conversation.v2.dialogs
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.Context
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
|
import android.os.Bundle
|
||||||
import android.text.Spannable
|
import android.text.Spannable
|
||||||
import android.text.SpannableStringBuilder
|
import android.text.SpannableStringBuilder
|
||||||
import android.text.style.StyleSpan
|
import android.text.style.StyleSpan
|
||||||
import android.view.LayoutInflater
|
import androidx.fragment.app.DialogFragment
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import network.loki.messenger.databinding.DialogBlockedBinding
|
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||||
import org.session.libsession.messaging.contacts.Contact
|
import org.session.libsession.messaging.contacts.Contact
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
|
import org.thoughtcrime.securesms.createSessionDialog
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||||
|
|
||||||
/** Shown upon sending a message to a user that's blocked. */
|
/** Shown upon sending a message to a user that's blocked. */
|
||||||
class BlockedDialog(private val recipient: Recipient) : BaseDialog() {
|
class BlockedDialog(private val recipient: Recipient, private val context: Context) : DialogFragment() {
|
||||||
|
|
||||||
override fun setContentView(builder: AlertDialog.Builder) {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
|
||||||
val binding = DialogBlockedBinding.inflate(LayoutInflater.from(requireContext()))
|
|
||||||
val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase()
|
val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase()
|
||||||
val sessionID = recipient.address.toString()
|
val sessionID = recipient.address.toString()
|
||||||
val contact = contactDB.getContactWithSessionID(sessionID)
|
val contact = contactDB.getContactWithSessionID(sessionID)
|
||||||
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
|
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
|
||||||
val title = resources.getString(R.string.dialog_blocked_title, name)
|
|
||||||
binding.blockedTitleTextView.text = title
|
|
||||||
val explanation = resources.getString(R.string.dialog_blocked_explanation, name)
|
val explanation = resources.getString(R.string.dialog_blocked_explanation, name)
|
||||||
val spannable = SpannableStringBuilder(explanation)
|
val spannable = SpannableStringBuilder(explanation)
|
||||||
val startIndex = explanation.indexOf(name)
|
val startIndex = explanation.indexOf(name)
|
||||||
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
binding.blockedExplanationTextView.text = spannable
|
|
||||||
binding.cancelButton.setOnClickListener { dismiss() }
|
title(resources.getString(R.string.dialog_blocked_title, name))
|
||||||
binding.unblockButton.setOnClickListener { unblock() }
|
text(spannable)
|
||||||
builder.setView(binding.root)
|
button(R.string.ConversationActivity_unblock) { unblock() }
|
||||||
|
cancelButton { dismiss() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun unblock() {
|
private fun unblock() {
|
||||||
DatabaseComponent.get(requireContext()).recipientDatabase().setBlocked(recipient, false)
|
MessagingModuleConfiguration.shared.storage.setBlocked(listOf(recipient), false)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
package org.thoughtcrime.securesms.conversation.v2.dialogs
|
package org.thoughtcrime.securesms.conversation.v2.dialogs
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
|
import android.os.Bundle
|
||||||
import android.text.Spannable
|
import android.text.Spannable
|
||||||
import android.text.SpannableStringBuilder
|
import android.text.SpannableStringBuilder
|
||||||
import android.text.style.StyleSpan
|
import android.text.style.StyleSpan
|
||||||
import android.view.LayoutInflater
|
import androidx.fragment.app.DialogFragment
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import network.loki.messenger.databinding.DialogDownloadBinding
|
|
||||||
import org.session.libsession.messaging.contacts.Contact
|
import org.session.libsession.messaging.contacts.Contact
|
||||||
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
||||||
import org.session.libsession.messaging.jobs.JobQueue
|
import org.session.libsession.messaging.jobs.JobQueue
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
|
import org.thoughtcrime.securesms.createSessionDialog
|
||||||
import org.thoughtcrime.securesms.database.SessionContactDatabase
|
import org.thoughtcrime.securesms.database.SessionContactDatabase
|
||||||
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -21,25 +21,24 @@ import javax.inject.Inject
|
|||||||
/** Shown when receiving media from a contact for the first time, to confirm that
|
/** Shown when receiving media from a contact for the first time, to confirm that
|
||||||
* they are to be trusted and files sent by them are to be downloaded. */
|
* they are to be trusted and files sent by them are to be downloaded. */
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class DownloadDialog(private val recipient: Recipient) : BaseDialog() {
|
class DownloadDialog(private val recipient: Recipient) : DialogFragment() {
|
||||||
|
|
||||||
@Inject lateinit var contactDB: SessionContactDatabase
|
@Inject lateinit var contactDB: SessionContactDatabase
|
||||||
|
|
||||||
override fun setContentView(builder: AlertDialog.Builder) {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
|
||||||
val binding = DialogDownloadBinding.inflate(LayoutInflater.from(requireContext()))
|
|
||||||
val sessionID = recipient.address.toString()
|
val sessionID = recipient.address.toString()
|
||||||
val contact = contactDB.getContactWithSessionID(sessionID)
|
val contact = contactDB.getContactWithSessionID(sessionID)
|
||||||
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
|
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
|
||||||
val title = resources.getString(R.string.dialog_download_title, name)
|
title(resources.getString(R.string.dialog_download_title, name))
|
||||||
binding.downloadTitleTextView.text = title
|
|
||||||
val explanation = resources.getString(R.string.dialog_download_explanation, name)
|
val explanation = resources.getString(R.string.dialog_download_explanation, name)
|
||||||
val spannable = SpannableStringBuilder(explanation)
|
val spannable = SpannableStringBuilder(explanation)
|
||||||
val startIndex = explanation.indexOf(name)
|
val startIndex = explanation.indexOf(name)
|
||||||
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
binding.downloadExplanationTextView.text = spannable
|
text(spannable)
|
||||||
binding.cancelButton.setOnClickListener { dismiss() }
|
|
||||||
binding.downloadButton.setOnClickListener { trust() }
|
button(R.string.dialog_download_button_title, R.string.AccessibilityId_download_media) { trust() }
|
||||||
builder.setView(binding.root)
|
cancelButton { dismiss() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun trust() {
|
private fun trust() {
|
||||||
@@ -50,4 +49,4 @@ class DownloadDialog(private val recipient: Recipient) : BaseDialog() {
|
|||||||
JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY)
|
JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,42 @@
|
|||||||
package org.thoughtcrime.securesms.conversation.v2.dialogs
|
package org.thoughtcrime.securesms.conversation.v2.dialogs
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
|
import android.os.Bundle
|
||||||
import android.text.Spannable
|
import android.text.Spannable
|
||||||
import android.text.SpannableStringBuilder
|
import android.text.SpannableStringBuilder
|
||||||
import android.text.style.StyleSpan
|
import android.text.style.StyleSpan
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.fragment.app.DialogFragment
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import network.loki.messenger.databinding.DialogJoinOpenGroupBinding
|
|
||||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||||
import org.session.libsession.utilities.OpenGroupUrlParser
|
import org.session.libsession.utilities.OpenGroupUrlParser
|
||||||
import org.session.libsignal.utilities.ThreadUtils
|
import org.session.libsignal.utilities.ThreadUtils
|
||||||
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
|
import org.thoughtcrime.securesms.createSessionDialog
|
||||||
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
||||||
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
|
||||||
|
|
||||||
/** Shown upon tapping an open group invitation. */
|
/** Shown upon tapping an open group invitation. */
|
||||||
class JoinOpenGroupDialog(private val name: String, private val url: String) : BaseDialog() {
|
class JoinOpenGroupDialog(private val name: String, private val url: String) : DialogFragment() {
|
||||||
|
|
||||||
override fun setContentView(builder: AlertDialog.Builder) {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
|
||||||
val binding = DialogJoinOpenGroupBinding.inflate(LayoutInflater.from(requireContext()))
|
title(resources.getString(R.string.dialog_join_open_group_title, name))
|
||||||
val title = resources.getString(R.string.dialog_join_open_group_title, name)
|
|
||||||
binding.joinOpenGroupTitleTextView.text = title
|
|
||||||
val explanation = resources.getString(R.string.dialog_join_open_group_explanation, name)
|
val explanation = resources.getString(R.string.dialog_join_open_group_explanation, name)
|
||||||
val spannable = SpannableStringBuilder(explanation)
|
val spannable = SpannableStringBuilder(explanation)
|
||||||
val startIndex = explanation.indexOf(name)
|
val startIndex = explanation.indexOf(name)
|
||||||
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
binding.joinOpenGroupExplanationTextView.text = spannable
|
text(spannable)
|
||||||
binding.cancelButton.setOnClickListener { dismiss() }
|
cancelButton { dismiss() }
|
||||||
binding.joinButton.setOnClickListener { join() }
|
button(R.string.open_group_invitation_view__join_accessibility_description) { join() }
|
||||||
builder.setView(binding.root)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun join() {
|
private fun join() {
|
||||||
val openGroup = OpenGroupUrlParser.parseUrl(url)
|
val openGroup = OpenGroupUrlParser.parseUrl(url)
|
||||||
val activity = requireContext() as AppCompatActivity
|
val activity = requireActivity()
|
||||||
ThreadUtils.queue {
|
ThreadUtils.queue {
|
||||||
try {
|
try {
|
||||||
OpenGroupManager.add(openGroup.server, openGroup.room, openGroup.serverPublicKey, activity)
|
openGroup.apply { OpenGroupManager.add(server, room, serverPublicKey, activity) }
|
||||||
MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server)
|
MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server, openGroup.room)
|
||||||
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(activity)
|
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(activity)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Toast.makeText(activity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show()
|
Toast.makeText(activity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show()
|
||||||
@@ -48,4 +44,4 @@ class JoinOpenGroupDialog(private val name: String, private val url: String) : B
|
|||||||
}
|
}
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
package org.thoughtcrime.securesms.conversation.v2.dialogs
|
package org.thoughtcrime.securesms.conversation.v2.dialogs
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import android.app.Dialog
|
||||||
import androidx.appcompat.app.AlertDialog
|
import android.os.Bundle
|
||||||
import network.loki.messenger.databinding.DialogLinkPreviewBinding
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import network.loki.messenger.R
|
||||||
import org.session.libsession.utilities.TextSecurePreferences
|
import org.session.libsession.utilities.TextSecurePreferences
|
||||||
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
|
import org.thoughtcrime.securesms.createSessionDialog
|
||||||
|
|
||||||
/** Shown the first time the user inputs a URL that could generate a link preview, to
|
/** Shown the first time the user inputs a URL that could generate a link preview, to
|
||||||
* let them know that Session offers the ability to send and receive link previews. */
|
* let them know that Session offers the ability to send and receive link previews. */
|
||||||
class LinkPreviewDialog(private val onEnabled: () -> Unit) : BaseDialog() {
|
class LinkPreviewDialog(private val onEnabled: () -> Unit) : DialogFragment() {
|
||||||
|
|
||||||
override fun setContentView(builder: AlertDialog.Builder) {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
|
||||||
val binding = DialogLinkPreviewBinding.inflate(LayoutInflater.from(requireContext()))
|
title(R.string.dialog_link_preview_title)
|
||||||
binding.cancelButton.setOnClickListener { dismiss() }
|
text(R.string.dialog_link_preview_explanation)
|
||||||
binding.enableLinkPreviewsButton.setOnClickListener { enable() }
|
button(R.string.dialog_link_preview_enable_button_title) { enable() }
|
||||||
builder.setView(binding.root)
|
cancelButton { dismiss() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enable() {
|
private fun enable() {
|
||||||
@@ -22,4 +23,4 @@ class LinkPreviewDialog(private val onEnabled: () -> Unit) : BaseDialog() {
|
|||||||
dismiss()
|
dismiss()
|
||||||
onEnabled()
|
onEnabled()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
package org.thoughtcrime.securesms.conversation.v2.dialogs
|
package org.thoughtcrime.securesms.conversation.v2.dialogs
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import android.app.Dialog
|
||||||
import androidx.appcompat.app.AlertDialog
|
import android.os.Bundle
|
||||||
import network.loki.messenger.databinding.DialogSendSeedBinding
|
import androidx.fragment.app.DialogFragment
|
||||||
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
|
import network.loki.messenger.R
|
||||||
|
import org.thoughtcrime.securesms.createSessionDialog
|
||||||
|
|
||||||
/** Shown if the user is about to send their recovery phrase to someone. */
|
/** Shown if the user is about to send their recovery phrase to someone. */
|
||||||
class SendSeedDialog(private val proceed: (() -> Unit)? = null) : BaseDialog() {
|
class SendSeedDialog(private val proceed: (() -> Unit)? = null) : DialogFragment() {
|
||||||
|
|
||||||
override fun setContentView(builder: AlertDialog.Builder) {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
|
||||||
val binding = DialogSendSeedBinding.inflate(LayoutInflater.from(requireContext()))
|
title(R.string.dialog_send_seed_title)
|
||||||
binding.cancelButton.setOnClickListener { dismiss() }
|
text(R.string.dialog_send_seed_explanation)
|
||||||
binding.sendSeedButton.setOnClickListener { send() }
|
button(R.string.dialog_send_seed_send_button_title) { send() }
|
||||||
builder.setView(binding.root)
|
cancelButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun send() {
|
private fun send() {
|
||||||
proceed?.invoke()
|
proceed?.invoke()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
|
|||||||
private val vMargin by lazy { toDp(4, resources) }
|
private val vMargin by lazy { toDp(4, resources) }
|
||||||
private val minHeight by lazy { toPx(56, resources) }
|
private val minHeight by lazy { toPx(56, resources) }
|
||||||
private var linkPreviewDraftView: LinkPreviewDraftView? = null
|
private var linkPreviewDraftView: LinkPreviewDraftView? = null
|
||||||
|
private var quoteView: QuoteView? = null
|
||||||
var delegate: InputBarDelegate? = null
|
var delegate: InputBarDelegate? = null
|
||||||
var additionalContentHeight = 0
|
var additionalContentHeight = 0
|
||||||
var quote: MessageRecord? = null
|
var quote: MessageRecord? = null
|
||||||
@@ -57,9 +58,9 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
|
|||||||
val attachmentButtonsContainerHeight: Int
|
val attachmentButtonsContainerHeight: Int
|
||||||
get() = binding.attachmentsButtonContainer.height
|
get() = binding.attachmentsButtonContainer.height
|
||||||
|
|
||||||
private val attachmentsButton by lazy { InputBarButton(context, R.drawable.ic_plus_24) }
|
private val attachmentsButton by lazy { InputBarButton(context, R.drawable.ic_plus_24).apply { contentDescription = context.getString(R.string.AccessibilityId_attachments_button)} }
|
||||||
private val microphoneButton by lazy { InputBarButton(context, R.drawable.ic_microphone) }
|
private val microphoneButton by lazy { InputBarButton(context, R.drawable.ic_microphone).apply { contentDescription = context.getString(R.string.AccessibilityId_microphone_button)} }
|
||||||
private val sendButton by lazy { InputBarButton(context, R.drawable.ic_arrow_up, true) }
|
private val sendButton by lazy { InputBarButton(context, R.drawable.ic_arrow_up, true).apply { contentDescription = context.getString(R.string.AccessibilityId_send_message_button)} }
|
||||||
|
|
||||||
// region Lifecycle
|
// region Lifecycle
|
||||||
constructor(context: Context) : super(context) { initialize() }
|
constructor(context: Context) : super(context) { initialize() }
|
||||||
@@ -98,7 +99,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
|
|||||||
binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_NONE
|
binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_NONE
|
||||||
binding.inputBarEditText.inputType =
|
binding.inputBarEditText.inputType =
|
||||||
binding.inputBarEditText.inputType or
|
binding.inputBarEditText.inputType or
|
||||||
InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
|
InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
|
||||||
}
|
}
|
||||||
val incognitoFlag = if (TextSecurePreferences.isIncognitoKeyboardEnabled(context)) 16777216 else 0
|
val incognitoFlag = if (TextSecurePreferences.isIncognitoKeyboardEnabled(context)) 16777216 else 0
|
||||||
binding.inputBarEditText.imeOptions = binding.inputBarEditText.imeOptions or incognitoFlag // Always use incognito keyboard if setting enabled
|
binding.inputBarEditText.imeOptions = binding.inputBarEditText.imeOptions or incognitoFlag // Always use incognito keyboard if setting enabled
|
||||||
@@ -138,53 +139,64 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
|
|||||||
delegate?.startRecordingVoiceMessage()
|
delegate?.startRecordingVoiceMessage()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drafting quotes and drafting link previews is mutually exclusive, i.e. you can't draft
|
|
||||||
// a quote and a link preview at the same time.
|
|
||||||
|
|
||||||
fun draftQuote(thread: Recipient, message: MessageRecord, glide: GlideRequests) {
|
fun draftQuote(thread: Recipient, message: MessageRecord, glide: GlideRequests) {
|
||||||
quote = message
|
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 layout = LayoutInflater.from(context).inflate(R.layout.view_quote_draft, binding.inputBarAdditionalContentContainer, false)
|
||||||
val quoteView = layout.findViewById<QuoteView>(R.id.mainQuoteViewContainer)
|
quoteView = layout.findViewById<QuoteView>(R.id.mainQuoteViewContainer).also {
|
||||||
quoteView.delegate = this
|
it.delegate = this
|
||||||
binding.inputBarAdditionalContentContainer.addView(layout)
|
binding.inputBarAdditionalContentContainer.addView(layout)
|
||||||
val attachments = (message as? MmsMessageRecord)?.slideDeck
|
val attachments = (message as? MmsMessageRecord)?.slideDeck
|
||||||
val sender = if (message.isOutgoing) TextSecurePreferences.getLocalNumber(context)!! else message.individualRecipient.address.serialize()
|
val sender = if (message.isOutgoing) TextSecurePreferences.getLocalNumber(context)!! else message.individualRecipient.address.serialize()
|
||||||
quoteView.bind(sender, message.body, attachments,
|
it.bind(sender, message.body, attachments, thread, true, message.isOpenGroupInvitation, message.threadId, false, glide)
|
||||||
thread, true, message.isOpenGroupInvitation, message.threadId, false, glide)
|
}
|
||||||
|
|
||||||
|
// Before we request a layout update we'll add back any LinkPreviewDraftView that might
|
||||||
|
// exist - as this goes into the LinearLayout second it will be below the quote View.
|
||||||
|
if (linkPreview != null && linkPreviewDraftView != null) {
|
||||||
|
binding.inputBarAdditionalContentContainer.addView(linkPreviewDraftView)
|
||||||
|
}
|
||||||
requestLayout()
|
requestLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun cancelQuoteDraft() {
|
override fun cancelQuoteDraft() {
|
||||||
|
binding.inputBarAdditionalContentContainer.removeView(quoteView)
|
||||||
quote = null
|
quote = null
|
||||||
binding.inputBarAdditionalContentContainer.removeAllViews()
|
quoteView = null
|
||||||
requestLayout()
|
requestLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun draftLinkPreview() {
|
fun draftLinkPreview() {
|
||||||
quote = null
|
// As `draftLinkPreview` is called before `updateLinkPreview` when we modify a URI in a
|
||||||
binding.inputBarAdditionalContentContainer.removeAllViews()
|
// message we'll bail early if a link preview View already exists and just let
|
||||||
val linkPreviewDraftView = LinkPreviewDraftView(context)
|
// `updateLinkPreview` get called to update the existing View.
|
||||||
linkPreviewDraftView.delegate = this
|
if (linkPreview != null && linkPreviewDraftView != null) return
|
||||||
this.linkPreviewDraftView = linkPreviewDraftView
|
|
||||||
|
linkPreviewDraftView = LinkPreviewDraftView(context).also { it.delegate = this }
|
||||||
|
|
||||||
|
// Add the link preview View. Note: If there's already a quote View in the 'additional
|
||||||
|
// content' container then this preview View will be added after / below it - which is fine.
|
||||||
binding.inputBarAdditionalContentContainer.addView(linkPreviewDraftView)
|
binding.inputBarAdditionalContentContainer.addView(linkPreviewDraftView)
|
||||||
requestLayout()
|
requestLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateLinkPreviewDraft(glide: GlideRequests, linkPreview: LinkPreview) {
|
fun updateLinkPreviewDraft(glide: GlideRequests, updatedLinkPreview: LinkPreview) {
|
||||||
this.linkPreview = linkPreview
|
// Update our `linkPreview` property with the new (provided as an argument to this function)
|
||||||
val linkPreviewDraftView = this.linkPreviewDraftView ?: return
|
// then update the View from that.
|
||||||
linkPreviewDraftView.update(glide, linkPreview)
|
linkPreview = updatedLinkPreview.also { linkPreviewDraftView?.update(glide, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun cancelLinkPreviewDraft() {
|
override fun cancelLinkPreviewDraft() {
|
||||||
if (quote != null) { return }
|
binding.inputBarAdditionalContentContainer.removeView(linkPreviewDraftView)
|
||||||
linkPreview = null
|
linkPreview = null
|
||||||
binding.inputBarAdditionalContentContainer.removeAllViews()
|
linkPreviewDraftView = null
|
||||||
requestLayout()
|
requestLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,11 +28,10 @@ class MentionCandidateView : RelativeLayout {
|
|||||||
|
|
||||||
private fun update() = with(binding) {
|
private fun update() = with(binding) {
|
||||||
mentionCandidateNameTextView.text = candidate.displayName
|
mentionCandidateNameTextView.text = candidate.displayName
|
||||||
profilePictureView.root.publicKey = candidate.publicKey
|
profilePictureView.publicKey = candidate.publicKey
|
||||||
profilePictureView.root.displayName = candidate.displayName
|
profilePictureView.displayName = candidate.displayName
|
||||||
profilePictureView.root.additionalPublicKey = null
|
profilePictureView.additionalPublicKey = null
|
||||||
profilePictureView.root.glide = glide!!
|
profilePictureView.update()
|
||||||
profilePictureView.root.update()
|
|
||||||
if (openGroupServer != null && openGroupRoom != null) {
|
if (openGroupServer != null && openGroupRoom != null) {
|
||||||
val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", candidate.publicKey)
|
val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", candidate.publicKey)
|
||||||
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE
|
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.view.ViewGroup
|
|||||||
import android.widget.BaseAdapter
|
import android.widget.BaseAdapter
|
||||||
import android.widget.ListView
|
import android.widget.ListView
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import network.loki.messenger.R
|
||||||
import org.session.libsession.messaging.mentions.Mention
|
import org.session.libsession.messaging.mentions.Mention
|
||||||
import org.thoughtcrime.securesms.database.LokiThreadDatabase
|
import org.thoughtcrime.securesms.database.LokiThreadDatabase
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||||
@@ -41,7 +42,9 @@ class MentionCandidatesView(context: Context, attrs: AttributeSet?, defStyleAttr
|
|||||||
override fun getItem(position: Int): Mention { return candidates[position] }
|
override fun getItem(position: Int): Mention { return candidates[position] }
|
||||||
|
|
||||||
override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View {
|
override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View {
|
||||||
val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context)
|
val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context).apply {
|
||||||
|
contentDescription = context.getString(R.string.AccessibilityId_contact)
|
||||||
|
}
|
||||||
val mentionCandidate = getItem(position)
|
val mentionCandidate = getItem(position)
|
||||||
cell.glide = glide
|
cell.glide = glide
|
||||||
cell.candidate = mentionCandidate
|
cell.candidate = mentionCandidate
|
||||||
|
|||||||
@@ -65,11 +65,13 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
|
|||||||
menu.findItem(R.id.menu_context_copy).isVisible = !containsControlMessage && hasText
|
menu.findItem(R.id.menu_context_copy).isVisible = !containsControlMessage && hasText
|
||||||
// Copy Session ID
|
// Copy Session ID
|
||||||
menu.findItem(R.id.menu_context_copy_public_key).isVisible =
|
menu.findItem(R.id.menu_context_copy_public_key).isVisible =
|
||||||
(thread.isGroupRecipient && !thread.isOpenGroupRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey)
|
(thread.isGroupRecipient && !thread.isCommunityRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey)
|
||||||
// Message detail
|
// Message detail
|
||||||
menu.findItem(R.id.menu_message_details).isVisible = (selectedItems.size == 1 && firstMessage.isOutgoing)
|
menu.findItem(R.id.menu_message_details).isVisible = selectedItems.size == 1
|
||||||
// Resend
|
// Resend
|
||||||
menu.findItem(R.id.menu_context_resend).isVisible = (selectedItems.size == 1 && firstMessage.isFailed)
|
menu.findItem(R.id.menu_context_resend).isVisible = (selectedItems.size == 1 && firstMessage.isFailed)
|
||||||
|
// Resync
|
||||||
|
menu.findItem(R.id.menu_context_resync).isVisible = (selectedItems.size == 1 && firstMessage.isSyncFailed)
|
||||||
// Save media
|
// Save media
|
||||||
menu.findItem(R.id.menu_context_save_attachment).isVisible = (selectedItems.size == 1
|
menu.findItem(R.id.menu_context_save_attachment).isVisible = (selectedItems.size == 1
|
||||||
&& firstMessage.isMms && (firstMessage as MediaMmsMessageRecord).containsMediaSlide())
|
&& firstMessage.isMms && (firstMessage as MediaMmsMessageRecord).containsMediaSlide())
|
||||||
@@ -90,6 +92,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
|
|||||||
R.id.menu_context_ban_and_delete_all -> delegate?.banAndDeleteAll(selectedItems)
|
R.id.menu_context_ban_and_delete_all -> delegate?.banAndDeleteAll(selectedItems)
|
||||||
R.id.menu_context_copy -> delegate?.copyMessages(selectedItems)
|
R.id.menu_context_copy -> delegate?.copyMessages(selectedItems)
|
||||||
R.id.menu_context_copy_public_key -> delegate?.copySessionID(selectedItems)
|
R.id.menu_context_copy_public_key -> delegate?.copySessionID(selectedItems)
|
||||||
|
R.id.menu_context_resync -> delegate?.resyncMessage(selectedItems)
|
||||||
R.id.menu_context_resend -> delegate?.resendMessage(selectedItems)
|
R.id.menu_context_resend -> delegate?.resendMessage(selectedItems)
|
||||||
R.id.menu_message_details -> delegate?.showMessageDetail(selectedItems)
|
R.id.menu_message_details -> delegate?.showMessageDetail(selectedItems)
|
||||||
R.id.menu_context_save_attachment -> delegate?.saveAttachment(selectedItems)
|
R.id.menu_context_save_attachment -> delegate?.saveAttachment(selectedItems)
|
||||||
@@ -113,6 +116,7 @@ interface ConversationActionModeCallbackDelegate {
|
|||||||
fun banAndDeleteAll(messages: Set<MessageRecord>)
|
fun banAndDeleteAll(messages: Set<MessageRecord>)
|
||||||
fun copyMessages(messages: Set<MessageRecord>)
|
fun copyMessages(messages: Set<MessageRecord>)
|
||||||
fun copySessionID(messages: Set<MessageRecord>)
|
fun copySessionID(messages: Set<MessageRecord>)
|
||||||
|
fun resyncMessage(messages: Set<MessageRecord>)
|
||||||
fun resendMessage(messages: Set<MessageRecord>)
|
fun resendMessage(messages: Set<MessageRecord>)
|
||||||
fun showMessageDetail(messages: Set<MessageRecord>)
|
fun showMessageDetail(messages: Set<MessageRecord>)
|
||||||
fun saveAttachment(messages: Set<MessageRecord>)
|
fun saveAttachment(messages: Set<MessageRecord>)
|
||||||
|
|||||||
@@ -4,17 +4,11 @@ import android.annotation.SuppressLint
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.PorterDuff
|
|
||||||
import android.graphics.PorterDuffColorFilter
|
|
||||||
import android.os.AsyncTask
|
import android.os.AsyncTask
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuInflater
|
import android.view.MenuInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.TextView
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.ColorInt
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.view.ContextThemeWrapper
|
import androidx.appcompat.view.ContextThemeWrapper
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
@@ -25,15 +19,12 @@ import androidx.core.graphics.drawable.IconCompat
|
|||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import org.session.libsession.messaging.sending_receiving.MessageSender
|
import org.session.libsession.messaging.sending_receiving.MessageSender
|
||||||
import org.session.libsession.messaging.sending_receiving.leave
|
import org.session.libsession.messaging.sending_receiving.leave
|
||||||
import org.session.libsession.utilities.ExpirationUtil
|
|
||||||
import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID
|
import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID
|
||||||
import org.session.libsession.utilities.TextSecurePreferences
|
import org.session.libsession.utilities.TextSecurePreferences
|
||||||
import org.session.libsession.utilities.getColorFromAttr
|
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.session.libsignal.utilities.guava.Optional
|
import org.session.libsignal.utilities.guava.Optional
|
||||||
import org.session.libsignal.utilities.toHexString
|
import org.session.libsignal.utilities.toHexString
|
||||||
import org.thoughtcrime.securesms.MediaOverviewActivity
|
import org.thoughtcrime.securesms.MediaOverviewActivity
|
||||||
import org.thoughtcrime.securesms.MuteDialog
|
|
||||||
import org.thoughtcrime.securesms.ShortcutLauncherActivity
|
import org.thoughtcrime.securesms.ShortcutLauncherActivity
|
||||||
import org.thoughtcrime.securesms.calls.WebRtcCallActivity
|
import org.thoughtcrime.securesms.calls.WebRtcCallActivity
|
||||||
import org.thoughtcrime.securesms.contacts.SelectContactsActivity
|
import org.thoughtcrime.securesms.contacts.SelectContactsActivity
|
||||||
@@ -44,6 +35,8 @@ import org.thoughtcrime.securesms.groups.EditClosedGroupActivity
|
|||||||
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey
|
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey
|
||||||
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
|
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
|
||||||
import org.thoughtcrime.securesms.service.WebRtcCallService
|
import org.thoughtcrime.securesms.service.WebRtcCallService
|
||||||
|
import org.thoughtcrime.securesms.showMuteDialog
|
||||||
|
import org.thoughtcrime.securesms.showSessionDialog
|
||||||
import org.thoughtcrime.securesms.util.BitmapUtil
|
import org.thoughtcrime.securesms.util.BitmapUtil
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
@@ -53,30 +46,16 @@ object ConversationMenuHelper {
|
|||||||
menu: Menu,
|
menu: Menu,
|
||||||
inflater: MenuInflater,
|
inflater: MenuInflater,
|
||||||
thread: Recipient,
|
thread: Recipient,
|
||||||
threadId: Long,
|
context: Context
|
||||||
context: Context,
|
|
||||||
onOptionsItemSelected: (MenuItem) -> Unit
|
|
||||||
) {
|
) {
|
||||||
// Prepare
|
// Prepare
|
||||||
menu.clear()
|
menu.clear()
|
||||||
val isOpenGroup = thread.isOpenGroupRecipient
|
val isOpenGroup = thread.isCommunityRecipient
|
||||||
// Base menu (options that should always be present)
|
// Base menu (options that should always be present)
|
||||||
inflater.inflate(R.menu.menu_conversation, menu)
|
inflater.inflate(R.menu.menu_conversation, menu)
|
||||||
// Expiring messages
|
// Expiring messages
|
||||||
if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient)) {
|
if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) {
|
||||||
if (thread.expireMessages > 0) {
|
inflater.inflate(R.menu.menu_conversation_expiration, menu)
|
||||||
inflater.inflate(R.menu.menu_conversation_expiration_on, menu)
|
|
||||||
val item = menu.findItem(R.id.menu_expiring_messages)
|
|
||||||
val actionView = item.actionView
|
|
||||||
val iconView = actionView.findViewById<ImageView>(R.id.menu_badge_icon)
|
|
||||||
val badgeView = actionView.findViewById<TextView>(R.id.expiration_badge)
|
|
||||||
@ColorInt val color = context.getColorFromAttr(android.R.attr.textColorPrimary)
|
|
||||||
iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY)
|
|
||||||
badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages)
|
|
||||||
actionView.setOnClickListener { onOptionsItemSelected(item) }
|
|
||||||
} else {
|
|
||||||
inflater.inflate(R.menu.menu_conversation_expiration_off, menu)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// One-on-one chat menu allows copying the session id
|
// One-on-one chat menu allows copying the session id
|
||||||
if (thread.isContactRecipient) {
|
if (thread.isContactRecipient) {
|
||||||
@@ -86,7 +65,7 @@ object ConversationMenuHelper {
|
|||||||
if (thread.isContactRecipient) {
|
if (thread.isContactRecipient) {
|
||||||
if (thread.isBlocked) {
|
if (thread.isBlocked) {
|
||||||
inflater.inflate(R.menu.menu_conversation_unblock, menu)
|
inflater.inflate(R.menu.menu_conversation_unblock, menu)
|
||||||
} else {
|
} else if (!thread.isLocalNumber) {
|
||||||
inflater.inflate(R.menu.menu_conversation_block, menu)
|
inflater.inflate(R.menu.menu_conversation_block, menu)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,7 +88,7 @@ object ConversationMenuHelper {
|
|||||||
inflater.inflate(R.menu.menu_conversation_notification_settings, menu)
|
inflater.inflate(R.menu.menu_conversation_notification_settings, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!thread.isGroupRecipient && thread.hasApprovedMe()) {
|
if (thread.showCallMenu()) {
|
||||||
inflater.inflate(R.menu.menu_conversation_call, menu)
|
inflater.inflate(R.menu.menu_conversation_call, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,8 +131,7 @@ object ConversationMenuHelper {
|
|||||||
R.id.menu_view_all_media -> { showAllMedia(context, thread) }
|
R.id.menu_view_all_media -> { showAllMedia(context, thread) }
|
||||||
R.id.menu_search -> { search(context) }
|
R.id.menu_search -> { search(context) }
|
||||||
R.id.menu_add_shortcut -> { addShortcut(context, thread) }
|
R.id.menu_add_shortcut -> { addShortcut(context, thread) }
|
||||||
R.id.menu_expiring_messages -> { showExpiringMessagesDialog(context, thread) }
|
R.id.menu_expiring_messages -> { showDisappearingMessages(context, thread) }
|
||||||
R.id.menu_expiring_messages_off -> { showExpiringMessagesDialog(context, thread) }
|
|
||||||
R.id.menu_unblock -> { unblock(context, thread) }
|
R.id.menu_unblock -> { unblock(context, thread) }
|
||||||
R.id.menu_block -> { block(context, thread, deleteThread = false) }
|
R.id.menu_block -> { block(context, thread, deleteThread = false) }
|
||||||
R.id.menu_block_delete -> { blockAndDelete(context, thread) }
|
R.id.menu_block_delete -> { blockAndDelete(context, thread) }
|
||||||
@@ -185,26 +163,23 @@ object ConversationMenuHelper {
|
|||||||
private fun call(context: Context, thread: Recipient) {
|
private fun call(context: Context, thread: Recipient) {
|
||||||
|
|
||||||
if (!TextSecurePreferences.isCallNotificationsEnabled(context)) {
|
if (!TextSecurePreferences.isCallNotificationsEnabled(context)) {
|
||||||
AlertDialog.Builder(context)
|
context.showSessionDialog {
|
||||||
.setTitle(R.string.ConversationActivity_call_title)
|
title(R.string.ConversationActivity_call_title)
|
||||||
.setMessage(R.string.ConversationActivity_call_prompt)
|
text(R.string.ConversationActivity_call_prompt)
|
||||||
.setPositiveButton(R.string.activity_settings_title) { _, _ ->
|
button(R.string.activity_settings_title, R.string.AccessibilityId_settings) {
|
||||||
val intent = Intent(context, PrivacySettingsActivity::class.java)
|
Intent(context, PrivacySettingsActivity::class.java).let(context::startActivity)
|
||||||
context.startActivity(intent)
|
|
||||||
}
|
}
|
||||||
.setNeutralButton(R.string.cancel) { d, _ ->
|
cancelButton()
|
||||||
d.dismiss()
|
}
|
||||||
}.show()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val service = WebRtcCallService.createCall(context, thread)
|
WebRtcCallService.createCall(context, thread)
|
||||||
context.startService(service)
|
.let(context::startService)
|
||||||
|
|
||||||
val activity = Intent(context, WebRtcCallActivity::class.java).apply {
|
Intent(context, WebRtcCallActivity::class.java)
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
.apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK }
|
||||||
}
|
.let(context::startActivity)
|
||||||
context.startActivity(activity)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +187,7 @@ object ConversationMenuHelper {
|
|||||||
private fun addShortcut(context: Context, thread: Recipient) {
|
private fun addShortcut(context: Context, thread: Recipient) {
|
||||||
object : AsyncTask<Void?, Void?, IconCompat?>() {
|
object : AsyncTask<Void?, Void?, IconCompat?>() {
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
override fun doInBackground(vararg params: Void?): IconCompat? {
|
override fun doInBackground(vararg params: Void?): IconCompat? {
|
||||||
var icon: IconCompat? = null
|
var icon: IconCompat? = null
|
||||||
val contactPhoto = thread.contactPhoto
|
val contactPhoto = thread.contactPhoto
|
||||||
@@ -230,6 +206,7 @@ object ConversationMenuHelper {
|
|||||||
return icon
|
return icon
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
override fun onPostExecute(icon: IconCompat?) {
|
override fun onPostExecute(icon: IconCompat?) {
|
||||||
val name = Optional.fromNullable<String>(thread.name)
|
val name = Optional.fromNullable<String>(thread.name)
|
||||||
.or(Optional.fromNullable<String>(thread.profileName))
|
.or(Optional.fromNullable<String>(thread.profileName))
|
||||||
@@ -246,9 +223,9 @@ object ConversationMenuHelper {
|
|||||||
}.execute()
|
}.execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showExpiringMessagesDialog(context: Context, thread: Recipient) {
|
private fun showDisappearingMessages(context: Context, thread: Recipient) {
|
||||||
val listener = context as? ConversationMenuListener ?: return
|
val listener = context as? ConversationMenuListener ?: return
|
||||||
listener.showExpiringMessagesDialog(thread)
|
listener.showDisappearingMessages(thread)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun unblock(context: Context, thread: Recipient) {
|
private fun unblock(context: Context, thread: Recipient) {
|
||||||
@@ -276,7 +253,7 @@ object ConversationMenuHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun copyOpenGroupUrl(context: Context, thread: Recipient) {
|
private fun copyOpenGroupUrl(context: Context, thread: Recipient) {
|
||||||
if (!thread.isOpenGroupRecipient) { return }
|
if (!thread.isCommunityRecipient) { return }
|
||||||
val listener = context as? ConversationMenuListener ?: return
|
val listener = context as? ConversationMenuListener ?: return
|
||||||
listener.copyOpenGroupUrl(thread)
|
listener.copyOpenGroupUrl(thread)
|
||||||
}
|
}
|
||||||
@@ -291,9 +268,7 @@ object ConversationMenuHelper {
|
|||||||
|
|
||||||
private fun leaveClosedGroup(context: Context, thread: Recipient) {
|
private fun leaveClosedGroup(context: Context, thread: Recipient) {
|
||||||
if (!thread.isClosedGroupRecipient) { return }
|
if (!thread.isClosedGroupRecipient) { return }
|
||||||
val builder = AlertDialog.Builder(context)
|
|
||||||
builder.setTitle(context.resources.getString(R.string.ConversationActivity_leave_group))
|
|
||||||
builder.setCancelable(true)
|
|
||||||
val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull()
|
val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull()
|
||||||
val admins = group.admins
|
val admins = group.admins
|
||||||
val sessionID = TextSecurePreferences.getLocalNumber(context)
|
val sessionID = TextSecurePreferences.getLocalNumber(context)
|
||||||
@@ -303,33 +278,29 @@ object ConversationMenuHelper {
|
|||||||
} else {
|
} else {
|
||||||
context.resources.getString(R.string.ConversationActivity_are_you_sure_you_want_to_leave_this_group)
|
context.resources.getString(R.string.ConversationActivity_are_you_sure_you_want_to_leave_this_group)
|
||||||
}
|
}
|
||||||
builder.setMessage(message)
|
|
||||||
builder.setPositiveButton(R.string.yes) { _, _ ->
|
fun onLeaveFailed() = Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show()
|
||||||
var groupPublicKey: String?
|
|
||||||
var isClosedGroup: Boolean
|
context.showSessionDialog {
|
||||||
try {
|
title(R.string.ConversationActivity_leave_group)
|
||||||
groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString()
|
text(message)
|
||||||
isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey)
|
button(R.string.yes) {
|
||||||
} catch (e: IOException) {
|
try {
|
||||||
groupPublicKey = null
|
val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString()
|
||||||
isClosedGroup = false
|
val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey)
|
||||||
}
|
|
||||||
try {
|
if (isClosedGroup) MessageSender.leave(groupPublicKey, notifyUser = false)
|
||||||
if (isClosedGroup) {
|
else onLeaveFailed()
|
||||||
MessageSender.leave(groupPublicKey!!, true)
|
} catch (e: Exception) {
|
||||||
} else {
|
onLeaveFailed()
|
||||||
Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
}
|
||||||
|
button(R.string.no)
|
||||||
}
|
}
|
||||||
builder.setNegativeButton(R.string.no, null)
|
|
||||||
builder.show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun inviteContacts(context: Context, thread: Recipient) {
|
private fun inviteContacts(context: Context, thread: Recipient) {
|
||||||
if (!thread.isOpenGroupRecipient) { return }
|
if (!thread.isCommunityRecipient) { return }
|
||||||
val intent = Intent(context, SelectContactsActivity::class.java)
|
val intent = Intent(context, SelectContactsActivity::class.java)
|
||||||
val activity = context as AppCompatActivity
|
val activity = context as AppCompatActivity
|
||||||
activity.startActivityForResult(intent, ConversationActivityV2.INVITE_CONTACTS)
|
activity.startActivityForResult(intent, ConversationActivityV2.INVITE_CONTACTS)
|
||||||
@@ -340,7 +311,7 @@ object ConversationMenuHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun mute(context: Context, thread: Recipient) {
|
private fun mute(context: Context, thread: Recipient) {
|
||||||
MuteDialog.show(ContextThemeWrapper(context, context.theme)) { until: Long ->
|
showMuteDialog(ContextThemeWrapper(context, context.theme)) { until ->
|
||||||
DatabaseComponent.get(context).recipientDatabase().setMuted(thread, until)
|
DatabaseComponent.get(context).recipientDatabase().setMuted(thread, until)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,7 +327,7 @@ object ConversationMenuHelper {
|
|||||||
fun unblock()
|
fun unblock()
|
||||||
fun copySessionID(sessionId: String)
|
fun copySessionID(sessionId: String)
|
||||||
fun copyOpenGroupUrl(thread: Recipient)
|
fun copyOpenGroupUrl(thread: Recipient)
|
||||||
fun showExpiringMessagesDialog(thread: Recipient)
|
fun showDisappearingMessages(thread: Recipient)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -3,49 +3,81 @@ package org.thoughtcrime.securesms.conversation.v2.messages
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import androidx.core.content.res.ResourcesCompat
|
import androidx.core.content.res.ResourcesCompat
|
||||||
|
import androidx.core.view.isGone
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import network.loki.messenger.databinding.ViewControlMessageBinding
|
import network.loki.messenger.databinding.ViewControlMessageBinding
|
||||||
|
import network.loki.messenger.libsession_util.util.ExpiryMode
|
||||||
|
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||||
|
import org.session.libsession.messaging.messages.ExpirationConfiguration
|
||||||
|
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages
|
||||||
|
import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||||
|
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
class ControlMessageView : LinearLayout {
|
class ControlMessageView : LinearLayout {
|
||||||
|
|
||||||
|
private val TAG = "ControlMessageView"
|
||||||
|
|
||||||
private lateinit var binding: ViewControlMessageBinding
|
private lateinit var binding: ViewControlMessageBinding
|
||||||
|
|
||||||
// region Lifecycle
|
|
||||||
constructor(context: Context) : super(context) { initialize() }
|
constructor(context: Context) : super(context) { initialize() }
|
||||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
|
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
|
||||||
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
|
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
|
||||||
|
|
||||||
|
@Inject lateinit var disappearingMessages: DisappearingMessages
|
||||||
|
|
||||||
private fun initialize() {
|
private fun initialize() {
|
||||||
binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true)
|
binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true)
|
||||||
layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
|
layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
|
||||||
}
|
}
|
||||||
// endregion
|
|
||||||
|
|
||||||
// region Updating
|
|
||||||
fun bind(message: MessageRecord, previous: MessageRecord?) {
|
fun bind(message: MessageRecord, previous: MessageRecord?) {
|
||||||
binding.dateBreakTextView.showDateBreak(message, previous)
|
binding.dateBreakTextView.showDateBreak(message, previous)
|
||||||
binding.iconImageView.visibility = View.GONE
|
binding.iconImageView.isGone = true
|
||||||
|
binding.expirationTimerView.isGone = true
|
||||||
|
binding.followSetting.isGone = true
|
||||||
var messageBody: CharSequence = message.getDisplayBody(context)
|
var messageBody: CharSequence = message.getDisplayBody(context)
|
||||||
|
binding.root.contentDescription = null
|
||||||
|
binding.textView.text = messageBody
|
||||||
when {
|
when {
|
||||||
message.isExpirationTimerUpdate -> {
|
message.isExpirationTimerUpdate -> {
|
||||||
binding.iconImageView.setImageDrawable(
|
binding.apply {
|
||||||
ResourcesCompat.getDrawable(resources, R.drawable.ic_timer, context.theme)
|
expirationTimerView.isVisible = true
|
||||||
)
|
|
||||||
binding.iconImageView.visibility = View.VISIBLE
|
val threadRecipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(message.threadId)
|
||||||
|
|
||||||
|
if (threadRecipient?.isClosedGroupRecipient == true) {
|
||||||
|
expirationTimerView.setTimerIcon()
|
||||||
|
} else {
|
||||||
|
expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
followSetting.isVisible = ExpirationConfiguration.isNewConfigEnabled
|
||||||
|
&& !message.isOutgoing
|
||||||
|
&& message.expiryMode != (MessagingModuleConfiguration.shared.storage.getExpirationConfiguration(message.threadId)?.expiryMode ?: ExpiryMode.NONE)
|
||||||
|
&& threadRecipient?.isGroupRecipient != true
|
||||||
|
|
||||||
|
followSetting.setOnClickListener { disappearingMessages.showFollowSettingDialog(context, message) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
message.isMediaSavedNotification -> {
|
message.isMediaSavedNotification -> {
|
||||||
binding.iconImageView.setImageDrawable(
|
binding.iconImageView.apply {
|
||||||
ResourcesCompat.getDrawable(resources, R.drawable.ic_file_download_white_36dp, context.theme)
|
setImageDrawable(
|
||||||
)
|
ResourcesCompat.getDrawable(resources, R.drawable.ic_file_download_white_36dp, context.theme)
|
||||||
binding.iconImageView.visibility = View.VISIBLE
|
)
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
message.isMessageRequestResponse -> {
|
message.isMessageRequestResponse -> {
|
||||||
messageBody = context.getString(R.string.message_requests_accepted)
|
binding.textView.text = context.getString(R.string.message_requests_accepted)
|
||||||
|
binding.root.contentDescription=context.getString(R.string.AccessibilityId_message_request_config_message)
|
||||||
}
|
}
|
||||||
message.isCallLog -> {
|
message.isCallLog -> {
|
||||||
val drawable = when {
|
val drawable = when {
|
||||||
@@ -54,16 +86,22 @@ class ControlMessageView : LinearLayout {
|
|||||||
message.isFirstMissedCall -> R.drawable.ic_info_outline_light
|
message.isFirstMissedCall -> R.drawable.ic_info_outline_light
|
||||||
else -> R.drawable.ic_missed_call
|
else -> R.drawable.ic_missed_call
|
||||||
}
|
}
|
||||||
binding.iconImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, drawable, context.theme))
|
binding.textView.isVisible = false
|
||||||
binding.iconImageView.visibility = View.VISIBLE
|
binding.callTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(ResourcesCompat.getDrawable(resources, drawable, context.theme), null, null, null)
|
||||||
|
binding.callTextView.text = messageBody
|
||||||
|
|
||||||
|
if (message.expireStarted > 0 && message.expiresIn > 0) {
|
||||||
|
binding.expirationTimerView.isVisible = true
|
||||||
|
binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.textView.text = messageBody
|
binding.textView.isGone = message.isCallLog
|
||||||
|
binding.callView.isVisible = message.isCallLog
|
||||||
}
|
}
|
||||||
|
|
||||||
fun recycle() {
|
fun recycle() {
|
||||||
|
|
||||||
}
|
}
|
||||||
// endregion
|
|
||||||
}
|
}
|
||||||
@@ -5,11 +5,13 @@ import android.content.res.ColorStateList
|
|||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import androidx.annotation.ColorInt
|
import androidx.annotation.ColorInt
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import network.loki.messenger.databinding.ViewDocumentBinding
|
import network.loki.messenger.databinding.ViewDocumentBinding
|
||||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||||
|
|
||||||
class DocumentView : LinearLayout {
|
class DocumentView : LinearLayout {
|
||||||
private val binding: ViewDocumentBinding by lazy { ViewDocumentBinding.bind(this) }
|
private val binding: ViewDocumentBinding by lazy { ViewDocumentBinding.bind(this) }
|
||||||
|
|
||||||
// region Lifecycle
|
// region Lifecycle
|
||||||
constructor(context: Context) : super(context)
|
constructor(context: Context) : super(context)
|
||||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
|
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
|
||||||
@@ -22,6 +24,12 @@ class DocumentView : LinearLayout {
|
|||||||
binding.documentTitleTextView.text = document.fileName.or("Untitled File")
|
binding.documentTitleTextView.text = document.fileName.or("Untitled File")
|
||||||
binding.documentTitleTextView.setTextColor(textColor)
|
binding.documentTitleTextView.setTextColor(textColor)
|
||||||
binding.documentViewIconImageView.imageTintList = ColorStateList.valueOf(textColor)
|
binding.documentViewIconImageView.imageTintList = ColorStateList.valueOf(textColor)
|
||||||
|
|
||||||
|
// Show the progress spinner if the attachment is downloading, otherwise show
|
||||||
|
// the document icon (and always remove the other, whichever one that is)
|
||||||
|
binding.documentViewProgress.isVisible = message.isMediaPending
|
||||||
|
binding.documentViewIconImageView.isVisible = !message.isMediaPending
|
||||||
}
|
}
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -3,25 +3,21 @@ package org.thoughtcrime.securesms.conversation.v2.messages
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import android.text.Spannable
|
import android.text.Spannable
|
||||||
import android.text.style.BackgroundColorSpan
|
import android.text.style.BackgroundColorSpan
|
||||||
import android.text.style.ForegroundColorSpan
|
import android.text.style.ForegroundColorSpan
|
||||||
import android.text.style.URLSpan
|
import android.text.style.URLSpan
|
||||||
import android.text.util.Linkify
|
import android.text.util.Linkify
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.LinearLayout
|
|
||||||
import androidx.annotation.ColorInt
|
import androidx.annotation.ColorInt
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.core.content.res.ResourcesCompat
|
import androidx.core.graphics.ColorUtils
|
||||||
import androidx.core.graphics.BlendModeColorFilterCompat
|
|
||||||
import androidx.core.graphics.BlendModeCompat
|
|
||||||
import androidx.core.text.getSpans
|
import androidx.core.text.getSpans
|
||||||
import androidx.core.text.toSpannable
|
import androidx.core.text.toSpannable
|
||||||
|
import androidx.core.view.children
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
|
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
|
||||||
@@ -29,7 +25,9 @@ import okhttp3.HttpUrl
|
|||||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||||
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
|
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
|
||||||
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
|
||||||
|
import org.session.libsession.utilities.ThemeUtil
|
||||||
import org.session.libsession.utilities.getColorFromAttr
|
import org.session.libsession.utilities.getColorFromAttr
|
||||||
|
import org.session.libsession.utilities.modifyLayoutParams
|
||||||
import org.session.libsession.utilities.recipients.Recipient
|
import org.session.libsession.utilities.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
||||||
import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet
|
import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet
|
||||||
@@ -39,15 +37,16 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getInt
|
|||||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||||
import org.thoughtcrime.securesms.database.model.SmsMessageRecord
|
import org.thoughtcrime.securesms.database.model.SmsMessageRecord
|
||||||
|
import org.thoughtcrime.securesms.mms.GlideApp
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||||
|
import org.thoughtcrime.securesms.util.GlowViewUtilities
|
||||||
import org.thoughtcrime.securesms.util.SearchUtil
|
import org.thoughtcrime.securesms.util.SearchUtil
|
||||||
import org.thoughtcrime.securesms.util.getAccentColor
|
import org.thoughtcrime.securesms.util.getAccentColor
|
||||||
import java.util.*
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class VisibleMessageContentView : ConstraintLayout {
|
class VisibleMessageContentView : ConstraintLayout {
|
||||||
private val binding: ViewVisibleMessageContentBinding by lazy { ViewVisibleMessageContentBinding.bind(this) }
|
private val binding: ViewVisibleMessageContentBinding by lazy { ViewVisibleMessageContentBinding.bind(this) }
|
||||||
var onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf()
|
|
||||||
var onContentDoubleTap: (() -> Unit)? = null
|
var onContentDoubleTap: (() -> Unit)? = null
|
||||||
var delegate: VisibleMessageViewDelegate? = null
|
var delegate: VisibleMessageViewDelegate? = null
|
||||||
var indexInAdapter: Int = -1
|
var indexInAdapter: Int = -1
|
||||||
@@ -61,21 +60,20 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
// region Updating
|
// region Updating
|
||||||
fun bind(
|
fun bind(
|
||||||
message: MessageRecord,
|
message: MessageRecord,
|
||||||
isStartOfMessageCluster: Boolean,
|
isStartOfMessageCluster: Boolean = true,
|
||||||
isEndOfMessageCluster: Boolean,
|
isEndOfMessageCluster: Boolean = true,
|
||||||
glide: GlideRequests,
|
glide: GlideRequests = GlideApp.with(this),
|
||||||
thread: Recipient,
|
thread: Recipient,
|
||||||
searchQuery: String?,
|
searchQuery: String? = null,
|
||||||
contactIsTrusted: Boolean,
|
contactIsTrusted: Boolean = true,
|
||||||
onAttachmentNeedsDownload: (Long, Long) -> Unit
|
onAttachmentNeedsDownload: (Long, Long) -> Unit,
|
||||||
|
suppressThumbnails: Boolean = false
|
||||||
) {
|
) {
|
||||||
// Background
|
// Background
|
||||||
val background = getBackground(message.isOutgoing)
|
|
||||||
val color = if (message.isOutgoing) context.getAccentColor()
|
val color = if (message.isOutgoing) context.getAccentColor()
|
||||||
else context.getColorFromAttr(R.attr.message_received_background_color)
|
else context.getColorFromAttr(R.attr.message_received_background_color)
|
||||||
val filter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN)
|
binding.contentParent.mainColor = color
|
||||||
background.colorFilter = filter
|
binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius)
|
||||||
binding.contentParent.background = background
|
|
||||||
|
|
||||||
val onlyBodyMessage = message is SmsMessageRecord
|
val onlyBodyMessage = message is SmsMessageRecord
|
||||||
val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null
|
val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null
|
||||||
@@ -132,7 +130,6 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
delegate?.scrollToMessageIfPossible(quote.id)
|
delegate?.scrollToMessageIfPossible(quote.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val hasMedia = message.slideDeck.asAttachments().isNotEmpty()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message is MmsMessageRecord) {
|
if (message is MmsMessageRecord) {
|
||||||
@@ -189,7 +186,7 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
|
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
message is MmsMessageRecord && message.slideDeck.asAttachments().isNotEmpty() -> {
|
message is MmsMessageRecord && !suppressThumbnails && message.slideDeck.asAttachments().isNotEmpty() -> {
|
||||||
/*
|
/*
|
||||||
* Images / Video attachment
|
* Images / Video attachment
|
||||||
*/
|
*/
|
||||||
@@ -202,9 +199,9 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
isStart = isStartOfMessageCluster,
|
isStart = isStartOfMessageCluster,
|
||||||
isEnd = isEndOfMessageCluster
|
isEnd = isEndOfMessageCluster
|
||||||
)
|
)
|
||||||
val layoutParams = binding.albumThumbnailView.root.layoutParams as ConstraintLayout.LayoutParams
|
binding.albumThumbnailView.root.modifyLayoutParams<ConstraintLayout.LayoutParams> {
|
||||||
layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
|
horizontalBias = if (message.isOutgoing) 1f else 0f
|
||||||
binding.albumThumbnailView.root.layoutParams = layoutParams
|
}
|
||||||
onContentClick.add { event ->
|
onContentClick.add { event ->
|
||||||
binding.albumThumbnailView.root.calculateHitObject(event, message, thread, onAttachmentNeedsDownload)
|
binding.albumThumbnailView.root.calculateHitObject(event, message, thread, onAttachmentNeedsDownload)
|
||||||
}
|
}
|
||||||
@@ -223,6 +220,7 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
binding.bodyTextView.isVisible = message.body.isNotEmpty() && !hideBody
|
binding.bodyTextView.isVisible = message.body.isNotEmpty() && !hideBody
|
||||||
|
binding.contentParent.apply { isVisible = children.any { it.isVisible } }
|
||||||
|
|
||||||
if (message.body.isNotEmpty() && !hideBody) {
|
if (message.body.isNotEmpty() && !hideBody) {
|
||||||
val color = getTextColor(context, message)
|
val color = getTextColor(context, message)
|
||||||
@@ -236,19 +234,20 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val layoutParams = binding.contentParent.layoutParams as ConstraintLayout.LayoutParams
|
binding.contentParent.modifyLayoutParams<ConstraintLayout.LayoutParams> {
|
||||||
layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
|
horizontalBias = if (message.isOutgoing) 1f else 0f
|
||||||
binding.contentParent.layoutParams = layoutParams
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf()
|
||||||
|
|
||||||
|
fun onContentClick(event: MotionEvent) {
|
||||||
|
onContentClick.forEach { clickHandler -> clickHandler.invoke(event) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean =
|
private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean =
|
||||||
listOf<View>(albumThumbnailView.root, linkPreviewView.root, voiceMessageView.root, quoteView.root).none { it.isVisible }
|
listOf<View>(albumThumbnailView.root, linkPreviewView.root, voiceMessageView.root, quoteView.root).none { it.isVisible }
|
||||||
|
|
||||||
private fun getBackground(isOutgoing: Boolean): Drawable {
|
|
||||||
val backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_alone else R.drawable.message_bubble_background_received_alone
|
|
||||||
return ResourcesCompat.getDrawable(resources, backgroundID, context.theme)!!
|
|
||||||
}
|
|
||||||
|
|
||||||
fun recycle() {
|
fun recycle() {
|
||||||
arrayOf(
|
arrayOf(
|
||||||
binding.deletedMessageView.root,
|
binding.deletedMessageView.root,
|
||||||
@@ -266,6 +265,15 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
fun playVoiceMessage() {
|
fun playVoiceMessage() {
|
||||||
binding.voiceMessageView.root.togglePlayback()
|
binding.voiceMessageView.root.togglePlayback()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun playHighlight() {
|
||||||
|
// Show the highlight colour immediately then slowly fade out
|
||||||
|
val targetColor = if (ThemeUtil.isDarkTheme(context)) context.getAccentColor() else resources.getColor(R.color.black, context.theme)
|
||||||
|
val clearTargetColor = ColorUtils.setAlphaComponent(targetColor, 0)
|
||||||
|
binding.contentParent.numShadowRenders = if (ThemeUtil.isDarkTheme(context)) 3 else 1
|
||||||
|
binding.contentParent.sessionShadowColor = targetColor
|
||||||
|
GlowViewUtilities.animateShadowColorChange(binding.contentParent, targetColor, clearTargetColor, 1600)
|
||||||
|
}
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Convenience
|
// region Convenience
|
||||||
@@ -299,16 +307,9 @@ class VisibleMessageContentView : ConstraintLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ColorInt
|
@ColorInt
|
||||||
fun getTextColor(context: Context, message: MessageRecord): Int {
|
fun getTextColor(context: Context, message: MessageRecord): Int = context.getColorFromAttr(
|
||||||
val colorAttribute = if (message.isOutgoing) {
|
if (message.isOutgoing) R.attr.message_sent_text_color else R.attr.message_received_text_color
|
||||||
// sent
|
)
|
||||||
R.attr.message_sent_text_color
|
|
||||||
} else {
|
|
||||||
// received
|
|
||||||
R.attr.message_received_text_color
|
|
||||||
}
|
|
||||||
return context.getColorFromAttr(colorAttribute)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// endregion
|
// endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
package org.thoughtcrime.securesms.conversation.v2.messages
|
package org.thoughtcrime.securesms.conversation.v2.messages
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.Resources
|
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.graphics.drawable.ColorDrawable
|
import android.graphics.drawable.ColorDrawable
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
|
import android.view.Gravity
|
||||||
import android.view.HapticFeedbackConstants
|
import android.view.HapticFeedbackConstants
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.widget.FrameLayout
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
|
import androidx.annotation.ColorInt
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.os.bundleOf
|
import androidx.core.os.bundleOf
|
||||||
import androidx.core.view.isInvisible
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.marginBottom
|
import androidx.core.view.marginBottom
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
@@ -27,11 +32,11 @@ import org.session.libsession.messaging.contacts.Contact
|
|||||||
import org.session.libsession.messaging.contacts.Contact.ContactContext
|
import org.session.libsession.messaging.contacts.Contact.ContactContext
|
||||||
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
import org.session.libsession.messaging.open_groups.OpenGroupApi
|
||||||
import org.session.libsession.utilities.Address
|
import org.session.libsession.utilities.Address
|
||||||
|
import org.session.libsession.utilities.TextSecurePreferences
|
||||||
import org.session.libsession.utilities.ViewUtil
|
import org.session.libsession.utilities.ViewUtil
|
||||||
import org.session.libsession.utilities.getColorFromAttr
|
import org.session.libsession.utilities.getColorFromAttr
|
||||||
|
import org.session.libsession.utilities.modifyLayoutParams
|
||||||
import org.session.libsignal.utilities.IdPrefix
|
import org.session.libsignal.utilities.IdPrefix
|
||||||
import org.session.libsignal.utilities.ThreadUtils
|
|
||||||
import org.thoughtcrime.securesms.ApplicationContext
|
|
||||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
|
||||||
import org.thoughtcrime.securesms.database.LokiAPIDatabase
|
import org.thoughtcrime.securesms.database.LokiAPIDatabase
|
||||||
import org.thoughtcrime.securesms.database.LokiThreadDatabase
|
import org.thoughtcrime.securesms.database.LokiThreadDatabase
|
||||||
@@ -42,6 +47,7 @@ import org.thoughtcrime.securesms.database.ThreadDatabase
|
|||||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||||
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
import org.thoughtcrime.securesms.groups.OpenGroupManager
|
||||||
import org.thoughtcrime.securesms.home.UserDetailsBottomSheet
|
import org.thoughtcrime.securesms.home.UserDetailsBottomSheet
|
||||||
|
import org.thoughtcrime.securesms.mms.GlideApp
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||||
import org.thoughtcrime.securesms.util.DateUtils
|
import org.thoughtcrime.securesms.util.DateUtils
|
||||||
import org.thoughtcrime.securesms.util.disableClipping
|
import org.thoughtcrime.securesms.util.disableClipping
|
||||||
@@ -55,9 +61,10 @@ import kotlin.math.min
|
|||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
private const val TAG = "VisibleMessageView"
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class VisibleMessageView : LinearLayout {
|
class VisibleMessageView : LinearLayout {
|
||||||
|
|
||||||
@Inject lateinit var threadDb: ThreadDatabase
|
@Inject lateinit var threadDb: ThreadDatabase
|
||||||
@Inject lateinit var lokiThreadDb: LokiThreadDatabase
|
@Inject lateinit var lokiThreadDb: LokiThreadDatabase
|
||||||
@Inject lateinit var lokiApiDb: LokiAPIDatabase
|
@Inject lateinit var lokiApiDb: LokiAPIDatabase
|
||||||
@@ -66,7 +73,6 @@ class VisibleMessageView : LinearLayout {
|
|||||||
@Inject lateinit var mmsDb: MmsDatabase
|
@Inject lateinit var mmsDb: MmsDatabase
|
||||||
|
|
||||||
private val binding by lazy { ViewVisibleMessageBinding.bind(this) }
|
private val binding by lazy { ViewVisibleMessageBinding.bind(this) }
|
||||||
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
|
|
||||||
private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate()
|
private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate()
|
||||||
private val swipeToReplyIconRect = Rect()
|
private val swipeToReplyIconRect = Rect()
|
||||||
private var dx = 0.0f
|
private var dx = 0.0f
|
||||||
@@ -107,7 +113,10 @@ class VisibleMessageView : LinearLayout {
|
|||||||
private fun initialize() {
|
private fun initialize() {
|
||||||
isHapticFeedbackEnabled = true
|
isHapticFeedbackEnabled = true
|
||||||
setWillNotDraw(false)
|
setWillNotDraw(false)
|
||||||
|
binding.root.disableClipping()
|
||||||
|
binding.mainContainer.disableClipping()
|
||||||
binding.messageInnerContainer.disableClipping()
|
binding.messageInnerContainer.disableClipping()
|
||||||
|
binding.messageInnerLayout.disableClipping()
|
||||||
binding.messageContentView.root.disableClipping()
|
binding.messageContentView.root.disableClipping()
|
||||||
}
|
}
|
||||||
// endregion
|
// endregion
|
||||||
@@ -115,24 +124,25 @@ class VisibleMessageView : LinearLayout {
|
|||||||
// region Updating
|
// region Updating
|
||||||
fun bind(
|
fun bind(
|
||||||
message: MessageRecord,
|
message: MessageRecord,
|
||||||
previous: MessageRecord?,
|
previous: MessageRecord? = null,
|
||||||
next: MessageRecord?,
|
next: MessageRecord? = null,
|
||||||
glide: GlideRequests,
|
glide: GlideRequests = GlideApp.with(this),
|
||||||
searchQuery: String?,
|
searchQuery: String? = null,
|
||||||
contact: Contact?,
|
contact: Contact? = null,
|
||||||
senderSessionID: String,
|
senderSessionID: String,
|
||||||
delegate: VisibleMessageViewDelegate?,
|
lastSeen: Long,
|
||||||
onAttachmentNeedsDownload: (Long, Long) -> Unit
|
delegate: VisibleMessageViewDelegate? = null,
|
||||||
|
onAttachmentNeedsDownload: (Long, Long) -> Unit,
|
||||||
|
lastSentMessageId: Long
|
||||||
) {
|
) {
|
||||||
val threadID = message.threadId
|
val threadID = message.threadId
|
||||||
val thread = threadDb.getRecipientForThreadId(threadID) ?: return
|
val thread = threadDb.getRecipientForThreadId(threadID) ?: return
|
||||||
val isGroupThread = thread.isGroupRecipient
|
val isGroupThread = thread.isGroupRecipient
|
||||||
val isStartOfMessageCluster = isStartOfMessageCluster(message, previous, isGroupThread)
|
val isStartOfMessageCluster = isStartOfMessageCluster(message, previous, isGroupThread)
|
||||||
val isEndOfMessageCluster = isEndOfMessageCluster(message, next, isGroupThread)
|
val isEndOfMessageCluster = isEndOfMessageCluster(message, next, isGroupThread)
|
||||||
// Show profile picture and sender name if this is a group thread AND
|
// Show profile picture and sender name if this is a group thread AND the message is incoming
|
||||||
// the message is incoming
|
|
||||||
binding.moderatorIconImageView.isVisible = false
|
binding.moderatorIconImageView.isVisible = false
|
||||||
binding.profilePictureView.root.visibility = when {
|
binding.profilePictureView.visibility = when {
|
||||||
thread.isGroupRecipient && !message.isOutgoing && isEndOfMessageCluster -> View.VISIBLE
|
thread.isGroupRecipient && !message.isOutgoing && isEndOfMessageCluster -> View.VISIBLE
|
||||||
thread.isGroupRecipient -> View.INVISIBLE
|
thread.isGroupRecipient -> View.INVISIBLE
|
||||||
else -> View.GONE
|
else -> View.GONE
|
||||||
@@ -141,25 +151,25 @@ class VisibleMessageView : LinearLayout {
|
|||||||
val bottomMargin = if (isEndOfMessageCluster) resources.getDimensionPixelSize(R.dimen.small_spacing)
|
val bottomMargin = if (isEndOfMessageCluster) resources.getDimensionPixelSize(R.dimen.small_spacing)
|
||||||
else ViewUtil.dpToPx(context,2)
|
else ViewUtil.dpToPx(context,2)
|
||||||
|
|
||||||
if (binding.profilePictureView.root.visibility == View.GONE) {
|
if (binding.profilePictureView.visibility == View.GONE) {
|
||||||
val expirationParams = binding.messageInnerContainer.layoutParams as MarginLayoutParams
|
val expirationParams = binding.messageInnerContainer.layoutParams as MarginLayoutParams
|
||||||
expirationParams.bottomMargin = bottomMargin
|
expirationParams.bottomMargin = bottomMargin
|
||||||
binding.messageInnerContainer.layoutParams = expirationParams
|
binding.messageInnerContainer.layoutParams = expirationParams
|
||||||
} else {
|
} else {
|
||||||
val avatarLayoutParams = binding.profilePictureView.root.layoutParams as MarginLayoutParams
|
val avatarLayoutParams = binding.profilePictureView.layoutParams as MarginLayoutParams
|
||||||
avatarLayoutParams.bottomMargin = bottomMargin
|
avatarLayoutParams.bottomMargin = bottomMargin
|
||||||
binding.profilePictureView.root.layoutParams = avatarLayoutParams
|
binding.profilePictureView.layoutParams = avatarLayoutParams
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGroupThread && !message.isOutgoing) {
|
if (isGroupThread && !message.isOutgoing) {
|
||||||
if (isEndOfMessageCluster) {
|
if (isEndOfMessageCluster) {
|
||||||
binding.profilePictureView.root.publicKey = senderSessionID
|
binding.profilePictureView.publicKey = senderSessionID
|
||||||
binding.profilePictureView.root.glide = glide
|
binding.profilePictureView.update(message.individualRecipient)
|
||||||
binding.profilePictureView.root.update(message.individualRecipient)
|
binding.profilePictureView.setOnClickListener {
|
||||||
binding.profilePictureView.root.setOnClickListener {
|
if (thread.isCommunityRecipient) {
|
||||||
if (thread.isOpenGroupRecipient) {
|
|
||||||
val openGroup = lokiThreadDb.getOpenGroupChat(threadID)
|
val openGroup = lokiThreadDb.getOpenGroupChat(threadID)
|
||||||
if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) {
|
if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) {
|
||||||
|
// TODO: support v2 soon
|
||||||
val intent = Intent(context, ConversationActivityV2::class.java)
|
val intent = Intent(context, ConversationActivityV2::class.java)
|
||||||
intent.putExtra(ConversationActivityV2.FROM_GROUP_THREAD_ID, threadID)
|
intent.putExtra(ConversationActivityV2.FROM_GROUP_THREAD_ID, threadID)
|
||||||
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(senderSessionID))
|
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(senderSessionID))
|
||||||
@@ -169,11 +179,11 @@ class VisibleMessageView : LinearLayout {
|
|||||||
maybeShowUserDetails(senderSessionID, threadID)
|
maybeShowUserDetails(senderSessionID, threadID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (thread.isOpenGroupRecipient) {
|
if (thread.isCommunityRecipient) {
|
||||||
val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return
|
val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return
|
||||||
var standardPublicKey = ""
|
var standardPublicKey = ""
|
||||||
var blindedPublicKey: String? = null
|
var blindedPublicKey: String? = null
|
||||||
if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED) {
|
if (IdPrefix.fromValue(senderSessionID)?.isBlinded() == true) {
|
||||||
blindedPublicKey = senderSessionID
|
blindedPublicKey = senderSessionID
|
||||||
} else {
|
} else {
|
||||||
standardPublicKey = senderSessionID
|
standardPublicKey = senderSessionID
|
||||||
@@ -185,49 +195,20 @@ class VisibleMessageView : LinearLayout {
|
|||||||
}
|
}
|
||||||
binding.senderNameTextView.isVisible = !message.isOutgoing && (isStartOfMessageCluster && (isGroupThread || snIsSelected))
|
binding.senderNameTextView.isVisible = !message.isOutgoing && (isStartOfMessageCluster && (isGroupThread || snIsSelected))
|
||||||
val contactContext =
|
val contactContext =
|
||||||
if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR
|
if (thread.isCommunityRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR
|
||||||
binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID
|
binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID
|
||||||
|
|
||||||
|
// Unread marker
|
||||||
|
binding.unreadMarkerContainer.isVisible = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing
|
||||||
|
|
||||||
// Date break
|
// Date break
|
||||||
val showDateBreak = isStartOfMessageCluster || snIsSelected
|
val showDateBreak = isStartOfMessageCluster || snIsSelected
|
||||||
binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null
|
binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null
|
||||||
binding.dateBreakTextView.isVisible = showDateBreak
|
binding.dateBreakTextView.isVisible = showDateBreak
|
||||||
|
|
||||||
// Message status indicator
|
// Message status indicator
|
||||||
if (message.isOutgoing) {
|
showStatusMessage(message)
|
||||||
val (iconID, iconColor, textId) = 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
val lastMessageID = mmsSmsDb.getLastMessageID(message.threadId)
|
|
||||||
binding.messageStatusTextView.isVisible = (
|
|
||||||
textId != null && (
|
|
||||||
!message.isSent ||
|
|
||||||
message.id == lastMessageID
|
|
||||||
)
|
|
||||||
)
|
|
||||||
binding.messageStatusImageView.isVisible = (
|
|
||||||
iconID != null && (
|
|
||||||
!message.isSent ||
|
|
||||||
message.id == lastMessageID
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
binding.messageStatusTextView.isVisible = false
|
|
||||||
binding.messageStatusImageView.isVisible = false
|
|
||||||
}
|
|
||||||
// Expiration timer
|
|
||||||
updateExpirationTimer(message)
|
|
||||||
// Emoji Reactions
|
// Emoji Reactions
|
||||||
val emojiLayoutParams = binding.emojiReactionsView.root.layoutParams as ConstraintLayout.LayoutParams
|
val emojiLayoutParams = binding.emojiReactionsView.root.layoutParams as ConstraintLayout.LayoutParams
|
||||||
emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
|
emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
|
||||||
@@ -262,95 +243,115 @@ class VisibleMessageView : LinearLayout {
|
|||||||
onDoubleTap = { binding.messageContentView.root.onContentDoubleTap?.invoke() }
|
onDoubleTap = { binding.messageContentView.root.onContentDoubleTap?.invoke() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean {
|
private fun showStatusMessage(message: MessageRecord) {
|
||||||
return if (isGroupThread) {
|
|
||||||
previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp)
|
val scheduledToDisappear = message.expiresIn > 0
|
||||||
|| current.recipient.address != previous.recipient.address
|
|
||||||
|
binding.messageInnerLayout.modifyLayoutParams<FrameLayout.LayoutParams> {
|
||||||
|
gravity = if (message.isOutgoing) Gravity.END else Gravity.START
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.statusContainer.modifyLayoutParams<ConstraintLayout.LayoutParams> {
|
||||||
|
horizontalBias = if (message.isOutgoing) 1f else 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.expirationTimerView.isGone = true
|
||||||
|
|
||||||
|
if (message.isOutgoing || scheduledToDisappear) {
|
||||||
|
val (iconID, iconColor, textId) = getMessageStatusImage(message)
|
||||||
|
textId?.let(binding.messageStatusTextView::setText)
|
||||||
|
iconColor?.let(binding.messageStatusTextView::setTextColor)
|
||||||
|
iconID?.let { ContextCompat.getDrawable(context, it) }
|
||||||
|
?.run { iconColor?.let { mutate().apply { setTint(it) } } ?: this }
|
||||||
|
?.let(binding.messageStatusImageView::setImageDrawable)
|
||||||
|
|
||||||
|
// Always show the delivery status of the last sent message
|
||||||
|
val thisUsersSessionId = TextSecurePreferences.getLocalNumber(context)
|
||||||
|
val lastSentMessageId = mmsSmsDb.getLastSentMessageFromSender(message.threadId, thisUsersSessionId)
|
||||||
|
val isLastSentMessage = lastSentMessageId == message.id
|
||||||
|
|
||||||
|
binding.messageStatusTextView.isVisible = textId != null && (isLastSentMessage || scheduledToDisappear)
|
||||||
|
val showTimer = scheduledToDisappear && !message.isPending
|
||||||
|
binding.messageStatusImageView.isVisible = iconID != null && !showTimer && (!message.isSent || isLastSentMessage)
|
||||||
|
|
||||||
|
binding.messageStatusImageView.bringToFront()
|
||||||
|
binding.expirationTimerView.bringToFront()
|
||||||
|
binding.expirationTimerView.isVisible = showTimer
|
||||||
|
if (showTimer) updateExpirationTimer(message)
|
||||||
} else {
|
} else {
|
||||||
previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp)
|
binding.messageStatusTextView.isVisible = false
|
||||||
|| current.isOutgoing != previous.isOutgoing
|
binding.messageStatusImageView.isVisible = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isEndOfMessageCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean {
|
private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean =
|
||||||
return if (isGroupThread) {
|
previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp) || if (isGroupThread) {
|
||||||
next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp)
|
current.recipient.address != previous.recipient.address
|
||||||
|| current.recipient.address != next.recipient.address
|
|
||||||
} else {
|
} else {
|
||||||
next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp)
|
current.isOutgoing != previous.isOutgoing
|
||||||
|| current.isOutgoing != next.isOutgoing
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun getMessageStatusImage(message: MessageRecord): Triple<Int?,Int?,Int?> {
|
private fun isEndOfMessageCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean =
|
||||||
return when {
|
next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp) || if (isGroupThread) {
|
||||||
!message.isOutgoing -> Triple(null, null, null)
|
current.recipient.address != next.recipient.address
|
||||||
message.isFailed ->
|
} else {
|
||||||
Triple(R.drawable.ic_delivery_status_failed, resources.getColor(R.color.destructive, context.theme), R.string.delivery_status_failed)
|
current.isOutgoing != next.isOutgoing
|
||||||
message.isPending ->
|
|
||||||
Triple(R.drawable.ic_delivery_status_sending, context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending)
|
|
||||||
message.isRead ->
|
|
||||||
Triple(R.drawable.ic_delivery_status_read, context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read)
|
|
||||||
else ->
|
|
||||||
Triple(R.drawable.ic_delivery_status_sent, context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class MessageStatusInfo(@DrawableRes val iconId: Int?,
|
||||||
|
@ColorInt val iconTint: Int?,
|
||||||
|
@StringRes val messageText: Int?)
|
||||||
|
|
||||||
|
private fun getMessageStatusImage(message: MessageRecord): MessageStatusInfo = when {
|
||||||
|
message.isFailed ->
|
||||||
|
MessageStatusInfo(
|
||||||
|
R.drawable.ic_delivery_status_failed,
|
||||||
|
resources.getColor(R.color.destructive, context.theme),
|
||||||
|
R.string.delivery_status_failed
|
||||||
|
)
|
||||||
|
message.isSyncFailed ->
|
||||||
|
MessageStatusInfo(
|
||||||
|
R.drawable.ic_delivery_status_failed,
|
||||||
|
context.getColor(R.color.accent_orange),
|
||||||
|
R.string.delivery_status_sync_failed
|
||||||
|
)
|
||||||
|
message.isPending ->
|
||||||
|
MessageStatusInfo(
|
||||||
|
R.drawable.ic_delivery_status_sending,
|
||||||
|
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending
|
||||||
|
)
|
||||||
|
message.isResyncing ->
|
||||||
|
MessageStatusInfo(
|
||||||
|
R.drawable.ic_delivery_status_sending,
|
||||||
|
context.getColor(R.color.accent_orange), R.string.delivery_status_syncing
|
||||||
|
)
|
||||||
|
message.isRead || !message.isOutgoing ->
|
||||||
|
MessageStatusInfo(
|
||||||
|
R.drawable.ic_delivery_status_read,
|
||||||
|
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read
|
||||||
|
)
|
||||||
|
else ->
|
||||||
|
MessageStatusInfo(
|
||||||
|
R.drawable.ic_delivery_status_sent,
|
||||||
|
context.getColorFromAttr(R.attr.message_status_color),
|
||||||
|
R.string.delivery_status_sent
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateExpirationTimer(message: MessageRecord) {
|
private fun updateExpirationTimer(message: MessageRecord) {
|
||||||
val container = binding.messageInnerContainer
|
if (!message.isOutgoing) binding.messageStatusTextView.bringToFront()
|
||||||
val content = binding.messageContentView.root
|
binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
|
||||||
val expiration = binding.expirationTimerView
|
|
||||||
val spacing = binding.messageContentSpacing
|
|
||||||
container.removeAllViewsInLayout()
|
|
||||||
container.addView(if (message.isOutgoing) expiration else content)
|
|
||||||
container.addView(if (message.isOutgoing) content else expiration)
|
|
||||||
container.addView(spacing, if (message.isOutgoing) 0 else 2)
|
|
||||||
val containerParams = container.layoutParams as ConstraintLayout.LayoutParams
|
|
||||||
containerParams.horizontalBias = if (message.isOutgoing) 1f else 0f
|
|
||||||
container.layoutParams = containerParams
|
|
||||||
if (message.expiresIn > 0 && !message.isPending) {
|
|
||||||
binding.expirationTimerView.setColorFilter(context.getColorFromAttr(android.R.attr.textColorPrimary))
|
|
||||||
binding.expirationTimerView.isInvisible = false
|
|
||||||
binding.expirationTimerView.setPercentComplete(0.0f)
|
|
||||||
if (message.expireStarted > 0) {
|
|
||||||
binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
|
|
||||||
binding.expirationTimerView.startAnimation()
|
|
||||||
if (message.expireStarted + message.expiresIn <= System.currentTimeMillis()) {
|
|
||||||
ApplicationContext.getInstance(context).expiringMessageManager.checkSchedule()
|
|
||||||
}
|
|
||||||
} else if (!message.isMediaPending) {
|
|
||||||
binding.expirationTimerView.setPercentComplete(0.0f)
|
|
||||||
binding.expirationTimerView.stopAnimation()
|
|
||||||
ThreadUtils.queue {
|
|
||||||
val expirationManager = ApplicationContext.getInstance(context).expiringMessageManager
|
|
||||||
val id = message.getId()
|
|
||||||
val mms = message.isMms
|
|
||||||
if (mms) mmsDb.markExpireStarted(id) else smsDb.markExpireStarted(id)
|
|
||||||
expirationManager.scheduleDeletion(id, mms, message.expiresIn)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
binding.expirationTimerView.stopAnimation()
|
|
||||||
binding.expirationTimerView.setPercentComplete(0.0f)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
binding.expirationTimerView.isInvisible = true
|
|
||||||
}
|
|
||||||
container.requestLayout()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleIsSelectedChanged() {
|
private fun handleIsSelectedChanged() {
|
||||||
background = if (snIsSelected) {
|
background = if (snIsSelected) ColorDrawable(context.getColorFromAttr(R.attr.message_selected)) else null
|
||||||
ColorDrawable(context.getColorFromAttr(R.attr.message_selected))
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDraw(canvas: Canvas) {
|
override fun onDraw(canvas: Canvas) {
|
||||||
val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing)
|
val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing)
|
||||||
val iconSize = toPx(24, context.resources)
|
val iconSize = toPx(24, context.resources)
|
||||||
val left = binding.messageInnerContainer.left + binding.messageContentView.root.right + spacing
|
val left = binding.messageInnerContainer.left + binding.messageContentView.root.right + spacing
|
||||||
val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.root.marginBottom - (iconSize / 2)
|
val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.marginBottom - (iconSize / 2)
|
||||||
val right = left + iconSize
|
val right = left + iconSize
|
||||||
val bottom = top + iconSize
|
val bottom = top + iconSize
|
||||||
swipeToReplyIconRect.left = left
|
swipeToReplyIconRect.left = left
|
||||||
@@ -370,12 +371,17 @@ class VisibleMessageView : LinearLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun recycle() {
|
fun recycle() {
|
||||||
binding.profilePictureView.root.recycle()
|
binding.profilePictureView.recycle()
|
||||||
binding.messageContentView.root.recycle()
|
binding.messageContentView.root.recycle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun playHighlight() {
|
||||||
|
binding.messageContentView.root.playHighlight()
|
||||||
|
}
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Interaction
|
// region Interaction
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||||
if (onPress == null || onSwipeToReply == null || onLongPress == null) { return false }
|
if (onPress == null || onSwipeToReply == null || onLongPress == null) { return false }
|
||||||
when (event.action) {
|
when (event.action) {
|
||||||
@@ -467,7 +473,7 @@ class VisibleMessageView : LinearLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onContentClick(event: MotionEvent) {
|
fun onContentClick(event: MotionEvent) {
|
||||||
binding.messageContentView.root.onContentClick.iterator().forEach { clickHandler -> clickHandler.invoke(event) }
|
binding.messageContentView.root.onContentClick(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onPress(event: MotionEvent) {
|
private fun onPress(event: MotionEvent) {
|
||||||
@@ -476,14 +482,13 @@ class VisibleMessageView : LinearLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun maybeShowUserDetails(publicKey: String, threadID: Long) {
|
private fun maybeShowUserDetails(publicKey: String, threadID: Long) {
|
||||||
val userDetailsBottomSheet = UserDetailsBottomSheet()
|
UserDetailsBottomSheet().apply {
|
||||||
val bundle = bundleOf(
|
arguments = bundleOf(
|
||||||
UserDetailsBottomSheet.ARGUMENT_PUBLIC_KEY to publicKey,
|
UserDetailsBottomSheet.ARGUMENT_PUBLIC_KEY to publicKey,
|
||||||
UserDetailsBottomSheet.ARGUMENT_THREAD_ID to threadID
|
UserDetailsBottomSheet.ARGUMENT_THREAD_ID to threadID
|
||||||
)
|
)
|
||||||
userDetailsBottomSheet.arguments = bundle
|
show((this@VisibleMessageView.context as AppCompatActivity).supportFragmentManager, tag)
|
||||||
val activity = context as AppCompatActivity
|
}
|
||||||
userDetailsBottomSheet.show(activity.supportFragmentManager, userDetailsBottomSheet.tag)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playVoiceMessage() {
|
fun playVoiceMessage() {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener {
|
|||||||
if (progress == 1.0) {
|
if (progress == 1.0) {
|
||||||
togglePlayback()
|
togglePlayback()
|
||||||
handleProgressChanged(0.0)
|
handleProgressChanged(0.0)
|
||||||
delegate?.playVoiceMessageAtIndexIfPossible(indexInAdapter - 1)
|
delegate?.playVoiceMessageAtIndexIfPossible(indexInAdapter + 1)
|
||||||
} else {
|
} else {
|
||||||
handleProgressChanged(progress)
|
handleProgressChanged(progress)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user