Merge remote-tracking branch 'upstream/dev' into closed_groups

# Conflicts:
#	app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java
#	app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java
#	app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt
#	app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt
#	app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt
#	app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt
#	app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt
#	app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java
#	app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt
#	app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java
#	app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
#	app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt
#	app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt
#	app/src/main/res/drawable/profile_picture_view_large_background.xml
#	app/src/main/res/layout/dialog_download.xml
#	app/src/main/res/layout/view_untrusted_attachment.xml
#	app/src/main/res/values/strings.xml
#	app/src/test/java/org/thoughtcrime/securesms/util/OpenGroupMigrationTests.kt
#	libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt
#	libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt
#	libsession/src/main/java/org/session/libsession/utilities/bencode/Bencode.kt
#	libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java
#	libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java
#	libsignal/src/main/java/org/session/libsignal/utilities/IdPrefix.kt
This commit is contained in:
0x330a 2023-08-23 16:45:24 +10:00
commit ff057d7110
524 changed files with 16783 additions and 23738 deletions

2
.gitignore vendored
View File

@ -15,4 +15,4 @@ signing.properties
ffpr ffpr
*.sh *.sh
pkcs11.password pkcs11.password
play app/play

3
.gitmodules vendored Normal file
View File

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

View File

@ -32,6 +32,13 @@ Setting up a development environment and building from Android Studio
4. Android Studio should detect the presence of a project file and ask you whether to open it. Click "yes". 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
----------------- -----------------

View File

@ -4,13 +4,13 @@
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?

View File

@ -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 = 354
implementation "androidx.appcompat:appcompat:$appcompatVersion" def canonicalVersionName = "1.17.0"
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
@ -250,14 +129,28 @@ android {
flavorDimensions "distribution" flavorDimensions "distribution"
productFlavors { productFlavors {
play { play {
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"
} }
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"
}
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\""
} }
} }
@ -288,6 +181,166 @@ 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)
}
}
}
}
}
}
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.8.0@aar"
implementation "com.google.protobuf:protobuf-java:$protobufVersion"
implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion"
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation 'app.cash.copper:copper-flow:1.0.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion"
implementation "com.jakewharton.rxbinding3:rxbinding:3.1.0"
implementation "com.github.tbruyelle:rxpermissions:0.10.2"
implementation "com.github.ybq:Android-SpinKit:1.4.0"
implementation "com.opencsv:opencsv:4.6"
testImplementation "junit:junit:$junitVersion"
testImplementation 'org.assertj:assertj-core:3.11.1'
testImplementation "org.mockito:mockito-inline:4.10.0"
testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
androidTestImplementation "org.mockito:mockito-android:4.10.0"
androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
testImplementation "androidx.test:core:$testCoreVersion"
testImplementation "androidx.arch.core:core-testing:2.2.0"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
// Core library
androidTestImplementation "androidx.test:core:$testCoreVersion"
androidTestImplementation('com.adevinta.android:barista:4.2.0') {
exclude group: 'org.jetbrains.kotlin'
}
// AndroidJUnitRunner and JUnit Rules
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
// Assertions
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.ext:truth:1.5.0'
androidTestImplementation 'com.google.truth:truth:1.1.3'
// Espresso dependencies
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1'
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
androidTestUtil 'androidx.test:orchestrator:1.4.2'
testImplementation 'org.robolectric:robolectric:4.4'
testImplementation 'org.robolectric:shadows-multidex:4.4'
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.1'
implementation 'androidx.compose.ui:ui:1.4.3'
implementation 'androidx.compose.ui:ui-tooling:1.4.3'
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.31.5-beta"
implementation "com.google.accompanist:accompanist-pager-indicators:0.31.5-beta"
implementation "androidx.compose.runtime:runtime-livedata:1.4.3"
implementation 'androidx.compose.foundation:foundation-layout:1.5.0-alpha02'
implementation 'androidx.compose.material:material:1.5.0-alpha02'
} }
static def getLastCommitTimestamp() { static def getLastCommitTimestamp() {
@ -308,3 +361,8 @@ def autoResConfig() {
.collect { matcher -> matcher.group(1) } .collect { matcher -> matcher.group(1) }
.sort() .sort()
} }
// Allow references to generated code
kapt {
correctErrorTypes = true
}

View File

@ -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())
} }

View File

@ -0,0 +1,102 @@
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.util.Contact
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.argThat
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.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.Companion.newInstance(key)
contactList.forEach { contact ->
contacts.set(contact)
}
return contacts.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
})
verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true))
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -29,12 +29,16 @@
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="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" />
@ -313,14 +317,6 @@
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:exported="false" /> android:exported="false" />
<service <service
@ -414,12 +410,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>
@ -453,17 +443,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" />

View File

@ -41,6 +41,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;
@ -56,35 +58,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.groups.OpenGroupMigrator;
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;
@ -114,7 +110,8 @@ import dagger.hilt.EntryPoints;
import dagger.hilt.android.HiltAndroidApp; import dagger.hilt.android.HiltAndroidApp;
import kotlin.Unit; import kotlin.Unit;
import kotlinx.coroutines.Job; import kotlinx.coroutines.Job;
import network.loki.messenger.BuildConfig; import network.loki.messenger.libsession_util.ConfigBase;
import network.loki.messenger.libsession_util.UserProfile;
/** /**
* Will be called once when the TextSecure process is created. * Will be called once when the TextSecure process is created.
@ -125,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";
@ -134,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;
@ -147,10 +143,12 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
private PersistentLogger persistentLogger; private PersistentLogger persistentLogger;
@Inject LokiAPIDatabase lokiAPIDatabase; @Inject LokiAPIDatabase lokiAPIDatabase;
@Inject StorageProtocol 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;
@ -169,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() {
@ -198,18 +196,28 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
return this.persistentLogger; return this.persistentLogger;
} }
@Override
public void notifyUpdates(@NonNull ConfigBase forConfigObject) {
// forward to the config factory / storage ig
if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) {
textSecurePreferences.setConfigurationMessageSynced(true);
}
storage.notifyConfigUpdates(forConfigObject);
}
@Override @Override
public void onCreate() { public void onCreate() {
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),
// migrate session open group data configFactory
OpenGroupMigrator.migrate(getDatabaseComponent()); );
// end migration
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();
@ -223,10 +231,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();
@ -234,7 +238,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();
@ -277,7 +280,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
@ -291,10 +294,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;
} }
@ -357,16 +356,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);
} }
@ -380,7 +369,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() {
@ -389,10 +378,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() {
@ -443,29 +428,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;
@ -473,7 +435,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() {
@ -516,6 +478,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
}); });
} catch (Exception exception) { } catch (Exception exception) {
// Do nothing // Do nothing
Log.e("Loki-Avatar", "Uploading avatar failed", exception);
} }
}); });
} }
@ -535,24 +498,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));
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -57,6 +57,7 @@ import org.session.libsession.database.StorageProtocol;
import org.session.libsession.messaging.MessagingModuleConfiguration; import org.session.libsession.messaging.MessagingModuleConfiguration;
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.session.libsession.utilities.GroupRecord; import org.session.libsession.utilities.GroupRecord;
import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.TextSecurePreferences;
@ -356,9 +357,9 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity i
@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)
@ -400,34 +401,25 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity i
}.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); () ->
builder.setCancelable(true); new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>(requireContext(),
builder.setPositiveButton(R.string.delete, (dialogInterface, i) -> {
new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>(getContext(),
R.string.MediaOverviewActivity_Media_delete_progress_title, R.string.MediaOverviewActivity_Media_delete_progress_title,
R.string.MediaOverviewActivity_Media_delete_progress_message) { R.string.MediaOverviewActivity_Media_delete_progress_message) {
@Override @Override
@ -442,11 +434,8 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity i
return null; return null;
} }
}.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()])); }.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()])));
}); }
builder.setNegativeButton(android.R.string.cancel, null);
builder.show();
}
private void handleSelectAllMedia() { private void handleSelectAllMedia() {
getListAdapter().selectAllMedia(); getListAdapter().selectAllMedia();

View File

@ -60,6 +60,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 +85,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 +147,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 +421,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 +429,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 +438,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 +455,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,7 +528,6 @@ 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);

View File

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

View File

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

View File

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

View File

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

View File

@ -210,8 +210,7 @@ public class PassphrasePromptActivity extends BaseActionBarActivity {
try { try {
signature = biometricSecretProvider.getOrCreateBiometricSignature(this); signature = biometricSecretProvider.getOrCreateBiometricSignature(this);
hasSignatureObject = true; hasSignatureObject = true;
throw new InvalidKeyException("e"); } catch (Exception e) {
} catch (InvalidKeyException e) {
signature = null; signature = null;
hasSignatureObject = false; hasSignatureObject = false;
Log.e(TAG, "Error getting / creating signature", e); Log.e(TAG, "Error getting / creating signature", e);

View File

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

View File

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

View File

@ -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)
} }
} }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -249,17 +249,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)
} }

View File

@ -13,6 +13,7 @@ import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import org.session.libsession.snode.SnodeAPI;
import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView; import org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
@ -106,7 +107,7 @@ public class ConversationItemFooter extends LinearLayout {
messageRecord.getExpiresIn()); messageRecord.getExpiresIn());
this.timerView.startAnimation(); this.timerView.startAnimation();
if (messageRecord.getExpireStarted() + messageRecord.getExpiresIn() <= System.currentTimeMillis()) { if (messageRecord.getExpireStarted() + messageRecord.getExpiresIn() <= SnodeAPI.getNowWithOffset()) {
ApplicationContext.getInstance(getContext()).getExpiringMessageManager().checkSchedule(); ApplicationContext.getInstance(getContext()).getExpiringMessageManager().checkSchedule();
} }
} else if (!messageRecord.isOutgoing() && !messageRecord.isMediaPending()) { } else if (!messageRecord.isOutgoing() && !messageRecord.isMediaPending()) {

View File

@ -4,30 +4,48 @@ import android.graphics.Bitmap;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import android.view.View;
import android.widget.ImageView; import android.widget.ImageView;
import com.bumptech.glide.request.target.BitmapImageViewTarget; import com.bumptech.glide.request.target.BitmapImageViewTarget;
import org.session.libsignal.utilities.SettableFuture; import org.session.libsignal.utilities.SettableFuture;
import java.lang.ref.WeakReference;
public class GlideBitmapListeningTarget extends BitmapImageViewTarget { public class GlideBitmapListeningTarget extends BitmapImageViewTarget {
private final SettableFuture<Boolean> loaded; private final SettableFuture<Boolean> loaded;
private final WeakReference<View> loadingView;
public GlideBitmapListeningTarget(@NonNull ImageView view, @NonNull SettableFuture<Boolean> loaded) { public GlideBitmapListeningTarget(@NonNull ImageView view, @Nullable View loadingView, @NonNull SettableFuture<Boolean> loaded) {
super(view); super(view);
this.loaded = loaded; this.loaded = loaded;
this.loadingView = new WeakReference<View>(loadingView);
} }
@Override @Override
protected void setResource(@Nullable Bitmap resource) { protected void setResource(@Nullable Bitmap resource) {
super.setResource(resource); super.setResource(resource);
loaded.set(true); loaded.set(true);
View loadingViewInstance = loadingView.get();
if (loadingViewInstance != null) {
loadingViewInstance.setVisibility(View.GONE);
}
} }
@Override @Override
public void onLoadFailed(@Nullable Drawable errorDrawable) { public void onLoadFailed(@Nullable Drawable errorDrawable) {
super.onLoadFailed(errorDrawable); super.onLoadFailed(errorDrawable);
loaded.set(true); loaded.set(true);
View loadingViewInstance = loadingView.get();
if (loadingViewInstance != null) {
loadingViewInstance.setVisibility(View.GONE);
}
} }
} }

View File

@ -3,30 +3,48 @@ package org.thoughtcrime.securesms.components;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import android.view.View;
import android.widget.ImageView; import android.widget.ImageView;
import com.bumptech.glide.request.target.DrawableImageViewTarget; import com.bumptech.glide.request.target.DrawableImageViewTarget;
import org.session.libsignal.utilities.SettableFuture; import org.session.libsignal.utilities.SettableFuture;
import java.lang.ref.WeakReference;
public class GlideDrawableListeningTarget extends DrawableImageViewTarget { public class GlideDrawableListeningTarget extends DrawableImageViewTarget {
private final SettableFuture<Boolean> loaded; private final SettableFuture<Boolean> loaded;
private final WeakReference<View> loadingView;
public GlideDrawableListeningTarget(@NonNull ImageView view, @NonNull SettableFuture<Boolean> loaded) { public GlideDrawableListeningTarget(@NonNull ImageView view, @Nullable View loadingView, @NonNull SettableFuture<Boolean> loaded) {
super(view); super(view);
this.loaded = loaded; this.loaded = loaded;
this.loadingView = new WeakReference<View>(loadingView);
} }
@Override @Override
protected void setResource(@Nullable Drawable resource) { protected void setResource(@Nullable Drawable resource) {
super.setResource(resource); super.setResource(resource);
loaded.set(true); loaded.set(true);
View loadingViewInstance = loadingView.get();
if (loadingViewInstance != null) {
loadingViewInstance.setVisibility(View.GONE);
}
} }
@Override @Override
public void onLoadFailed(@Nullable Drawable errorDrawable) { public void onLoadFailed(@Nullable Drawable errorDrawable) {
super.onLoadFailed(errorDrawable); super.onLoadFailed(errorDrawable);
loaded.set(true); loaded.set(true);
View loadingViewInstance = loadingView.get();
if (loadingViewInstance != null) {
loadingViewInstance.setVisibility(View.GONE);
}
} }
} }

View File

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

View File

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

View File

@ -2,6 +2,7 @@ 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
@ -9,6 +10,7 @@ import androidx.annotation.DimenRes
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewProfilePictureBinding import network.loki.messenger.databinding.ViewProfilePictureBinding
import network.loki.messenger.databinding.ViewUserBinding
import org.session.libsession.avatars.ContactColors import org.session.libsession.avatars.ContactColors
import org.session.libsession.avatars.PlaceholderAvatarPhoto import org.session.libsession.avatars.PlaceholderAvatarPhoto
import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.avatars.ProfileContactPhoto
@ -18,13 +20,14 @@ 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
@ -32,13 +35,18 @@ class ProfilePictureView @JvmOverloads constructor(
var isLarge = false var isLarge = false
private val profilePicturesCache = mutableMapOf<String, String?>() private val profilePicturesCache = mutableMapOf<String, String?>()
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,12 +60,19 @@ 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.getDecodedOpenGroupInbox(recipient.address.serialize())
this.publicKey = publicKey this.publicKey = publicKey
@ -73,7 +88,6 @@ 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) {
@ -108,30 +122,36 @@ class ProfilePictureView @JvmOverloads constructor(
val signalProfilePicture = recipient.contactPhoto val signalProfilePicture = recipient.contactPhoto
val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject
val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
if (signalProfilePicture != null && avatar != "0" && avatar != "") { if (signalProfilePicture != null && avatar != "0" && avatar != "") {
glide.clear(imageView) 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.isOpenGroupRecipient && 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.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 profilePicturesCache[publicKey] = recipient.profileAvatar
} else { } else {
imageView.setImageDrawable(null) glide.load(unknownRecipientDrawable)
.centerCrop()
.into(imageView)
} }
} }

View File

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

View File

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

View File

@ -5,8 +5,9 @@ import androidx.annotation.AttrRes
/** /**
* Represents an action to be rendered * Represents an action to be rendered
*/ */
data class ActionItem( data class ActionItem @JvmOverloads constructor(
@AttrRes val iconRes: Int, @AttrRes val iconRes: Int,
val title: CharSequence, val title: CharSequence,
val action: Runnable val action: Runnable,
val contentDescription: String? = null
) )

View File

@ -77,6 +77,7 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
context.theme.resolveAttribute(model.item.iconRes, typedValue, true) context.theme.resolveAttribute(model.item.iconRes, typedValue, true)
icon.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId)) icon.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId))
} }
itemView.contentDescription = model.item.contentDescription
title.text = model.item.title title.text = model.item.title
itemView.setOnClickListener { itemView.setOnClickListener {
model.item.action.run() model.item.action.run()

View File

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

View File

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

View File

@ -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
} }

View File

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

View File

@ -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()
} }
} }

View File

@ -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 }

View File

@ -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
@ -31,10 +30,15 @@ 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 +56,8 @@ 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)
init { init {
lifecycleCoroutineScope.launch(IO) { lifecycleCoroutineScope.launch(IO) {
while (isActive) { while (isActive) {
@ -128,6 +134,7 @@ class ConversationAdapter(
searchQuery, searchQuery,
contact, contact,
senderId, senderId,
lastSeen.get(),
visibleMessageViewDelegate, visibleMessageViewDelegate,
onAttachmentNeedsDownload onAttachmentNeedsDownload
) )
@ -146,17 +153,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,14 +190,18 @@ 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
} }
@ -219,11 +228,30 @@ class ConversationAdapter(
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 +261,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 +271,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
}
} }

View File

@ -81,6 +81,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
private View dropdownAnchor; private View dropdownAnchor;
private LinearLayout conversationItem; private LinearLayout conversationItem;
private View conversationBubble;
private TextView conversationTimestamp;
private View backgroundView; private View backgroundView;
private ConstraintLayout foregroundView; private ConstraintLayout foregroundView;
private EmojiImageView[] emojiViews; private EmojiImageView[] emojiViews;
@ -116,6 +118,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
dropdownAnchor = findViewById(R.id.dropdown_anchor); dropdownAnchor = findViewById(R.id.dropdown_anchor);
conversationItem = findViewById(R.id.conversation_item); 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); backgroundView = findViewById(R.id.conversation_reaction_scrubber_background);
foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground); foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground);
@ -165,10 +169,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap(); Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
View conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble);
conversationBubble.setLayoutParams(new LinearLayout.LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight())); conversationBubble.setLayoutParams(new LinearLayout.LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight()));
conversationBubble.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot)); conversationBubble.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot));
TextView conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp);
conversationTimestamp.setText(DateUtils.getDisplayFormattedTimeSpanString(getContext(), Locale.getDefault(), messageRecord.getTimestamp())); conversationTimestamp.setText(DateUtils.getDisplayFormattedTimeSpanString(getContext(), Locale.getDefault(), messageRecord.getTimestamp()));
updateConversationTimestamp(messageRecord); updateConversationTimestamp(messageRecord);
@ -190,12 +192,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
} }
private void updateConversationTimestamp(MessageRecord message) { private void updateConversationTimestamp(MessageRecord message) {
View bubble = conversationItem.findViewById(R.id.conversation_item_bubble); if (message.isOutgoing()) conversationBubble.bringToFront();
View timestamp = conversationItem.findViewById(R.id.conversation_item_timestamp); else conversationTimestamp.bringToFront();
conversationItem.removeAllViewsInLayout();
conversationItem.addView(message.isOutgoing() ? timestamp : bubble);
conversationItem.addView(message.isOutgoing() ? bubble : timestamp);
conversationItem.requestLayout();
} }
private void showAfterLayout(@NonNull MessageRecord messageRecord, private void showAfterLayout(@NonNull MessageRecord messageRecord,
@ -203,10 +201,11 @@ public final class ConversationReactionOverlay extends FrameLayout {
boolean isMessageOnLeft) { boolean isMessageOnLeft) {
contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord)); contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord));
float itemX = isMessageOnLeft ? scrubberHorizontalMargin : float endX = isMessageOnLeft ? scrubberHorizontalMargin :
selectedConversationModel.getBubbleX() - conversationItem.getWidth() + selectedConversationModel.getBubbleWidth(); selectedConversationModel.getBubbleX() - conversationItem.getWidth() + selectedConversationModel.getBubbleWidth();
conversationItem.setX(itemX); float endY = selectedConversationModel.getBubbleY() - statusBarHeight;
conversationItem.setY(selectedConversationModel.getBubbleY() - statusBarHeight); conversationItem.setX(endX);
conversationItem.setY(endY);
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap(); Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth(); boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth();
@ -214,8 +213,6 @@ public final class ConversationReactionOverlay extends FrameLayout {
int overlayHeight = getHeight(); int overlayHeight = getHeight();
int bubbleWidth = selectedConversationModel.getBubbleWidth(); int bubbleWidth = selectedConversationModel.getBubbleWidth();
float endX = itemX;
float endY = conversationItem.getY();
float endApparentTop = endY; float endApparentTop = endY;
float endScale = 1f; float endScale = 1f;
@ -265,9 +262,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
} }
} else { } else {
endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.getHeight(); endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.getHeight();
reactionBarBackgroundY = endY - reactionBarHeight - menuPadding;
float contextMenuTop = endY + conversationItemSnapshot.getHeight();
reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
} }
endApparentTop = endY; endApparentTop = endY;
@ -354,11 +349,14 @@ public final class ConversationReactionOverlay extends FrameLayout {
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration); int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
conversationBubble.animate()
.scaleX(endScale)
.scaleY(endScale)
.setDuration(revealDuration);
conversationItem.animate() conversationItem.animate()
.x(endX) .x(endX)
.y(endY) .y(endY)
.scaleX(endScale)
.scaleY(endScale)
.setDuration(revealDuration); .setDuration(revealDuration);
} }
@ -660,10 +658,15 @@ public final class ConversationReactionOverlay extends FrameLayout {
String userPublicKey = TextSecurePreferences.getLocalNumber(getContext()); String userPublicKey = TextSecurePreferences.getLocalNumber(getContext());
// Select message // Select message
items.add(new ActionItem(R.attr.menu_select_icon, getContext().getResources().getString(R.string.conversation_context__menu_select), () -> handleActionItemClicked(Action.SELECT))); items.add(new ActionItem(R.attr.menu_select_icon, getContext().getResources().getString(R.string.conversation_context__menu_select), () -> handleActionItemClicked(Action.SELECT),
getContext().getResources().getString(R.string.AccessibilityId_select)));
// Reply // Reply
if (!message.isPending() && !message.isFailed()) { boolean canWrite = openGroup == null || openGroup.getCanWrite();
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_reply), () -> handleActionItemClicked(Action.REPLY))); if (canWrite && !message.isPending() && !message.isFailed()) {
items.add(
new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_reply), () -> handleActionItemClicked(Action.REPLY),
getContext().getResources().getString(R.string.AccessibilityId_reply_message))
);
} }
// Copy message text // Copy message text
if (!containsControlMessage && hasText) { if (!containsControlMessage && hasText) {
@ -671,11 +674,17 @@ public final class ConversationReactionOverlay extends FrameLayout {
} }
// Copy Session ID // Copy Session ID
if (recipient.isGroupRecipient() && !recipient.isOpenGroupRecipient() && !message.getRecipient().getAddress().toString().equals(userPublicKey)) { 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))); 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 // Delete message
if (ConversationMenuItemHelper.userCanDeleteSelectedItems(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) { 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))); items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.delete),
() -> handleActionItemClicked(Action.DELETE),
getContext().getResources().getString(R.string.AccessibilityId_delete_message)
)
);
} }
// Ban user // Ban user
if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) { if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) {
@ -686,16 +695,20 @@ public final class ConversationReactionOverlay extends FrameLayout {
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))); 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 // 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)));
items.add(new ActionItem(R.attr.menu_info_icon, getContext().getResources().getString(R.string.conversation_context__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO)));
}
// Resend // Resend
if (message.isFailed()) { 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))); items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resend_message), () -> handleActionItemClicked(Action.RESEND)));
} }
// Resync
if (message.isSyncFailed()) {
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resync_message), () -> handleActionItemClicked(Action.RESYNC)));
}
// Save media // Save media
if (message.isMms() && ((MediaMmsMessageRecord)message).containsMediaSlide()) { 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))); items.add(new ActionItem(R.attr.menu_save_icon, getContext().getResources().getString(R.string.conversation_context_image__save_attachment), () -> handleActionItemClicked(Action.DOWNLOAD),
getContext().getResources().getString(R.string.AccessibilityId_save_attachment))
);
} }
backgroundView.setVisibility(View.VISIBLE); backgroundView.setVisibility(View.VISIBLE);
@ -876,6 +889,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
public enum Action { public enum Action {
REPLY, REPLY,
RESEND, RESEND,
RESYNC,
DOWNLOAD, DOWNLOAD,
COPY_MESSAGE, COPY_MESSAGE,
COPY_SESSION_ID, COPY_SESSION_ID,

View File

@ -1,8 +1,10 @@
package org.thoughtcrime.securesms.conversation.v2 package org.thoughtcrime.securesms.conversation.v2
import android.content.ContentResolver
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.cash.copper.flow.observeQuery
import com.goterl.lazysodium.utils.KeyPair import com.goterl.lazysodium.utils.KeyPair
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -20,6 +22,8 @@ import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.DatabaseContentProviders
import org.thoughtcrime.securesms.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
@ -27,18 +31,28 @@ import java.util.UUID
class ConversationViewModel( class ConversationViewModel(
val threadId: Long, val threadId: Long,
val edKeyPair: KeyPair?, val edKeyPair: KeyPair?,
private val contentResolver: ContentResolver,
private val repository: ConversationRepository, private val repository: ConversationRepository,
private val storage: StorageProtocol private val storage: StorageProtocol
) : 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
private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce {
repository.maybeGetRecipientForThreadId(threadId)
}
val recipient: Recipient? val recipient: Recipient?
get() = repository.maybeGetRecipientForThreadId(threadId) get() = _recipient.value
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 +63,18 @@ class ConversationViewModel(
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString ?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
} }
init {
viewModelScope.launch(Dispatchers.IO) {
contentResolver.observeQuery(DatabaseContentProviders.Conversation.getUriForThread(threadId))
.collect {
val recipientExists = storage.getRecipientForThread(threadId) != null
if (!recipientExists && _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)
@ -170,21 +196,26 @@ class ConversationViewModel(
return repository.hasReceived(threadId) return repository.hasReceived(threadId)
} }
fun updateRecipient() {
_recipient.updateTo(repository.maybeGetRecipientForThreadId(threadId))
}
@dagger.assisted.AssistedFactory @dagger.assisted.AssistedFactory
interface AssistedFactory { interface AssistedFactory {
fun create(threadId: Long, edKeyPair: KeyPair?): Factory fun create(threadId: Long, edKeyPair: KeyPair?, contentResolver: ContentResolver): Factory
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
class Factory @AssistedInject constructor( class Factory @AssistedInject constructor(
@Assisted private val threadId: Long, @Assisted private val threadId: Long,
@Assisted private val edKeyPair: KeyPair?, @Assisted private val edKeyPair: KeyPair?,
@Assisted private val contentResolver: ContentResolver,
private val repository: ConversationRepository, private val repository: ConversationRepository,
private val storage: StorageProtocol private val storage: StorageProtocol
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ConversationViewModel(threadId, edKeyPair, repository, storage) as T return ConversationViewModel(threadId, edKeyPair, contentResolver, repository, storage) as T
} }
} }
} }
@ -193,5 +224,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 }
}

View File

@ -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)
} }
} }

View File

@ -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.database.StorageProtocol import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.ExpirationUtil
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.IdPrefix
import 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.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: StorageProtocol lateinit var storage: StorageProtocol
// 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)
}

View File

@ -0,0 +1,159 @@
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.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
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.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,
) : ViewModel() {
private val state = MutableStateFlow(MessageDetailsState())
val stateFlow = state.asStateFlow()
private val event = Channel<Event>()
val eventFlow = event.receiveAsFlow()
var timestamp: Long = 0L
set(value) {
field = value
val record = mmsSmsDatabase.getMessageForTimestamp(timestamp)
if (record == null) {
viewModelScope.launch { event.send(Event.Finish) }
return
}
val mmsRecord = record as? MmsMessageRecord
state.value = record.run {
val slides = mmsRecord?.slideDeck?.slides ?: emptyList()
MessageDetailsState(
attachments = slides.map(::Attachment),
record = record,
sent = dateSent.let(::Date).toString().let { TitledText(R.string.message_details_header__sent, it) },
received = dateReceived.let(::Date).toString().let { TitledText(R.string.message_details_header__received, it) },
error = lokiMessageDatabase.getErrorMessage(id)?.let { TitledText(R.string.message_details_header__error, it) },
senderInfo = individualRecipient.run { name?.let { TitledText(it, address.serialize()) } },
sender = individualRecipient,
thread = threadDb.getRecipientForThreadId(threadId)!!,
)
}
}
private val Slide.details: List<TitledText>
get() = listOfNotNull(
fileName.orNull()?.let { TitledText(R.string.message_details_header__file_id, it) },
TitledText(R.string.message_details_header__file_type, asAttachment().contentType),
TitledText(R.string.message_details_header__file_size, Util.getPrettyFileSize(fileSize)),
takeIf { it is ImageSlide }
?.let(Slide::asAttachment)
?.run { "${width}x$height" }
?.let { TitledText(R.string.message_details_header__resolution, it) },
attachmentDb.duration(this)?.let { TitledText(R.string.message_details_header__duration, it) },
)
private fun AttachmentDatabase.duration(slide: Slide): String? =
slide.takeIf { it.hasAudio() }
?.run { asAttachment() as? DatabaseAttachment }
?.run { getAttachmentAudioExtras(attachmentId)?.durationMs }
?.takeIf { it > 0 }
?.let {
String.format(
"%01d:%02d",
TimeUnit.MILLISECONDS.toMinutes(it),
TimeUnit.MILLISECONDS.toSeconds(it) % 60
)
}
fun Attachment(slide: Slide): Attachment =
Attachment(slide.details, slide.fileName.orNull(), slide.uri, slide is ImageSlide)
fun onClickImage(index: Int) {
val state = state.value ?: return
val mmsRecord = state.mmsRecord ?: return
val slide = mmsRecord.slideDeck.slides[index] ?: return
// only open to downloaded images
if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) {
// Restart download here (on IO thread)
(slide.asAttachment() as? DatabaseAttachment)?.let { attachment ->
onAttachmentNeedsDownload(attachment.attachmentId.rowId, state.mmsRecord.getId())
}
}
if (slide.isInProgress) return
viewModelScope.launch {
MediaPreviewArgs(slide, state.mmsRecord, state.thread)
.let(Event::StartMediaPreview)
.let { event.send(it) }
}
}
fun onAttachmentNeedsDownload(attachmentId: Long, mmsId: Long) {
viewModelScope.launch(Dispatchers.IO) {
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId))
}
}
}
data class MessageDetailsState(
val attachments: List<Attachment> = emptyList(),
val imageAttachments: List<Attachment> = attachments.filter { it.hasImage },
val nonImageAttachmentFileDetails: List<TitledText>? = attachments.firstOrNull { !it.hasImage }?.fileDetails,
val record: MessageRecord? = null,
val mmsRecord: MmsMessageRecord? = record as? MmsMessageRecord,
val sent: TitledText? = null,
val received: TitledText? = null,
val error: TitledText? = null,
val senderInfo: TitledText? = null,
val sender: Recipient? = null,
val thread: Recipient? = null,
) {
val fromTitle = GetString(R.string.message_details_header__from)
}
data class Attachment(
val fileDetails: List<TitledText>,
val fileName: String?,
val uri: Uri?,
val hasImage: Boolean
)
sealed class Event {
object Finish: Event()
data class StartMediaPreview(val args: MediaPreviewArgs): Event()
}

View File

@ -60,8 +60,7 @@ class ModalUrlBottomSheet(private val url: String): BottomSheetDialogFragment(),
override fun onStart() { 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?) {

View File

@ -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);
} }

View File

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

View File

@ -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()
} }
} }

View File

@ -1,11 +1,12 @@
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 network.loki.messenger.databinding.DialogDownloadBinding
@ -13,7 +14,7 @@ import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
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 javax.inject.Inject import javax.inject.Inject
@ -22,13 +23,12 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class AutoDownloadDialog(private val threadRecipient: Recipient, class AutoDownloadDialog(private val threadRecipient: Recipient,
private val databaseAttachment: DatabaseAttachment private val databaseAttachment: DatabaseAttachment
) : BaseDialog() { ) : DialogFragment() {
@Inject lateinit var storage: StorageProtocol @Inject lateinit var storage: StorageProtocol
@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 threadId = storage.getThreadId(threadRecipient) ?: run { val threadId = storage.getThreadId(threadRecipient) ?: run {
dismiss() dismiss()
return return
@ -39,25 +39,23 @@ class AutoDownloadDialog(private val threadRecipient: Recipient,
threadRecipient.isClosedGroupRecipient -> storage.getGroup(threadRecipient.address.toGroupString())?.title ?: "UNKNOWN" threadRecipient.isClosedGroupRecipient -> storage.getGroup(threadRecipient.address.toGroupString())?.title ?: "UNKNOWN"
else -> storage.getContactWithSessionID(threadRecipient.address.serialize())?.displayName(Contact.ContactContext.REGULAR) ?: "UNKNOWN" else -> storage.getContactWithSessionID(threadRecipient.address.serialize())?.displayName(Contact.ContactContext.REGULAR) ?: "UNKNOWN"
} }
val title = resources.getString(R.string.dialog_auto_download_title) title(resources.getString(R.string.dialog_auto_download_title))
binding.downloadTitleTextView.text = title
val explanation = resources.getString(R.string.dialog_auto_download_explanation, displayName) val explanation = resources.getString(R.string.dialog_auto_download_explanation, displayName)
val spannable = SpannableStringBuilder(explanation) val spannable = SpannableStringBuilder(explanation)
val startIndex = explanation.indexOf(displayName) val startIndex = explanation.indexOf(name)
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + displayName.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.no.setOnClickListener {
setAutoDownload(false) button(R.string.dialog_download_button_title, R.string.AccessibilityId_download_media) {
dismiss()
}
binding.yes.setOnClickListener {
setAutoDownload(true) setAutoDownload(true)
dismiss()
} }
builder.setView(binding.root) cancelButton {
setAutoDownload(false)
}
} }
private fun setAutoDownload(shouldDownload: Boolean) { private fun setAutoDownload(shouldDownload: Boolean) {
storage.setAutoDownloadAttachments(threadRecipient, shouldDownload) storage.setAutoDownloadAttachments(threadRecipient, shouldDownload)
} }
} }

View File

@ -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()
} }
} }

View File

@ -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()
} }
} }

View File

@ -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()
} }
} }

View File

@ -57,9 +57,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() }

View File

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

View File

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

View File

@ -67,9 +67,11 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
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.isOpenGroupRecipient && 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>)

View File

@ -14,7 +14,6 @@ import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.annotation.ColorInt 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
@ -33,7 +32,6 @@ 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 +42,8 @@ import org.thoughtcrime.securesms.groups.EditClosedGroupActivity
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.service.WebRtcCallService import org.thoughtcrime.securesms.service.WebRtcCallService
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.showMuteDialog
import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.BitmapUtil
import java.io.IOException import java.io.IOException
@ -63,26 +63,31 @@ object ConversationMenuHelper {
// 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.isBlocked) {
if (thread.expireMessages > 0) { if (thread.expireMessages > 0) {
inflater.inflate(R.menu.menu_conversation_expiration_on, menu) inflater.inflate(R.menu.menu_conversation_expiration_on, menu)
val item = menu.findItem(R.id.menu_expiring_messages) val item = menu.findItem(R.id.menu_expiring_messages)
val actionView = item.actionView item.actionView?.let { actionView ->
val iconView = actionView.findViewById<ImageView>(R.id.menu_badge_icon) val iconView = actionView.findViewById<ImageView>(R.id.menu_badge_icon)
val badgeView = actionView.findViewById<TextView>(R.id.expiration_badge) val badgeView = actionView.findViewById<TextView>(R.id.expiration_badge)
@ColorInt val color = context.getColorFromAttr(android.R.attr.textColorPrimary) @ColorInt val color = context.getColorFromAttr(android.R.attr.textColorPrimary)
iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY) iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY)
badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages) badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages)
actionView.setOnClickListener { onOptionsItemSelected(item) } actionView.setOnClickListener { onOptionsItemSelected(item) }
}
} else { } else {
inflater.inflate(R.menu.menu_conversation_expiration_off, menu) inflater.inflate(R.menu.menu_conversation_expiration_off, menu)
} }
} }
// One-on-one chat menu allows copying the session id
if (thread.isContactRecipient) {
inflater.inflate(R.menu.menu_conversation_copy_session_id, menu)
}
// One-on-one chat menu (options that should only be present for one-on-one chats) // One-on-one chat menu (options that should only be present for one-on-one chats)
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)
} }
} }
@ -154,6 +159,7 @@ object ConversationMenuHelper {
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) }
R.id.menu_copy_session_id -> { copySessionID(context, thread) } R.id.menu_copy_session_id -> { copySessionID(context, thread) }
R.id.menu_copy_open_group_url -> { copyOpenGroupUrl(context, thread) }
R.id.menu_edit_group -> { editClosedGroup(context, thread) } R.id.menu_edit_group -> { editClosedGroup(context, thread) }
R.id.menu_leave_group -> { leaveClosedGroup(context, thread) } R.id.menu_leave_group -> { leaveClosedGroup(context, thread) }
R.id.menu_invite_to_open_group -> { inviteContacts(context, thread) } R.id.menu_invite_to_open_group -> { inviteContacts(context, thread) }
@ -180,26 +186,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)
} }
@ -270,6 +273,12 @@ object ConversationMenuHelper {
listener.copySessionID(thread.address.toString()) listener.copySessionID(thread.address.toString())
} }
private fun copyOpenGroupUrl(context: Context, thread: Recipient) {
if (!thread.isOpenGroupRecipient) { return }
val listener = context as? ConversationMenuListener ?: return
listener.copyOpenGroupUrl(thread)
}
private fun editClosedGroup(context: Context, thread: Recipient) { private fun editClosedGroup(context: Context, thread: Recipient) {
if (!thread.isClosedGroupRecipient) { return } if (!thread.isClosedGroupRecipient) { return }
val intent = Intent(context, EditClosedGroupActivity::class.java) val intent = Intent(context, EditClosedGroupActivity::class.java)
@ -280,9 +289,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)
@ -292,29 +299,25 @@ 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) {
@ -329,7 +332,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)
} }
} }
@ -344,6 +347,7 @@ object ConversationMenuHelper {
fun block(deleteThread: Boolean = false) fun block(deleteThread: Boolean = false)
fun unblock() fun unblock()
fun copySessionID(sessionId: String) fun copySessionID(sessionId: String)
fun copyOpenGroupUrl(thread: Recipient)
fun showExpiringMessagesDialog(thread: Recipient) fun showExpiringMessagesDialog(thread: Recipient)
} }

View File

@ -31,6 +31,7 @@ class ControlMessageView : LinearLayout {
binding.dateBreakTextView.showDateBreak(message, previous) binding.dateBreakTextView.showDateBreak(message, previous)
binding.iconImageView.visibility = View.GONE binding.iconImageView.visibility = View.GONE
var messageBody: CharSequence = message.getDisplayBody(context) var messageBody: CharSequence = message.getDisplayBody(context)
binding.root.contentDescription= null
when { when {
message.isExpirationTimerUpdate -> { message.isExpirationTimerUpdate -> {
binding.iconImageView.setImageDrawable( binding.iconImageView.setImageDrawable(
@ -46,6 +47,7 @@ class ControlMessageView : LinearLayout {
} }
message.isMessageRequestResponse -> { message.isMessageRequestResponse -> {
messageBody = context.getString(R.string.message_requests_accepted) messageBody = 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 {

View File

@ -57,8 +57,12 @@ class LinkPreviewView : LinearLayout {
val cornerRadii = MessageBubbleUtilities.calculateRadii(context, isStartOfMessageCluster, isEndOfMessageCluster, message.isOutgoing) val cornerRadii = MessageBubbleUtilities.calculateRadii(context, isStartOfMessageCluster, isEndOfMessageCluster, message.isOutgoing)
cornerMask.setTopLeftRadius(cornerRadii[0]) cornerMask.setTopLeftRadius(cornerRadii[0])
cornerMask.setTopRightRadius(cornerRadii[1]) cornerMask.setTopRightRadius(cornerRadii[1])
cornerMask.setBottomRightRadius(cornerRadii[2])
cornerMask.setBottomLeftRadius(cornerRadii[3]) // Only round the bottom corners if there is no body text
if (message.body.isEmpty()) {
cornerMask.setBottomRightRadius(cornerRadii[2])
cornerMask.setBottomLeftRadius(cornerRadii[3])
}
} }
override fun dispatchDraw(canvas: Canvas) { override fun dispatchDraw(canvas: Canvas) {

View File

@ -3,7 +3,6 @@ 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
@ -15,11 +14,10 @@ import android.view.View
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
@ -27,6 +25,7 @@ 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.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
@ -36,7 +35,10 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.ModalURLSpan
import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans
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.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.Locale import java.util.Locale
@ -44,7 +46,6 @@ 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
@ -58,20 +59,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,
onAttachmentNeedsDownload: (Long, Long) -> Unit contactIsTrusted: Boolean = true,
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 mediaDownloaded = message is MmsMessageRecord && message.slideDeck.asAttachments().all { it.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE } val mediaDownloaded = message is MmsMessageRecord && message.slideDeck.asAttachments().all { it.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE }
val mediaInProgress = message is MmsMessageRecord && message.slideDeck.asAttachments().any { it.isInProgress } val mediaInProgress = message is MmsMessageRecord && message.slideDeck.asAttachments().any { it.isInProgress }
@ -97,6 +98,9 @@ class VisibleMessageContentView : ConstraintLayout {
binding.deletedMessageView.root.isVisible = false binding.deletedMessageView.root.isVisible = false
} }
// Note: Need to clear the body to prevent the message bubble getting incorrectly
// sized based on text content from a recycled view
binding.bodyTextView.text = null
binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null
binding.linkPreviewView.root.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty() binding.linkPreviewView.root.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty()
binding.pendingAttachmentView.root.isVisible = !mediaDownloaded && !mediaInProgress && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty() binding.pendingAttachmentView.root.isVisible = !mediaDownloaded && !mediaInProgress && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty()
@ -125,7 +129,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) {
@ -152,7 +155,9 @@ class VisibleMessageContentView : ConstraintLayout {
message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> { message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> {
binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster) binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster)
onContentClick.add { event -> binding.linkPreviewView.root.calculateHit(event) } onContentClick.add { event -> binding.linkPreviewView.root.calculateHit(event) }
// Body text view is inside the link preview for layout convenience
// When in a link preview ensure the bodyTextView can expand to the full width
binding.bodyTextView.maxWidth = binding.linkPreviewView.root.layoutParams.width
} }
// AUDIO // AUDIO
message is MmsMessageRecord && message.slideDeck.audioSlide != null -> { message is MmsMessageRecord && message.slideDeck.audioSlide != null -> {
@ -197,7 +202,7 @@ class VisibleMessageContentView : ConstraintLayout {
} }
} }
// IMAGE / VIDEO // IMAGE / VIDEO
message is MmsMessageRecord && message.slideDeck.asAttachments().isNotEmpty() -> { message is MmsMessageRecord && !suppressThumbnails && message.slideDeck.asAttachments().isNotEmpty() -> {
if (mediaDownloaded || mediaInProgress || message.isOutgoing) { if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
// isStart and isEnd of cluster needed for calculating the mask for full bubble image groups // isStart and isEnd of cluster needed for calculating the mask for full bubble image groups
// bind after add view because views are inflated and calculated during bind // bind after add view because views are inflated and calculated during bind
@ -237,6 +242,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)
@ -255,14 +261,15 @@ class VisibleMessageContentView : ConstraintLayout {
binding.contentParent.layoutParams = layoutParams 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,
@ -280,6 +287,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

View File

@ -2,17 +2,21 @@ package org.thoughtcrime.securesms.conversation.v2.messages
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
@ -26,6 +30,7 @@ import network.loki.messenger.databinding.ViewVisibleMessageBinding
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.contacts.Contact.ContactContext import org.session.libsession.messaging.contacts.Contact.ContactContext
import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.ViewUtil
import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.getColorFromAttr
@ -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
@ -66,7 +72,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 +112,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,13 +123,14 @@ 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,
delegate: VisibleMessageViewDelegate? = null,
onAttachmentNeedsDownload: (Long, Long) -> Unit onAttachmentNeedsDownload: (Long, Long) -> Unit
) { ) {
val threadID = message.threadId val threadID = message.threadId
@ -132,7 +141,7 @@ class VisibleMessageView : LinearLayout {
// 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 +150,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.isOpenGroupRecipient) { 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))
@ -173,7 +182,7 @@ class VisibleMessageView : LinearLayout {
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
@ -187,13 +196,15 @@ class VisibleMessageView : LinearLayout {
val contactContext = val contactContext =
if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR if (thread.isOpenGroupRecipient) 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) { if (message.isOutgoing) {
val (iconID, iconColor, textId) = getMessageStatusImage(message) val (iconID, iconColor, textId, contentDescription) = getMessageStatusImage(message)
if (textId != null) { if (textId != null) {
binding.messageStatusTextView.setText(textId) binding.messageStatusTextView.setText(textId)
@ -208,6 +219,7 @@ class VisibleMessageView : LinearLayout {
} }
binding.messageStatusImageView.setImageDrawable(drawable) binding.messageStatusImageView.setImageDrawable(drawable)
} }
binding.messageStatusImageView.contentDescription = contentDescription
val lastMessageID = mmsSmsDb.getLastMessageID(message.threadId) val lastMessageID = mmsSmsDb.getLastMessageID(message.threadId)
binding.messageStatusTextView.isVisible = ( binding.messageStatusTextView.isVisible = (
@ -281,29 +293,63 @@ class VisibleMessageView : LinearLayout {
} }
} }
private fun getMessageStatusImage(message: MessageRecord): Triple<Int?,Int?,Int?> { data class MessageStatusInfo(@DrawableRes val iconId: Int?,
return when { @ColorInt val iconTint: Int?,
!message.isOutgoing -> Triple(null, null, null) @StringRes val messageText: Int?,
message.isFailed -> val contentDescription: String?)
Triple(R.drawable.ic_delivery_status_failed, resources.getColor(R.color.destructive, context.theme), R.string.delivery_status_failed)
message.isPending -> private fun getMessageStatusImage(message: MessageRecord): MessageStatusInfo = when {
Triple(R.drawable.ic_delivery_status_sending, context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending) message.isFailed ->
message.isRead -> MessageStatusInfo(
Triple(R.drawable.ic_delivery_status_read, context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read) R.drawable.ic_delivery_status_failed,
else -> resources.getColor(R.color.destructive, context.theme),
Triple(R.drawable.ic_delivery_status_sent, context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sent) R.string.delivery_status_failed,
} null
)
message.isSyncFailed ->
MessageStatusInfo(
R.drawable.ic_delivery_status_failed,
context.getColor(R.color.accent_orange),
R.string.delivery_status_sync_failed,
null
)
message.isPending ->
MessageStatusInfo(
R.drawable.ic_delivery_status_sending,
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending,
context.getString(R.string.AccessibilityId_message_sent_status_pending)
)
message.isResyncing ->
MessageStatusInfo(
R.drawable.ic_delivery_status_sending,
context.getColor(R.color.accent_orange), R.string.delivery_status_syncing,
context.getString(R.string.AccessibilityId_message_sent_status_syncing)
)
message.isRead ->
MessageStatusInfo(
R.drawable.ic_delivery_status_read,
context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read,
null
)
else ->
MessageStatusInfo(
R.drawable.ic_delivery_status_sent,
context.getColorFromAttr(R.attr.message_status_color),
R.string.delivery_status_sent,
context.getString(R.string.AccessibilityId_message_sent_status_tick)
)
} }
private fun updateExpirationTimer(message: MessageRecord) { private fun updateExpirationTimer(message: MessageRecord) {
val container = binding.messageInnerContainer val container = binding.messageInnerContainer
val content = binding.messageContentView.root val layout = binding.messageInnerLayout
val expiration = binding.expirationTimerView
val spacing = binding.messageContentSpacing if (message.isOutgoing) binding.messageContentView.root.bringToFront()
container.removeAllViewsInLayout() else binding.expirationTimerView.bringToFront()
container.addView(if (message.isOutgoing) expiration else content)
container.addView(if (message.isOutgoing) content else expiration) layout.layoutParams = layout.layoutParams.let { it as FrameLayout.LayoutParams }
container.addView(spacing, if (message.isOutgoing) 0 else 2) .apply { gravity = if (message.isOutgoing) Gravity.END else Gravity.START }
val containerParams = container.layoutParams as ConstraintLayout.LayoutParams val containerParams = container.layoutParams as ConstraintLayout.LayoutParams
containerParams.horizontalBias = if (message.isOutgoing) 1f else 0f containerParams.horizontalBias = if (message.isOutgoing) 1f else 0f
container.layoutParams = containerParams container.layoutParams = containerParams
@ -314,7 +360,7 @@ class VisibleMessageView : LinearLayout {
if (message.expireStarted > 0) { if (message.expireStarted > 0) {
binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
binding.expirationTimerView.startAnimation() binding.expirationTimerView.startAnimation()
if (message.expireStarted + message.expiresIn <= System.currentTimeMillis()) { if (message.expireStarted + message.expiresIn <= SnodeAPI.nowWithOffset) {
ApplicationContext.getInstance(context).expiringMessageManager.checkSchedule() ApplicationContext.getInstance(context).expiringMessageManager.checkSchedule()
} }
} else if (!message.isMediaPending) { } else if (!message.isMediaPending) {
@ -349,7 +395,7 @@ class VisibleMessageView : LinearLayout {
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
@ -369,9 +415,13 @@ 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
@ -466,7 +516,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) {

View File

@ -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)
} }

View File

@ -25,6 +25,7 @@ import android.content.Intent;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Build;
import android.provider.OpenableColumns; import android.provider.OpenableColumns;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Pair; import android.util.Pair;
@ -244,12 +245,17 @@ public class AttachmentManager {
} }
public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body) { public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body) {
Permissions.with(activity) Permissions.PermissionsBuilder builder = Permissions.with(activity);
.request(Manifest.permission.READ_EXTERNAL_STORAGE) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) builder = builder.request(Manifest.permission.READ_MEDIA_VIDEO)
.withRationaleDialog(activity.getString(R.string.ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage), R.drawable.ic_baseline_photo_library_24) .request(Manifest.permission.READ_MEDIA_IMAGES);
.onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode)) } else {
.execute(); builder = builder.request(Manifest.permission.READ_EXTERNAL_STORAGE);
}
builder.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio))
.withRationaleDialog(activity.getString(R.string.ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage), R.drawable.ic_baseline_photo_library_24)
.onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode))
.execute();
} }
public static void selectAudio(Activity activity, int requestCode) { public static void selectAudio(Activity activity, int requestCode) {

View File

@ -1,26 +0,0 @@
package org.thoughtcrime.securesms.conversation.v2.utilities
import android.app.Dialog
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import org.thoughtcrime.securesms.util.UiModeUtilities
open class BaseDialog : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = AlertDialog.Builder(requireContext())
setContentView(builder)
val result = builder.create()
result.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
val isLightMode = UiModeUtilities.isDayUiMode(requireContext())
result.window?.setDimAmount(if (isLightMode) 0.1f else 0.75f)
return result
}
open fun setContentView(builder: AlertDialog.Builder) {
// To be overridden by subclasses
}
}

View File

@ -1,21 +1,18 @@
package org.thoughtcrime.securesms.conversation.v2.utilities package org.thoughtcrime.securesms.conversation.v2.utilities
import android.content.Context import android.content.Context
import androidx.appcompat.app.AlertDialog
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.showSessionDialog
object NotificationUtils { object NotificationUtils {
fun showNotifyDialog(context: Context, thread: Recipient, notifyTypeHandler: (Int)->Unit) { fun showNotifyDialog(context: Context, thread: Recipient, notifyTypeHandler: (Int)->Unit) {
val notifyTypes = context.resources.getStringArray(R.array.notify_types) context.showSessionDialog {
val currentSelected = thread.notifyType title(R.string.RecipientPreferenceActivity_notification_settings)
singleChoiceItems(
AlertDialog.Builder(context) context.resources.getStringArray(R.array.notify_types),
.setSingleChoiceItems(notifyTypes,currentSelected) { d, newSelection -> thread.notifyType
notifyTypeHandler(newSelection) ) { notifyTypeHandler(it) }
d.dismiss() }
}
.setTitle(R.string.RecipientPreferenceActivity_notification_settings)
.show()
} }
} }

View File

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2.utilities
import android.content.Context import android.content.Context
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.visible.LinkPreview import org.session.libsession.messaging.messages.visible.LinkPreview
import org.session.libsession.messaging.messages.visible.OpenGroupInvitation import org.session.libsession.messaging.messages.visible.OpenGroupInvitation
import org.session.libsession.messaging.messages.visible.Quote import org.session.libsession.messaging.messages.visible.Quote
@ -15,7 +16,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
object ResendMessageUtilities { object ResendMessageUtilities {
fun resend(context: Context, messageRecord: MessageRecord, userBlindedKey: String?) { fun resend(context: Context, messageRecord: MessageRecord, userBlindedKey: String?, isResync: Boolean = false) {
val recipient: Recipient = messageRecord.recipient val recipient: Recipient = messageRecord.recipient
val message = VisibleMessage() val message = VisibleMessage()
message.id = messageRecord.getId() message.id = messageRecord.getId()
@ -55,8 +56,13 @@ object ResendMessageUtilities {
val sentTimestamp = message.sentTimestamp val sentTimestamp = message.sentTimestamp
val sender = MessagingModuleConfiguration.shared.storage.getUserPublicKey() val sender = MessagingModuleConfiguration.shared.storage.getUserPublicKey()
if (sentTimestamp != null && sender != null) { if (sentTimestamp != null && sender != null) {
MessagingModuleConfiguration.shared.storage.markAsSending(sentTimestamp, sender) if (isResync) {
MessagingModuleConfiguration.shared.storage.markAsResyncing(sentTimestamp, sender)
MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = true)
} else {
MessagingModuleConfiguration.shared.storage.markAsSending(sentTimestamp, sender)
MessageSender.send(message, recipient.address)
}
} }
MessageSender.send(message, recipient.address)
} }
} }

View File

@ -38,13 +38,12 @@ object TextUtilities {
fun TextView.getIntersectedModalSpans(hitRect: Rect): List<ModalURLSpan> { fun TextView.getIntersectedModalSpans(hitRect: Rect): List<ModalURLSpan> {
val textLayout = layout ?: return emptyList() val textLayout = layout ?: return emptyList()
val lineRect = Rect() val lineRect = Rect()
val bodyTextRect = Rect() val offset = intArrayOf(0, 0).also { getLocationOnScreen(it) }
getGlobalVisibleRect(bodyTextRect)
val textSpannable = text.toSpannable() val textSpannable = text.toSpannable()
return (0 until textLayout.lineCount).flatMap { line -> return (0 until textLayout.lineCount).flatMap { line ->
textLayout.getLineBounds(line, lineRect) textLayout.getLineBounds(line, lineRect)
lineRect.offset(bodyTextRect.left + totalPaddingLeft, bodyTextRect.top + totalPaddingTop) lineRect.offset(offset[0] + totalPaddingLeft, offset[1] + totalPaddingTop)
if ((Rect(lineRect)).contains(hitRect)) { if (lineRect.contains(hitRect)) {
// calculate the url span intersected with (if any) // calculate the url span intersected with (if any)
val off = textLayout.getOffsetForHorizontal(line, hitRect.left.toFloat()) // left and right will be the same val off = textLayout.getOffsetForHorizontal(line, hitRect.left.toFloat()) // left and right will be the same
textSpannable.getSpans<ModalURLSpan>(off, off).toList() textSpannable.getSpans<ModalURLSpan>(off, off).toList()

View File

@ -123,10 +123,10 @@ open class ThumbnailView: FrameLayout {
when { when {
slide.thumbnailUri != null -> { slide.thumbnailUri != null -> {
buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(binding.thumbnailImage, result)) buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, result))
} }
slide.hasPlaceholder() -> { slide.hasPlaceholder() -> {
buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(binding.thumbnailImage, result)) buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(binding.thumbnailImage, null, result))
} }
else -> { else -> {
glide.clear(binding.thumbnailImage) glide.clear(binding.thumbnailImage)
@ -190,7 +190,7 @@ open class ThumbnailView: FrameLayout {
request.transforms(CenterCrop()) request.transforms(CenterCrop())
} }
request.into(GlideDrawableListeningTarget(binding.thumbnailImage, future)) request.into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, future))
return future return future
} }

View File

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

View File

@ -1,13 +1,13 @@
package org.thoughtcrime.securesms.crypto; package org.thoughtcrime.securesms.crypto;
import android.os.Build; import static org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK;
import android.security.keystore.KeyGenParameterSpec; import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties; import android.security.keystore.KeyProperties;
import android.util.Base64; import android.util.Base64;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonGenerator;
@ -45,44 +45,50 @@ public final class KeyStoreHelper {
private static final String ANDROID_KEY_STORE = "AndroidKeyStore"; private static final String ANDROID_KEY_STORE = "AndroidKeyStore";
private static final String KEY_ALIAS = "SignalSecret"; private static final String KEY_ALIAS = "SignalSecret";
@RequiresApi(Build.VERSION_CODES.M)
public static SealedData seal(@NonNull byte[] input) { public static SealedData seal(@NonNull byte[] input) {
SecretKey secretKey = getOrCreateKeyStoreEntry(); SecretKey secretKey = getOrCreateKeyStoreEntry();
try { try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); // Cipher operations are not thread-safe so we synchronize over them through doFinal to
cipher.init(Cipher.ENCRYPT_MODE, secretKey); // prevent crashes with quickly repeated encrypt/decrypt operations
// https://github.com/mozilla-mobile/android-components/issues/5342
synchronized (CIPHER_LOCK) {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
byte[] iv = cipher.getIV(); byte[] iv = cipher.getIV();
byte[] data = cipher.doFinal(input); byte[] data = cipher.doFinal(input);
return new SealedData(iv, data); return new SealedData(iv, data);
}
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) { } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
throw new AssertionError(e); throw new AssertionError(e);
} }
} }
@RequiresApi(Build.VERSION_CODES.M)
public static byte[] unseal(@NonNull SealedData sealedData) { public static byte[] unseal(@NonNull SealedData sealedData) {
SecretKey secretKey = getKeyStoreEntry(); SecretKey secretKey = getKeyStoreEntry();
try { try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); // Cipher operations are not thread-safe so we synchronize over them through doFinal to
cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(128, sealedData.iv)); // prevent crashes with quickly repeated encrypt/decrypt operations
// https://github.com/mozilla-mobile/android-components/issues/5342
synchronized (CIPHER_LOCK) {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(128, sealedData.iv));
return cipher.doFinal(sealedData.data); return cipher.doFinal(sealedData.data);
}
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
throw new AssertionError(e); throw new AssertionError(e);
} }
} }
@RequiresApi(Build.VERSION_CODES.M)
private static SecretKey getOrCreateKeyStoreEntry() { private static SecretKey getOrCreateKeyStoreEntry() {
if (hasKeyStoreEntry()) return getKeyStoreEntry(); if (hasKeyStoreEntry()) return getKeyStoreEntry();
else return createKeyStoreEntry(); else return createKeyStoreEntry();
} }
@RequiresApi(Build.VERSION_CODES.M)
private static SecretKey createKeyStoreEntry() { private static SecretKey createKeyStoreEntry() {
try { try {
KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE); KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE);
@ -99,7 +105,6 @@ public final class KeyStoreHelper {
} }
} }
@RequiresApi(Build.VERSION_CODES.M)
private static SecretKey getKeyStoreEntry() { private static SecretKey getKeyStoreEntry() {
KeyStore keyStore = getKeyStore(); KeyStore keyStore = getKeyStore();
@ -137,7 +142,6 @@ public final class KeyStoreHelper {
} }
} }
@RequiresApi(Build.VERSION_CODES.M)
private static boolean hasKeyStoreEntry() { private static boolean hasKeyStoreEntry() {
try { try {
KeyStore ks = KeyStore.getInstance(ANDROID_KEY_STORE); KeyStore ks = KeyStore.getInstance(ANDROID_KEY_STORE);
@ -202,7 +206,5 @@ public final class KeyStoreHelper {
return Base64.decode(p.getValueAsString(), Base64.NO_WRAP | Base64.NO_PADDING); return Base64.decode(p.getValueAsString(), Base64.NO_WRAP | Base64.NO_PADDING);
} }
} }
} }
} }

View File

@ -0,0 +1,53 @@
package org.thoughtcrime.securesms.database
import android.content.Context
import androidx.core.content.contentValuesOf
import androidx.core.database.getBlobOrNull
import androidx.core.database.getLongOrNull
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) {
companion object {
private const val VARIANT = "variant"
private const val PUBKEY = "publicKey"
private const val DATA = "data"
private const val TIMESTAMP = "timestamp" // Milliseconds
private const val TABLE_NAME = "configs_table"
const val CREATE_CONFIG_TABLE_COMMAND =
"CREATE TABLE $TABLE_NAME ($VARIANT TEXT NOT NULL, $PUBKEY TEXT NOT NULL, $DATA BLOB, $TIMESTAMP INTEGER NOT NULL DEFAULT 0, PRIMARY KEY($VARIANT, $PUBKEY));"
private const val VARIANT_AND_PUBKEY_WHERE = "$VARIANT = ? AND $PUBKEY = ?"
}
fun storeConfig(variant: String, publicKey: String, data: ByteArray, timestamp: Long) {
val db = writableDatabase
val contentValues = contentValuesOf(
VARIANT to variant,
PUBKEY to publicKey,
DATA to data,
TIMESTAMP to timestamp
)
db.insertOrUpdate(TABLE_NAME, contentValues, VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey))
}
fun retrieveConfigAndHashes(variant: String, publicKey: String): ByteArray? {
val db = readableDatabase
val query = db.query(TABLE_NAME, arrayOf(DATA), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null)
return query?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
val bytes = cursor.getBlobOrNull(cursor.getColumnIndex(DATA)) ?: return@use null
bytes
}
}
fun retrieveConfigLastUpdateTimestamp(variant: String, publicKey: String): Long {
val db = readableDatabase
val cursor = db.query(TABLE_NAME, arrayOf(TIMESTAMP), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null)
if (cursor == null) return 0
if (!cursor.moveToFirst()) return 0
return (cursor.getLongOrNull(cursor.getColumnIndex(TIMESTAMP)) ?: 0)
}
}

View File

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

View File

@ -36,9 +36,9 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
@SuppressWarnings("unused") @SuppressWarnings("unused")
private static final String TAG = GroupDatabase.class.getSimpleName(); private static final String TAG = GroupDatabase.class.getSimpleName();
static final String TABLE_NAME = "groups"; public static final String TABLE_NAME = "groups";
private static final String ID = "_id"; private static final String ID = "_id";
static final String GROUP_ID = "group_id"; public static final String GROUP_ID = "group_id";
private static final String TITLE = "title"; private static final String TITLE = "title";
private static final String MEMBERS = "members"; private static final String MEMBERS = "members";
private static final String ZOMBIE_MEMBERS = "zombie_members"; private static final String ZOMBIE_MEMBERS = "zombie_members";
@ -133,12 +133,12 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
return new Reader(cursor); return new Reader(cursor);
} }
public List<GroupRecord> getAllGroups() { public List<GroupRecord> getAllGroups(boolean includeInactive) {
Reader reader = getGroups(); Reader reader = getGroups();
GroupRecord record; GroupRecord record;
List<GroupRecord> groups = new LinkedList<>(); List<GroupRecord> groups = new LinkedList<>();
while ((record = reader.getNext()) != null) { while ((record = reader.getNext()) != null) {
if (record.isActive()) { groups.add(record); } if (record.isActive() || includeInactive) { groups.add(record); }
} }
reader.close(); reader.close();
return groups; return groups;
@ -318,6 +318,25 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
notifyConversationListListeners(); notifyConversationListListeners();
} }
@Override
public void removeProfilePicture(String groupID) {
databaseHelper.getWritableDatabase()
.execSQL("UPDATE " + TABLE_NAME +
" SET " + AVATAR + " = NULL, " +
AVATAR_ID + " = NULL, " +
AVATAR_KEY + " = NULL, " +
AVATAR_CONTENT_TYPE + " = NULL, " +
AVATAR_RELAY + " = NULL, " +
AVATAR_DIGEST + " = NULL, " +
AVATAR_URL + " = NULL" +
" WHERE " +
GROUP_ID + " = ?",
new String[] {groupID});
Recipient.applyCached(Address.fromSerialized(groupID), recipient -> recipient.setGroupAvatarId(null));
notifyConversationListListeners();
}
public boolean hasDownloadedProfilePicture(String groupId) { public boolean hasDownloadedProfilePicture(String groupId) {
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[]{AVATAR}, GROUP_ID + " = ?", try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[]{AVATAR}, GROUP_ID + " = ?",
new String[] {groupId}, new String[] {groupId},

View File

@ -1,249 +0,0 @@
package org.thoughtcrime.securesms.database;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import androidx.annotation.NonNull;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec;
import org.thoughtcrime.securesms.jobmanager.persistence.DependencySpec;
import org.thoughtcrime.securesms.jobmanager.persistence.FullSpec;
import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec;
import java.util.LinkedList;
import java.util.List;
public class JobDatabase extends Database {
public static final String[] CREATE_TABLE = new String[] { Jobs.CREATE_TABLE,
Constraints.CREATE_TABLE,
Dependencies.CREATE_TABLE };
public static final class Jobs {
public static final String TABLE_NAME = "job_spec";
private static final String ID = "_id";
private static final String JOB_SPEC_ID = "job_spec_id";
private static final String FACTORY_KEY = "factory_key";
private static final String QUEUE_KEY = "queue_key";
private static final String CREATE_TIME = "create_time";
private static final String NEXT_RUN_ATTEMPT_TIME = "next_run_attempt_time";
private static final String RUN_ATTEMPT = "run_attempt";
private static final String MAX_ATTEMPTS = "max_attempts";
private static final String MAX_BACKOFF = "max_backoff";
private static final String MAX_INSTANCES = "max_instances";
private static final String LIFESPAN = "lifespan";
private static final String SERIALIZED_DATA = "serialized_data";
private static final String IS_RUNNING = "is_running";
private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
JOB_SPEC_ID + " TEXT UNIQUE, " +
FACTORY_KEY + " TEXT, " +
QUEUE_KEY + " TEXT, " +
CREATE_TIME + " INTEGER, " +
NEXT_RUN_ATTEMPT_TIME + " INTEGER, " +
RUN_ATTEMPT + " INTEGER, " +
MAX_ATTEMPTS + " INTEGER, " +
MAX_BACKOFF + " INTEGER, " +
MAX_INSTANCES + " INTEGER, " +
LIFESPAN + " INTEGER, " +
SERIALIZED_DATA + " TEXT, " +
IS_RUNNING + " INTEGER)";
}
public static final class Constraints {
public static final String TABLE_NAME = "constraint_spec";
private static final String ID = "_id";
private static final String JOB_SPEC_ID = "job_spec_id";
private static final String FACTORY_KEY = "factory_key";
private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
JOB_SPEC_ID + " TEXT, " +
FACTORY_KEY + " TEXT, " +
"UNIQUE(" + JOB_SPEC_ID + ", " + FACTORY_KEY + "))";
}
public static final class Dependencies {
public static final String TABLE_NAME = "dependency_spec";
private static final String ID = "_id";
private static final String JOB_SPEC_ID = "job_spec_id";
private static final String DEPENDS_ON_JOB_SPEC_ID = "depends_on_job_spec_id";
private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
JOB_SPEC_ID + " TEXT, " +
DEPENDS_ON_JOB_SPEC_ID + " TEXT, " +
"UNIQUE(" + JOB_SPEC_ID + ", " + DEPENDS_ON_JOB_SPEC_ID + "))";
}
public JobDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper);
}
public synchronized void insertJobs(@NonNull List<FullSpec> fullSpecs) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
for (FullSpec fullSpec : fullSpecs) {
insertJobSpec(db, fullSpec.getJobSpec());
insertConstraintSpecs(db, fullSpec.getConstraintSpecs());
insertDependencySpecs(db, fullSpec.getDependencySpecs());
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
public synchronized @NonNull List<JobSpec> getAllJobSpecs() {
List<JobSpec> jobs = new LinkedList<>();
try (Cursor cursor = databaseHelper.getReadableDatabase().query(Jobs.TABLE_NAME, null, null, null, null, null, Jobs.CREATE_TIME + ", " + Jobs.ID + " ASC")) {
while (cursor != null && cursor.moveToNext()) {
jobs.add(jobSpecFromCursor(cursor));
}
}
return jobs;
}
public synchronized void updateJobRunningState(@NonNull String id, boolean isRunning) {
ContentValues contentValues = new ContentValues();
contentValues.put(Jobs.IS_RUNNING, isRunning ? 1 : 0);
String query = Jobs.JOB_SPEC_ID + " = ?";
String[] args = new String[]{ id };
databaseHelper.getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, query, args);
}
public synchronized void updateJobAfterRetry(@NonNull String id, boolean isRunning, int runAttempt, long nextRunAttemptTime) {
ContentValues contentValues = new ContentValues();
contentValues.put(Jobs.IS_RUNNING, isRunning ? 1 : 0);
contentValues.put(Jobs.RUN_ATTEMPT, runAttempt);
contentValues.put(Jobs.NEXT_RUN_ATTEMPT_TIME, nextRunAttemptTime);
String query = Jobs.JOB_SPEC_ID + " = ?";
String[] args = new String[]{ id };
databaseHelper.getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, query, args);
}
public synchronized void updateAllJobsToBePending() {
ContentValues contentValues = new ContentValues();
contentValues.put(Jobs.IS_RUNNING, 0);
databaseHelper.getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, null, null);
}
public synchronized void deleteJobs(@NonNull List<String> jobIds) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
for (String jobId : jobIds) {
String[] arg = new String[]{jobId};
db.delete(Jobs.TABLE_NAME, Jobs.JOB_SPEC_ID + " = ?", arg);
db.delete(Constraints.TABLE_NAME, Constraints.JOB_SPEC_ID + " = ?", arg);
db.delete(Dependencies.TABLE_NAME, Dependencies.JOB_SPEC_ID + " = ?", arg);
db.delete(Dependencies.TABLE_NAME, Dependencies.DEPENDS_ON_JOB_SPEC_ID + " = ?", arg);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
public synchronized @NonNull List<ConstraintSpec> getAllConstraintSpecs() {
List<ConstraintSpec> constraints = new LinkedList<>();
try (Cursor cursor = databaseHelper.getReadableDatabase().query(Constraints.TABLE_NAME, null, null, null, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
constraints.add(constraintSpecFromCursor(cursor));
}
}
return constraints;
}
public synchronized @NonNull List<DependencySpec> getAllDependencySpecs() {
List<DependencySpec> dependencies = new LinkedList<>();
try (Cursor cursor = databaseHelper.getReadableDatabase().query(Dependencies.TABLE_NAME, null, null, null, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
dependencies.add(dependencySpecFromCursor(cursor));
}
}
return dependencies;
}
private void insertJobSpec(@NonNull SQLiteDatabase db, @NonNull JobSpec job) {
ContentValues contentValues = new ContentValues();
contentValues.put(Jobs.JOB_SPEC_ID, job.getId());
contentValues.put(Jobs.FACTORY_KEY, job.getFactoryKey());
contentValues.put(Jobs.QUEUE_KEY, job.getQueueKey());
contentValues.put(Jobs.CREATE_TIME, job.getCreateTime());
contentValues.put(Jobs.NEXT_RUN_ATTEMPT_TIME, job.getNextRunAttemptTime());
contentValues.put(Jobs.RUN_ATTEMPT, job.getRunAttempt());
contentValues.put(Jobs.MAX_ATTEMPTS, job.getMaxAttempts());
contentValues.put(Jobs.MAX_BACKOFF, job.getMaxBackoff());
contentValues.put(Jobs.MAX_INSTANCES, job.getMaxInstances());
contentValues.put(Jobs.LIFESPAN, job.getLifespan());
contentValues.put(Jobs.SERIALIZED_DATA, job.getSerializedData());
contentValues.put(Jobs.IS_RUNNING, job.isRunning() ? 1 : 0);
db.insertWithOnConflict(Jobs.TABLE_NAME, null, contentValues, SQLiteDatabase.CONFLICT_IGNORE);
}
private void insertConstraintSpecs(@NonNull SQLiteDatabase db, @NonNull List<ConstraintSpec> constraints) {
for (ConstraintSpec constraintSpec : constraints) {
ContentValues contentValues = new ContentValues();
contentValues.put(Constraints.JOB_SPEC_ID, constraintSpec.getJobSpecId());
contentValues.put(Constraints.FACTORY_KEY, constraintSpec.getFactoryKey());
db.insertWithOnConflict(Constraints.TABLE_NAME, null ,contentValues, SQLiteDatabase.CONFLICT_IGNORE);
}
}
private void insertDependencySpecs(@NonNull SQLiteDatabase db, @NonNull List<DependencySpec> dependencies) {
for (DependencySpec dependencySpec : dependencies) {
ContentValues contentValues = new ContentValues();
contentValues.put(Dependencies.JOB_SPEC_ID, dependencySpec.getJobId());
contentValues.put(Dependencies.DEPENDS_ON_JOB_SPEC_ID, dependencySpec.getDependsOnJobId());
db.insertWithOnConflict(Dependencies.TABLE_NAME, null, contentValues, SQLiteDatabase.CONFLICT_IGNORE);
}
}
private @NonNull JobSpec jobSpecFromCursor(@NonNull Cursor cursor) {
return new JobSpec(cursor.getString(cursor.getColumnIndexOrThrow(Jobs.JOB_SPEC_ID)),
cursor.getString(cursor.getColumnIndexOrThrow(Jobs.FACTORY_KEY)),
cursor.getString(cursor.getColumnIndexOrThrow(Jobs.QUEUE_KEY)),
cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.CREATE_TIME)),
cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.NEXT_RUN_ATTEMPT_TIME)),
cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.RUN_ATTEMPT)),
cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.MAX_ATTEMPTS)),
cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.MAX_BACKOFF)),
cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.LIFESPAN)),
cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.MAX_INSTANCES)),
cursor.getString(cursor.getColumnIndexOrThrow(Jobs.SERIALIZED_DATA)),
cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.IS_RUNNING)) == 1);
}
private @NonNull ConstraintSpec constraintSpecFromCursor(@NonNull Cursor cursor) {
return new ConstraintSpec(cursor.getString(cursor.getColumnIndexOrThrow(Constraints.JOB_SPEC_ID)),
cursor.getString(cursor.getColumnIndexOrThrow(Constraints.FACTORY_KEY)));
}
private @NonNull DependencySpec dependencySpecFromCursor(@NonNull Cursor cursor) {
return new DependencySpec(cursor.getString(cursor.getColumnIndexOrThrow(Dependencies.JOB_SPEC_ID)),
cursor.getString(cursor.getColumnIndexOrThrow(Dependencies.DEPENDS_ON_JOB_SPEC_ID)));
}
}

View File

@ -458,9 +458,8 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
return ECKeyPair(DjbECPublicKey(keyPair.publicKey.serialize().removingIdPrefixIfNeeded()), DjbECPrivateKey(keyPair.privateKey.serialize())) return ECKeyPair(DjbECPublicKey(keyPair.publicKey.serialize().removingIdPrefixIfNeeded()), DjbECPrivateKey(keyPair.privateKey.serialize()))
} }
fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String) { fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) {
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
val timestamp = Date().time.toString()
val index = "$groupPublicKey-$timestamp" val index = "$groupPublicKey-$timestamp"
val encryptionKeyPairPublicKey = encryptionKeyPair.publicKey.serialize().toHexString().removingIdPrefixIfNeeded() val encryptionKeyPairPublicKey = encryptionKeyPair.publicKey.serialize().toHexString().removingIdPrefixIfNeeded()
val encryptionKeyPairPrivateKey = encryptionKeyPair.privateKey.serialize().toHexString() val encryptionKeyPairPrivateKey = encryptionKeyPair.privateKey.serialize().toHexString()

View File

@ -4,11 +4,8 @@ import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.JsonUtil
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
@ -24,12 +21,6 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
val createPublicChatTableCommand = "CREATE TABLE $publicChatTable ($threadID INTEGER PRIMARY KEY, $publicChat TEXT);" val createPublicChatTableCommand = "CREATE TABLE $publicChatTable ($threadID INTEGER PRIMARY KEY, $publicChat TEXT);"
} }
fun getThreadID(hexEncodedPublicKey: String): Long {
val address = Address.fromSerialized(hexEncodedPublicKey)
val recipient = Recipient.from(context, address, false)
return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient)
}
fun getAllOpenGroups(): Map<Long, OpenGroup> { fun getAllOpenGroups(): Map<Long, OpenGroup> {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
var cursor: Cursor? = null var cursor: Cursor? = null
@ -61,6 +52,13 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
} }
} }
fun getThreadId(openGroup: OpenGroup): Long? {
val database = databaseHelper.readableDatabase
return database.get(publicChatTable, "$publicChat = ?", arrayOf(JsonUtil.toJson(openGroup.toJson()))) { cursor ->
cursor.getLong(threadID)
}
}
fun setOpenGroupChat(openGroup: OpenGroup, threadID: Long) { fun setOpenGroupChat(openGroup: OpenGroup, threadID: Long) {
if (threadID < 0) { if (threadID < 0) {
return return

View File

@ -37,6 +37,13 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
public abstract void markExpireStarted(long messageId, long startTime); public abstract void markExpireStarted(long messageId, long startTime);
public abstract void markAsSent(long messageId, boolean secure); public abstract void markAsSent(long messageId, boolean secure);
public abstract void markAsSyncing(long id);
public abstract void markAsResyncing(long id);
public abstract void markAsSyncFailed(long id);
public abstract void markUnidentified(long messageId, boolean unidentified); public abstract void markUnidentified(long messageId, boolean unidentified);
public abstract void markAsDeleted(long messageId, boolean read, boolean hasMention); public abstract void markAsDeleted(long messageId, boolean read, boolean hasMention);
@ -199,7 +206,6 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
contentValues.put(THREAD_ID, newThreadId); contentValues.put(THREAD_ID, newThreadId);
db.update(getTableName(), contentValues, where, args); db.update(getTableName(), contentValues, where, args);
} }
public static class SyncMessageId { public static class SyncMessageId {
private final Address address; private final Address address;

View File

@ -20,13 +20,11 @@ import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import com.annimon.stream.Stream import com.annimon.stream.Stream
import com.google.android.mms.pdu_alt.NotificationInd
import com.google.android.mms.pdu_alt.PduHeaders import com.google.android.mms.pdu_alt.PduHeaders
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import org.session.libsession.messaging.messages.signal.IncomingMediaMessage import org.session.libsession.messaging.messages.signal.IncomingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingExpirationUpdateMessage
import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingSecureMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingSecureMediaMessage
@ -35,21 +33,19 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.UNKNOWN import org.session.libsession.utilities.Address.Companion.UNKNOWN
import org.session.libsession.utilities.Address.Companion.fromExternal import org.session.libsession.utilities.Address.Companion.fromExternal
import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.Contact import org.session.libsession.utilities.Contact
import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID
import org.session.libsession.utilities.IdentityKeyMismatch import org.session.libsession.utilities.IdentityKeyMismatch
import org.session.libsession.utilities.IdentityKeyMismatchList import org.session.libsession.utilities.IdentityKeyMismatchList
import org.session.libsession.utilities.NetworkFailure import org.session.libsession.utilities.NetworkFailure
import org.session.libsession.utilities.NetworkFailureList import org.session.libsession.utilities.NetworkFailureList
import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled
import org.session.libsession.utilities.Util.toIsoBytes import org.session.libsession.utilities.Util.toIsoBytes
import org.session.libsession.utilities.Util.toIsoString
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.recipients.RecipientFormattingException
import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.ThreadUtils.queue import org.session.libsignal.utilities.ThreadUtils.queue
@ -59,11 +55,13 @@ import org.thoughtcrime.securesms.database.SmsDatabase.InsertListener
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
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.NotificationMmsMessageRecord import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord
import org.thoughtcrime.securesms.database.model.Quote import org.thoughtcrime.securesms.database.model.Quote
import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
import org.thoughtcrime.securesms.mms.MmsException import org.thoughtcrime.securesms.mms.MmsException
import org.thoughtcrime.securesms.mms.SlideDeck import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.util.asSequence
import java.io.Closeable import java.io.Closeable
import java.io.IOException import java.io.IOException
import java.security.SecureRandom import java.security.SecureRandom
@ -90,54 +88,22 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
return 0 return 0
} }
fun addFailures(messageId: Long, failure: List<NetworkFailure>) { fun isOutgoingMessage(timestamp: Long): Boolean =
try { databaseHelper.writableDatabase.query(
addToDocument(messageId, NETWORK_FAILURE, failure, NetworkFailureList::class.java) TABLE_NAME,
} catch (e: IOException) { arrayOf(ID, THREAD_ID, MESSAGE_BOX, ADDRESS),
Log.w(TAG, e) DATE_SENT + " = ?",
arrayOf(timestamp.toString()),
null,
null,
null,
null
).use { cursor ->
cursor.asSequence()
.map { cursor.getColumnIndexOrThrow(MESSAGE_BOX) }
.map(cursor::getLong)
.any { MmsSmsColumns.Types.isOutgoingMessageType(it) }
} }
}
fun removeFailure(messageId: Long, failure: NetworkFailure?) {
try {
removeFromDocument(messageId, NETWORK_FAILURE, failure, NetworkFailureList::class.java)
} catch (e: IOException) {
Log.w(TAG, e)
}
}
fun isOutgoingMessage(timestamp: Long): Boolean {
val database = databaseHelper.writableDatabase
var cursor: Cursor? = null
var isOutgoing = false
try {
cursor = database.query(
TABLE_NAME,
arrayOf<String>(ID, THREAD_ID, MESSAGE_BOX, ADDRESS),
DATE_SENT + " = ?",
arrayOf(timestamp.toString()),
null,
null,
null,
null
)
while (cursor.moveToNext()) {
if (MmsSmsColumns.Types.isOutgoingMessageType(
cursor.getLong(
cursor.getColumnIndexOrThrow(
MESSAGE_BOX
)
)
)
) {
isOutgoing = true
}
}
} finally {
cursor?.close()
}
return isOutgoing
}
fun incrementReceiptCount( fun incrementReceiptCount(
messageId: SyncMessageId, messageId: SyncMessageId,
@ -191,7 +157,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
) )
get(context).groupReceiptDatabase() get(context).groupReceiptDatabase()
.update(ourAddress, id, status, timestamp) .update(ourAddress, id, status, timestamp)
get(context).threadDatabase().update(threadId, false) get(context).threadDatabase().update(threadId, false, true)
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
} }
} }
@ -234,34 +200,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
} }
} }
@Throws(RecipientFormattingException::class, MmsException::class)
private fun getThreadIdFor(retrieved: IncomingMediaMessage): Long {
return if (retrieved.groupId != null) {
val groupRecipients = Recipient.from(
context,
retrieved.groupId,
true
)
get(context).threadDatabase().getOrCreateThreadIdFor(groupRecipients)
} else {
val sender = Recipient.from(
context,
retrieved.from,
true
)
get(context).threadDatabase().getOrCreateThreadIdFor(sender)
}
}
private fun getThreadIdFor(notification: NotificationInd): Long {
val fromString =
if (notification.from != null && notification.from.textString != null) toIsoString(
notification.from.textString
) else ""
val recipient = Recipient.from(context, fromExternal(context, fromString), false)
return get(context).threadDatabase().getOrCreateThreadIdFor(recipient)
}
private fun rawQuery(where: String, arguments: Array<String>?): Cursor { private fun rawQuery(where: String, arguments: Array<String>?): Cursor {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
return database.rawQuery( return database.rawQuery(
@ -272,10 +210,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
) )
} }
fun getMessages(idsAsString: String): Cursor {
return rawQuery(idsAsString, null)
}
fun getMessage(messageId: Long): Cursor { fun getMessage(messageId: Long): Cursor {
val cursor = rawQuery(RAW_ID_WHERE, arrayOf(messageId.toString())) val cursor = rawQuery(RAW_ID_WHERE, arrayOf(messageId.toString()))
setNotifyConverationListeners(cursor, getThreadIdForMessage(messageId)) setNotifyConverationListeners(cursor, getThreadIdForMessage(messageId))
@ -301,52 +235,44 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
" WHERE " + ID + " = ?", arrayOf(id.toString() + "") " WHERE " + ID + " = ?", arrayOf(id.toString() + "")
) )
if (threadId.isPresent) { if (threadId.isPresent) {
get(context).threadDatabase().update(threadId.get(), false) get(context).threadDatabase().update(threadId.get(), false, true)
} }
} }
fun markAsPendingInsecureSmsFallback(messageId: Long) { private fun markAs(
val threadId = getThreadIdForMessage(messageId) messageId: Long,
baseType: Long,
threadId: Long = getThreadIdForMessage(messageId)
) {
updateMailboxBitmask( updateMailboxBitmask(
messageId, messageId,
MmsSmsColumns.Types.BASE_TYPE_MASK, MmsSmsColumns.Types.BASE_TYPE_MASK,
MmsSmsColumns.Types.BASE_PENDING_INSECURE_SMS_FALLBACK, baseType,
Optional.of(threadId) Optional.of(threadId)
) )
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
} }
override fun markAsSyncing(messageId: Long) {
markAs(messageId, MmsSmsColumns.Types.BASE_SYNCING_TYPE)
}
override fun markAsResyncing(messageId: Long) {
markAs(messageId, MmsSmsColumns.Types.BASE_RESYNCING_TYPE)
}
override fun markAsSyncFailed(messageId: Long) {
markAs(messageId, MmsSmsColumns.Types.BASE_SYNC_FAILED_TYPE)
}
fun markAsSending(messageId: Long) { fun markAsSending(messageId: Long) {
val threadId = getThreadIdForMessage(messageId) markAs(messageId, MmsSmsColumns.Types.BASE_SENDING_TYPE)
updateMailboxBitmask(
messageId,
MmsSmsColumns.Types.BASE_TYPE_MASK,
MmsSmsColumns.Types.BASE_SENDING_TYPE,
Optional.of(threadId)
)
notifyConversationListeners(threadId)
} }
fun markAsSentFailed(messageId: Long) { fun markAsSentFailed(messageId: Long) {
val threadId = getThreadIdForMessage(messageId) markAs(messageId, MmsSmsColumns.Types.BASE_SENT_FAILED_TYPE)
updateMailboxBitmask(
messageId,
MmsSmsColumns.Types.BASE_TYPE_MASK,
MmsSmsColumns.Types.BASE_SENT_FAILED_TYPE,
Optional.of(threadId)
)
notifyConversationListeners(threadId)
} }
override fun markAsSent(messageId: Long, secure: Boolean) { override fun markAsSent(messageId: Long, secure: Boolean) {
val threadId = getThreadIdForMessage(messageId) markAs(messageId, MmsSmsColumns.Types.BASE_SENT_TYPE or if (secure) MmsSmsColumns.Types.PUSH_MESSAGE_BIT or MmsSmsColumns.Types.SECURE_MESSAGE_BIT else 0)
updateMailboxBitmask(
messageId,
MmsSmsColumns.Types.BASE_TYPE_MASK,
MmsSmsColumns.Types.BASE_SENT_TYPE or if (secure) MmsSmsColumns.Types.PUSH_MESSAGE_BIT or MmsSmsColumns.Types.SECURE_MESSAGE_BIT else 0,
Optional.of(threadId)
)
notifyConversationListeners(threadId)
} }
override fun markUnidentified(messageId: Long, unidentified: Boolean) { override fun markUnidentified(messageId: Long, unidentified: Boolean) {
@ -366,21 +292,12 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val attachmentDatabase = get(context).attachmentDatabase() val attachmentDatabase = get(context).attachmentDatabase()
queue(Runnable { attachmentDatabase.deleteAttachmentsForMessage(messageId) }) queue(Runnable { attachmentDatabase.deleteAttachmentsForMessage(messageId) })
val threadId = getThreadIdForMessage(messageId) val threadId = getThreadIdForMessage(messageId)
if (!read) {
val mentionChange = if (hasMention) { 1 } else { 0 } markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId)
get(context).threadDatabase().decrementUnread(threadId, 1, mentionChange)
}
updateMailboxBitmask(
messageId,
MmsSmsColumns.Types.BASE_TYPE_MASK,
MmsSmsColumns.Types.BASE_DELETED_TYPE,
Optional.of(threadId)
)
notifyConversationListeners(threadId)
} }
override fun markExpireStarted(messageId: Long) { override fun markExpireStarted(messageId: Long) {
markExpireStarted(messageId, System.currentTimeMillis()) markExpireStarted(messageId, SnodeAPI.nowWithOffset)
} }
override fun markExpireStarted(messageId: Long, startedTimestamp: Long) { override fun markExpireStarted(messageId: Long, startedTimestamp: Long) {
@ -399,6 +316,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(id.toString())) database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(id.toString()))
} }
fun setMessagesRead(threadId: Long, beforeTime: Long): List<MarkedMessageInfo> {
return setMessagesRead(
THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1) AND " + DATE_SENT + " <= ?",
arrayOf(threadId.toString(), beforeTime.toString())
)
}
fun setMessagesRead(threadId: Long): List<MarkedMessageInfo> { fun setMessagesRead(threadId: Long): List<MarkedMessageInfo> {
return setMessagesRead( return setMessagesRead(
THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)", THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)",
@ -406,10 +330,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
) )
} }
fun setAllMessagesRead(): List<MarkedMessageInfo> {
return setMessagesRead(READ + " = 0", null)
}
private fun setMessagesRead(where: String, arguments: Array<String>?): List<MarkedMessageInfo> { private fun setMessagesRead(where: String, arguments: Array<String>?): List<MarkedMessageInfo> {
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
val result: MutableList<MarkedMessageInfo> = LinkedList() val result: MutableList<MarkedMessageInfo> = LinkedList()
@ -418,7 +338,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
try { try {
cursor = database.query( cursor = database.query(
TABLE_NAME, TABLE_NAME,
arrayOf<String>(ID, ADDRESS, DATE_SENT, MESSAGE_BOX, EXPIRES_IN, EXPIRE_STARTED), arrayOf(ID, ADDRESS, DATE_SENT, MESSAGE_BOX, EXPIRES_IN, EXPIRE_STARTED),
where, where,
arguments, arguments,
null, null,
@ -627,18 +547,9 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
contentLocation: String, contentLocation: String,
threadId: Long, mailbox: Long, threadId: Long, mailbox: Long,
serverTimestamp: Long, serverTimestamp: Long,
runIncrement: Boolean,
runThreadUpdate: Boolean runThreadUpdate: Boolean
): Optional<InsertResult> { ): Optional<InsertResult> {
var threadId = threadId if (threadId < 0 ) throw MmsException("No thread ID supplied!")
if (threadId == -1L || retrieved.isGroupMessage) {
try {
threadId = getThreadIdFor(retrieved)
} catch (e: RecipientFormattingException) {
Log.w("MmsDatabase", e)
if (threadId == -1L) throw MmsException(e)
}
}
val contentValues = ContentValues() val contentValues = ContentValues()
contentValues.put(DATE_SENT, retrieved.sentTimeMillis) contentValues.put(DATE_SENT, retrieved.sentTimeMillis)
contentValues.put(ADDRESS, retrieved.from.serialize()) contentValues.put(ADDRESS, retrieved.from.serialize())
@ -692,12 +603,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
null, null,
) )
if (!MmsSmsColumns.Types.isExpirationTimerUpdate(mailbox)) { if (!MmsSmsColumns.Types.isExpirationTimerUpdate(mailbox)) {
if (runIncrement) {
val mentionAmount = if (retrieved.hasMention()) { 1 } else { 0 }
get(context).threadDatabase().incrementUnread(threadId, 1, mentionAmount)
}
if (runThreadUpdate) { if (runThreadUpdate) {
get(context).threadDatabase().update(threadId, true) get(context).threadDatabase().update(threadId, true, true)
} }
} }
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
@ -711,27 +618,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
serverTimestamp: Long, serverTimestamp: Long,
runThreadUpdate: Boolean runThreadUpdate: Boolean
): Optional<InsertResult> { ): Optional<InsertResult> {
var threadId = threadId if (threadId < 0 ) throw MmsException("No thread ID supplied!")
if (threadId == -1L) {
if (retrieved.isGroup) {
val decodedGroupId: String = if (retrieved is OutgoingExpirationUpdateMessage) {
retrieved.groupId
} else {
(retrieved as OutgoingGroupMediaMessage).groupId
}
val groupId: String
groupId = try {
doubleEncodeGroupID(decodedGroupId)
} catch (e: IOException) {
Log.e(TAG, "Couldn't encrypt group ID")
throw MmsException(e)
}
val group = Recipient.from(context, fromSerialized(groupId), false)
threadId = get(context).threadDatabase().getOrCreateThreadIdFor(group)
} else {
threadId = get(context).threadDatabase().getOrCreateThreadIdFor(retrieved.recipient)
}
}
val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate) val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate)
if (messageId == -1L) { if (messageId == -1L) {
return Optional.absent() return Optional.absent()
@ -746,7 +633,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
retrieved: IncomingMediaMessage, retrieved: IncomingMediaMessage,
threadId: Long, threadId: Long,
serverTimestamp: Long = 0, serverTimestamp: Long = 0,
runIncrement: Boolean,
runThreadUpdate: Boolean runThreadUpdate: Boolean
): Optional<InsertResult> { ): Optional<InsertResult> {
var type = MmsSmsColumns.Types.BASE_INBOX_TYPE or MmsSmsColumns.Types.SECURE_MESSAGE_BIT var type = MmsSmsColumns.Types.BASE_INBOX_TYPE or MmsSmsColumns.Types.SECURE_MESSAGE_BIT
@ -765,7 +651,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
if (retrieved.isMessageRequestResponse) { if (retrieved.isMessageRequestResponse) {
type = type or MmsSmsColumns.Types.MESSAGE_REQUEST_RESPONSE_BIT type = type or MmsSmsColumns.Types.MESSAGE_REQUEST_RESPONSE_BIT
} }
return insertMessageInbox(retrieved, "", threadId, type, serverTimestamp, runIncrement, runThreadUpdate) return insertMessageInbox(retrieved, "", threadId, type, serverTimestamp, runThreadUpdate)
} }
@JvmOverloads @JvmOverloads
@ -798,7 +684,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
// In open groups messages should be sorted by their server timestamp // In open groups messages should be sorted by their server timestamp
var receivedTimestamp = serverTimestamp var receivedTimestamp = serverTimestamp
if (serverTimestamp == 0L) { if (serverTimestamp == 0L) {
receivedTimestamp = System.currentTimeMillis() receivedTimestamp = SnodeAPI.nowWithOffset
} }
contentValues.put(DATE_RECEIVED, receivedTimestamp) contentValues.put(DATE_RECEIVED, receivedTimestamp)
contentValues.put(SUBSCRIPTION_ID, message.subscriptionId) contentValues.put(SUBSCRIPTION_ID, message.subscriptionId)
@ -854,10 +740,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
) )
} }
with (get(context).threadDatabase()) { with (get(context).threadDatabase()) {
setLastSeen(threadId) val lastSeen = getLastSeenAndHasSent(threadId).first()
if (lastSeen < message.sentTimeMillis) {
setLastSeen(threadId, message.sentTimeMillis)
}
setHasSent(threadId, true) setHasSent(threadId, true)
if (runThreadUpdate) { if (runThreadUpdate) {
update(threadId, true) update(threadId, true, true)
} }
} }
return messageId return messageId
@ -975,7 +864,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
groupReceiptDatabase.deleteRowsForMessage(messageId) groupReceiptDatabase.deleteRowsForMessage(messageId)
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
database!!.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString())) database!!.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString()))
val threadDeleted = get(context).threadDatabase().update(threadId, false) val threadDeleted = get(context).threadDatabase().update(threadId, false, true)
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
notifyStickerListeners() notifyStickerListeners()
notifyStickerPackListeners() notifyStickerPackListeners()
@ -992,7 +881,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
database!!.delete(TABLE_NAME, ID_IN, arrayOf(messageIds.joinToString(","))) database!!.delete(TABLE_NAME, ID_IN, arrayOf(messageIds.joinToString(",")))
val threadDeleted = get(context).threadDatabase().update(threadId, false) val threadDeleted = get(context).threadDatabase().update(threadId, false, true)
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
notifyStickerListeners() notifyStickerListeners()
notifyStickerPackListeners() notifyStickerPackListeners()
@ -1245,7 +1134,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
} }
val threadDb = get(context).threadDatabase() val threadDb = get(context).threadDatabase()
for (threadId in threadIds) { for (threadId in threadIds) {
val threadDeleted = threadDb.update(threadId, false) val threadDeleted = threadDb.update(threadId, false, true)
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
} }
notifyStickerListeners() notifyStickerListeners()
@ -1315,7 +1204,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val slideDeck = SlideDeck(context, message!!.attachments) val slideDeck = SlideDeck(context, message!!.attachments)
return MediaMmsMessageRecord( return MediaMmsMessageRecord(
id, message.recipient, message.recipient, id, message.recipient, message.recipient,
1, System.currentTimeMillis(), System.currentTimeMillis(), 1, SnodeAPI.nowWithOffset, SnodeAPI.nowWithOffset,
0, threadId, message.body, 0, threadId, message.body,
slideDeck, slideDeck.slides.size, slideDeck, slideDeck.slides.size,
if (message.isSecure) MmsSmsColumns.Types.getOutgoingEncryptedMessageType() else MmsSmsColumns.Types.getOutgoingSmsMessageType(), if (message.isSecure) MmsSmsColumns.Types.getOutgoingEncryptedMessageType() else MmsSmsColumns.Types.getOutgoingSmsMessageType(),
@ -1323,7 +1212,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
LinkedList(), LinkedList(),
message.subscriptionId, message.subscriptionId,
message.expiresIn, message.expiresIn,
System.currentTimeMillis(), 0, SnodeAPI.nowWithOffset, 0,
if (message.outgoingQuote != null) Quote( if (message.outgoingQuote != null) Quote(
message.outgoingQuote!!.id, message.outgoingQuote!!.id,
message.outgoingQuote!!.author, message.outgoingQuote!!.author,
@ -1437,25 +1326,16 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val attachments = get(context).attachmentDatabase().getAttachment( val attachments = get(context).attachmentDatabase().getAttachment(
cursor cursor
) )
val contacts: List<Contact?> = getSharedContacts( val contacts: List<Contact?> = getSharedContacts(cursor, attachments)
cursor, attachments val contactAttachments: Set<Attachment?> =
) contacts.mapNotNull { it?.avatarAttachment }.toSet()
val contactAttachments = val previews: List<LinkPreview?> = getLinkPreviews(cursor, attachments)
contacts.map { obj: Contact? -> obj!!.avatarAttachment } val previewAttachments: Set<Attachment?> =
.filter { a: Attachment? -> a != null } previews.mapNotNull { it?.getThumbnail()?.orNull() }.toSet()
.toSet()
val previews: List<LinkPreview?> = getLinkPreviews(
cursor, attachments
)
val previewAttachments =
previews.filter { lp: LinkPreview? -> lp!!.getThumbnail().isPresent }
.map { lp: LinkPreview? -> lp!!.getThumbnail().get() }
.toSet()
val slideDeck = getSlideDeck( val slideDeck = getSlideDeck(
Stream.of(attachments) attachments
.filterNot { o: DatabaseAttachment? -> contactAttachments.contains(o) } .filterNot { o: DatabaseAttachment? -> o in contactAttachments }
.filterNot { o: DatabaseAttachment? -> previewAttachments.contains(o) } .filterNot { o: DatabaseAttachment? -> o in previewAttachments }
.toList()
) )
val quote = getQuote(cursor) val quote = getQuote(cursor)
val reactions = get(context).reactionDatabase().getReactions(cursor) val reactions = get(context).reactionDatabase().getReactions(cursor)
@ -1513,11 +1393,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val retrievedQuote = get(context).mmsSmsDatabase().getMessageFor(quoteId, quoteAuthor) val retrievedQuote = get(context).mmsSmsDatabase().getMessageFor(quoteId, quoteAuthor)
val quoteText = retrievedQuote?.body val quoteText = retrievedQuote?.body
val quoteMissing = retrievedQuote == null val quoteMissing = retrievedQuote == null
val attachments = get(context).attachmentDatabase().getAttachment(cursor) val quoteDeck = (
val quoteAttachments: List<Attachment?>? = (retrievedQuote as? MmsMessageRecord)?.slideDeck ?:
Stream.of(attachments).filter { obj: DatabaseAttachment? -> obj!!.isQuote } Stream.of(get(context).attachmentDatabase().getAttachment(cursor))
.filter { obj: DatabaseAttachment? -> obj!!.isQuote }
.toList() .toList()
val quoteDeck = SlideDeck(context, quoteAttachments!!) .let { SlideDeck(context, it) }
)
return Quote( return Quote(
quoteId, quoteId,
fromExternal(context, quoteAuthor), fromExternal(context, quoteAuthor),
@ -1617,6 +1499,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
SHARED_CONTACTS, SHARED_CONTACTS,
LINK_PREVIEWS, LINK_PREVIEWS,
UNIDENTIFIED, UNIDENTIFIED,
HAS_MENTION,
"json_group_array(json_object(" + "json_group_array(json_object(" +
"'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " + "'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " +
"'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " + "'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " +
@ -1659,4 +1542,4 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
const val CREATE_REACTIONS_LAST_SEEN_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_LAST_SEEN INTEGER DEFAULT 0;" const val CREATE_REACTIONS_LAST_SEEN_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_LAST_SEEN INTEGER DEFAULT 0;"
const val CREATE_HAS_MENTION_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $HAS_MENTION INTEGER DEFAULT 0;" const val CREATE_HAS_MENTION_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $HAS_MENTION INTEGER DEFAULT 0;"
} }
} }

View File

@ -47,8 +47,13 @@ public interface MmsSmsColumns {
protected static final long BASE_PENDING_INSECURE_SMS_FALLBACK = 26; protected static final long BASE_PENDING_INSECURE_SMS_FALLBACK = 26;
public static final long BASE_DRAFT_TYPE = 27; public static final long BASE_DRAFT_TYPE = 27;
protected static final long BASE_DELETED_TYPE = 28; protected static final long BASE_DELETED_TYPE = 28;
protected static final long BASE_SYNCING_TYPE = 29;
protected static final long BASE_RESYNCING_TYPE = 30;
protected static final long BASE_SYNC_FAILED_TYPE = 31;
protected static final long[] OUTGOING_MESSAGE_TYPES = {BASE_OUTBOX_TYPE, BASE_SENT_TYPE, protected static final long[] OUTGOING_MESSAGE_TYPES = {BASE_OUTBOX_TYPE, BASE_SENT_TYPE,
BASE_SYNCING_TYPE, BASE_RESYNCING_TYPE,
BASE_SYNC_FAILED_TYPE,
BASE_SENDING_TYPE, BASE_SENT_FAILED_TYPE, BASE_SENDING_TYPE, BASE_SENT_FAILED_TYPE,
BASE_PENDING_SECURE_SMS_FALLBACK, BASE_PENDING_SECURE_SMS_FALLBACK,
BASE_PENDING_INSECURE_SMS_FALLBACK, BASE_PENDING_INSECURE_SMS_FALLBACK,
@ -109,6 +114,18 @@ public interface MmsSmsColumns {
return (type & BASE_TYPE_MASK) == BASE_DRAFT_TYPE; return (type & BASE_TYPE_MASK) == BASE_DRAFT_TYPE;
} }
public static boolean isResyncingType(long type) {
return (type & BASE_TYPE_MASK) == BASE_RESYNCING_TYPE;
}
public static boolean isSyncingType(long type) {
return (type & BASE_TYPE_MASK) == BASE_SYNCING_TYPE;
}
public static boolean isSyncFailedMessageType(long type) {
return (type & BASE_TYPE_MASK) == BASE_SYNC_FAILED_TYPE;
}
public static boolean isFailedMessageType(long type) { public static boolean isFailedMessageType(long type) {
return (type & BASE_TYPE_MASK) == BASE_SENT_FAILED_TYPE; return (type & BASE_TYPE_MASK) == BASE_SENT_FAILED_TYPE;
} }

View File

@ -16,6 +16,8 @@
*/ */
package org.thoughtcrime.securesms.database; package org.thoughtcrime.securesms.database;
import static org.thoughtcrime.securesms.database.MmsDatabase.MESSAGE_BOX;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
@ -25,6 +27,7 @@ import androidx.annotation.Nullable;
import net.zetetic.database.sqlcipher.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import net.zetetic.database.sqlcipher.SQLiteQueryBuilder; import net.zetetic.database.sqlcipher.SQLiteQueryBuilder;
import org.jetbrains.annotations.NotNull;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.Util; import org.session.libsession.utilities.Util;
import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId; import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId;
@ -36,6 +39,8 @@ import java.io.Closeable;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
import kotlin.Pair;
public class MmsSmsDatabase extends Database { public class MmsSmsDatabase extends Database {
@SuppressWarnings("unused") @SuppressWarnings("unused")
@ -259,8 +264,8 @@ public class MmsSmsDatabase extends Database {
return -1; return -1;
} }
public int getMessagePositionInConversation(long threadId, long sentTimestamp, @NonNull Address address) { public int getMessagePositionInConversation(long threadId, long sentTimestamp, @NonNull Address address, boolean reverse) {
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String order = MmsSmsColumns.NORMALIZED_DATE_SENT + (reverse ? " DESC" : " ASC");
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.ADDRESS }, selection, order, null)) { try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.ADDRESS }, selection, order, null)) {
@ -512,6 +517,23 @@ public class MmsSmsDatabase extends Database {
return new Reader(cursor); return new Reader(cursor);
} }
@NotNull
public Pair<Boolean, Long> timestampAndDirectionForCurrent(@NotNull Cursor cursor) {
int sentColumn = cursor.getColumnIndex(MmsSmsColumns.NORMALIZED_DATE_SENT);
String msgType = cursor.getString(cursor.getColumnIndexOrThrow(TRANSPORT));
long sentTime = cursor.getLong(sentColumn);
long type = 0;
if (MmsSmsDatabase.MMS_TRANSPORT.equals(msgType)) {
int typeIndex = cursor.getColumnIndex(MESSAGE_BOX);
type = cursor.getLong(typeIndex);
} else if (MmsSmsDatabase.SMS_TRANSPORT.equals(msgType)) {
int typeIndex = cursor.getColumnIndex(SmsDatabase.TYPE);
type = cursor.getLong(typeIndex);
}
return new Pair<Boolean, Long>(MmsSmsColumns.Types.isOutgoingMessageType(type), sentTime);
}
public class Reader implements Closeable { public class Reader implements Closeable {
private final Cursor cursor; private final Cursor cursor;

View File

@ -62,6 +62,7 @@ public class RecipientDatabase extends Database {
private static final String UNIDENTIFIED_ACCESS_MODE = "unidentified_access_mode"; private static final String UNIDENTIFIED_ACCESS_MODE = "unidentified_access_mode";
private static final String FORCE_SMS_SELECTION = "force_sms_selection"; private static final String FORCE_SMS_SELECTION = "force_sms_selection";
private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none
private static final String WRAPPER_HASH = "wrapper_hash";
private static final String AUTO_DOWNLOAD = "auto_download"; // 1 / 0 / -1 flag for whether to auto-download in a conversation, or if the user hasn't selected a preference private static final String AUTO_DOWNLOAD = "auto_download"; // 1 / 0 / -1 flag for whether to auto-download in a conversation, or if the user hasn't selected a preference
private static final String[] RECIPIENT_PROJECTION = new String[] { private static final String[] RECIPIENT_PROJECTION = new String[] {
@ -69,7 +70,7 @@ public class RecipientDatabase extends Database {
PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI, PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI,
SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL, SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
UNIDENTIFIED_ACCESS_MODE, UNIDENTIFIED_ACCESS_MODE,
FORCE_SMS_SELECTION, NOTIFY_TYPE, AUTO_DOWNLOAD, FORCE_SMS_SELECTION, NOTIFY_TYPE, WRAPPER_HASH, AUTO_DOWNLOAD,
}; };
static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION) static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
@ -148,6 +149,11 @@ public class RecipientDatabase extends Database {
"OR "+ADDRESS+" IN (SELECT "+GroupDatabase.TABLE_NAME+"."+GroupDatabase.ADMINS+" FROM "+GroupDatabase.TABLE_NAME+")))"; "OR "+ADDRESS+" IN (SELECT "+GroupDatabase.TABLE_NAME+"."+GroupDatabase.ADMINS+" FROM "+GroupDatabase.TABLE_NAME+")))";
} }
public static String getAddWrapperHash() {
return "ALTER TABLE "+TABLE_NAME+" "+
"ADD COLUMN "+WRAPPER_HASH+" TEXT DEFAULT NULL;";
}
public static final int NOTIFY_TYPE_ALL = 0; public static final int NOTIFY_TYPE_ALL = 0;
public static final int NOTIFY_TYPE_MENTIONS = 1; public static final int NOTIFY_TYPE_MENTIONS = 1;
public static final int NOTIFY_TYPE_NONE = 2; public static final int NOTIFY_TYPE_NONE = 2;
@ -166,18 +172,14 @@ public class RecipientDatabase extends Database {
public Optional<RecipientSettings> getRecipientSettings(@NonNull Address address) { public Optional<RecipientSettings> getRecipientSettings(@NonNull Address address) {
SQLiteDatabase database = databaseHelper.getReadableDatabase(); SQLiteDatabase database = databaseHelper.getReadableDatabase();
Cursor cursor = null;
try { try (Cursor cursor = database.query(TABLE_NAME, null, ADDRESS + " = ?", new String[]{address.serialize()}, null, null, null)) {
cursor = database.query(TABLE_NAME, null, ADDRESS + " = ?", new String[] {address.serialize()}, null, null, null);
if (cursor != null && cursor.moveToNext()) { if (cursor != null && cursor.moveToNext()) {
return getRecipientSettings(cursor); return getRecipientSettings(cursor);
} }
return Optional.absent(); return Optional.absent();
} finally {
if (cursor != null) cursor.close();
} }
} }
@ -207,6 +209,7 @@ public class RecipientDatabase extends Database {
String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL)); String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL));
int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE)); int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE));
boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1; boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1;
String wrapperHash = cursor.getString(cursor.getColumnIndexOrThrow(WRAPPER_HASH));
MaterialColor color; MaterialColor color;
byte[] profileKey = null; byte[] profileKey = null;
@ -238,7 +241,7 @@ public class RecipientDatabase extends Database {
systemPhoneLabel, systemContactUri, systemPhoneLabel, systemContactUri,
signalProfileName, signalProfileAvatar, profileSharing, signalProfileName, signalProfileAvatar, profileSharing,
notificationChannel, Recipient.UnidentifiedAccessMode.fromMode(unidentifiedAccessMode), notificationChannel, Recipient.UnidentifiedAccessMode.fromMode(unidentifiedAccessMode),
forceSmsSelection)); forceSmsSelection, wrapperHash));
} }
public boolean isAutoDownloadFlagSet(Recipient recipient) { public boolean isAutoDownloadFlagSet(Recipient recipient) {
@ -281,6 +284,24 @@ public class RecipientDatabase extends Database {
notifyRecipientListeners(); notifyRecipientListeners();
} }
public boolean getApproved(@NonNull Address address) {
SQLiteDatabase db = getReadableDatabase();
try (Cursor cursor = db.query(TABLE_NAME, new String[]{APPROVED}, ADDRESS + " = ?", new String[]{address.serialize()}, null, null, null)) {
if (cursor != null && cursor.moveToNext()) {
return cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED)) == 1;
}
}
return false;
}
public void setRecipientHash(@NonNull Recipient recipient, String recipientHash) {
ContentValues values = new ContentValues();
values.put(WRAPPER_HASH, recipientHash);
updateOrInsert(recipient.getAddress(), values);
recipient.resolve().setWrapperHash(recipientHash);
notifyRecipientListeners();
}
public void setApproved(@NonNull Recipient recipient, boolean approved) { public void setApproved(@NonNull Recipient recipient, boolean approved) {
ContentValues values = new ContentValues(); ContentValues values = new ContentValues();
values.put(APPROVED, approved ? 1 : 0); values.put(APPROVED, approved ? 1 : 0);
@ -297,15 +318,7 @@ public class RecipientDatabase extends Database {
notifyRecipientListeners(); notifyRecipientListeners();
} }
public void setBlocked(@NonNull Recipient recipient, boolean blocked) { public void setBlocked(@NonNull Iterable<Recipient> recipients, boolean blocked) {
ContentValues values = new ContentValues();
values.put(BLOCK, blocked ? 1 : 0);
updateOrInsert(recipient.getAddress(), values);
recipient.resolve().setBlocked(blocked);
notifyRecipientListeners();
}
public void setBlocked(@NonNull List<Recipient> recipients, boolean blocked) {
SQLiteDatabase db = getWritableDatabase(); SQLiteDatabase db = getWritableDatabase();
db.beginTransaction(); db.beginTransaction();
try { try {

View File

@ -3,8 +3,11 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import androidx.core.database.getStringOrNull
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
@ -42,6 +45,9 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
return database.getAll(sessionContactTable, null, null) { cursor -> return database.getAll(sessionContactTable, null, null) { cursor ->
contactFromCursor(cursor) contactFromCursor(cursor)
}.filter { contact ->
val sessionId = SessionId(contact.sessionID)
sessionId.prefix == IdPrefix.STANDARD
}.toSet() }.toSet()
} }

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