Integrate shared libsession-util library (#1096)

* feat: add some config db basics and DI for it, make the user profile optional, start looking at integrate building from initial dump

* update: get latest util library submodule update

* refactor: fix compile for refactored API

* refactor: naming consistent with library

* feat: add in config storage and injection to common places, managing lifecycle of native instances

* refactor: config database changes, new protos, adding in support for config base namespace queries

* refactor: config query and store use the same format as other platforms

* feat: add batch snode calls and try to poll from all the config namespaces

* fix: add optional namespace in signature and params

* feat: add raw requests for modifying expiry and getting expiries

* feat: add some base config migration logic, start implementing wrappers for conversation and expiry types

* chore: update libsession base

* feat: start integrating conversation wrapper functions

* feat: add basic conversation info volatile types and implementations, start working on tests

* feat: more common library wrapper implementation and test

* fix: tests and compile issues

* fix: fix tests, don't use iterables

* feat: add all iterators and tests

* feat: add in more config factory for volatile

* feat: update request responses and their appropriate processing

* feat: add storage with hashes and some basic profile update logic in config factory probably move that somewhere else

* feat: adding config sync functionality, refactoring jobs to execute in suspend context to do some nice coroutine execution

* refactor: moving some properties around so we have access in libsession

* feat: expand on the config sync job, finish basic implementation to test against

* feat: add forced config sync

* feat: syncs the user profile stuff for now, and errors back to placeholder instead of unknown recipient

* feat: add basic message read logic for synchronizing last reads, need to modify the query to use the last seen instead of the unread count in a subquery possibly for thread display record

* feat: add broken unreads everywhere

* fix: unreads work now for incoming messages, need to sync conv volatile properly still

* feat: batching poll responses properly and handling groups properly

* fix: replace the mark read receiver (from notifications) to use the new set last seen mark read logic

* feat: update to the group list branch

* fix: compile errors from updating library to use latest branch, now requires cmake 3.22.1

* fix: fix the contact tests

* fix: getters weren't getters properly in the config factory, fixed new onboarding from configs

* feat: add the last seen

* feat: start adding user groups wrapper objects

* refactor: add more else branches for unimplemented types

* feat: buffer the last read when in conversation

* feat: add basic contact logic for setting local contact state. Need to implement handling properly

* refactor: trying to just include blocked status for now in updating contacts

* fix: add some more contact syncing: nicknames, approved statuses, blocked statuses

* feat: start implementing hashes in shared lib and refactoring

* feat: start to implement group list info classes and wrappers and refactor to use library based hashes

* feat: incorporate hashes from library, more wrapper for user groups and serialization from c++

* feat: adding more serialization changes for community base info and user groups LGC

* feat: adding more serialize deserialize to legacy closed groups

* feat: finish serial/deserial helper

* feat: just implement deserialize community info

* refactor: refactor tests and wrappers to use less pointers, finish implementing user groups API

* feat: finish latest wrappers fix tests and continue building default generation functions. refactor defaults to be used if no stored data blob in DB

* feat: more usergroup functionality, storage functionality for checking pinned status, adding pinned status for NTS/contacts, move community info parse full url to base community, add StorageProtocol logic for group info

* feat: adding user groups to the list of user configs, refactorign some of the config factory to fetch the user configs easier. Add handling for polling user group namespace

* feat: implement the default user config list

* feat: add user group config handling

* chore: extra missed existing group

* refactor: use existing lookup for objects in wrappers so they don't overwrite missing values

* feat: add contacts expiry serialization/deserialization, more LGC, timestamps to add closed group encryption info (for latest tracking)

* refactor: change how expiration timer works for contacts, set the expiration timer for those conversations in handling contact configs

* feat: add expiration updates via config for contacts as well

* feat: add almost all group editing cases, need to hook into the thread deletion for groups in the user groups

* feat: open group joining should work now

* feat: add groups to configs for push

* fix: handling user group updates bug fix for closed groups instead of all groups

* fix: open group sync persistence

* feat: add in activity finish if recipient no longer exists (deleted thread) from sync

* feat: support avatar removal from shared library

* feat: support thread deletion and refactoring a lot of getOrCreateThread references to go via storage or assume they are correctly set to hook into the contact and volatile creation during thread creation

* fix: database update not deleting in certain circumstances, storage persisting and removing the volatile convo info for thread deletion / creation, NTS hidden getter values in shared library

* refactor: make update listener visibility package

* refactor: update kotlin

* feat: update dependencies and support outdated config messages, refactor config factory to return null configs if new configs not supported

* feat: update shared library to use priority only, fix compile errors, fix group member sync problem

* fix: compile error

* fix: profile avatar fixes for local user now that we aren't setting local user profile key

* Revert "fix: profile avatar fixes for local user now that we aren't setting local user profile key"

This reverts commit 3f569e3403.

* refactor: let the local number update recipient details in profile manager

* fix: don't recreate thread after leaving

* fix: fix up the duplicate thread creation in the message receive handler

* fix: fix the placeholder rendering on new messages, add in extra context logging for adding contacts and preventing new thread creation on new messages of various types

* feat: add test theme for xml layout previews

* feat: add shortened hex for session IDs throughout, replace nullable getName with null in underlying contacts for individual contacts, build shared lib with release mode, remove todo, fix broken unit test

* feat: setup android unit tests for verifying storage behaviours and state of shared configs

* feat: adding dependencies to try and get android tests working, fixing bug with initial config not syncing properly

* fix: remove hilt testing, add spy on app context storage field instead, update libsession-util to fixed sodium cmake branch

* refactor: use PR version of libsession-util to test cmake build

* fix: new build on normal repo

* feat: new libsession util commit

* refactor: remove the old custom build libsodium stuff from cmake

* feat: update libsession module

* fix: add legacy config subscription to the home activity to enable showing banner at any time

* fix: pinned status for communities and groups, group last read time being set to snodeapi.now on finish joining

* fix: some open group volatile convo fix for last read timer being set. Need to investigate further

* fix: prevent blocking local number

* fix: adding in more checks for open group inbox recipients before being saved to the shared configs. Prevent sending typing indicator for blocked users

* fix: add blocked check for read receipt and updating expiring messages

* fix: another contact recipient config library call removed for non-standard IDs

* fix: another ID check

* fix: don't process thread creation for user is sender && recipient (sync message) for message request responses

* refactor: mark as read on open and use less buffer time

* fix: finally fix the darn unread count issue by

* fix: removing debug logs, adding failure error handling logs for expiry message updater, properly using the message thread ID created for the expiring messages. Process the non-thread messages properly with await in BatchMessageReceiveJob

* fix: checking the last read open to message and make sure that scroll behaviour matches expected, fix the config sync job not deleting ALL old hashes only latest

* refactor: try to add a retry logic to config sync job in case of snode failure

* build: update submodule

* fix: remove user notifications for leaving group to prevent synced device issues, don't create thread in messages for new closed groups, includei nactive groups in the deletion queries for merging group configs

* feat: use blinded message count for banner also

* refactor: remove some logging, don't use blinded conversations in the list

* fix: don't set the read flag in update notifications, some roundabout logic for first loads and scrolling to last known positions

* refactor: merge changes, re-add the group check in unapproved messages

* fix: re-poll on fail in case that was breaking anything

* fix: pinning groups and notifying list listeners in threadDb.setPinned

* feat: add in TTL extension subrequest and builder, enable extending TTLs for all latest config messages in poll as subrequest

* feat: add block to the delete all message requests, only if they're not open group inbox contacts

* refactor: disable edit text for non contacts

* refactor: let the user display name return "You" for local user

* fix: prevent NTS self create thread on user view bind

* refactor: remove populate public key cache if needed call which seems unnecessary at that point, maybe UserView refs have changed since 2020

* refactor: use just first visible instead of completely visible, merge message sender changes

* fix: prevent block of users in delete all

* fix: self sync sync message failures for default values

* feat: update libsession-util, adjust docs, update mms and sms to use message sent timestamp instead of -1 for last read in the thread

* fix: some compile issues in tests and some TODOs for things to do before merge

* fix: handle recyclerview scrolled on scroll to first unread if it's the first load

* fix: added more migration code for deleting unnecessary threads and groups, fixed a post-migration last seen issue on last item (current read is now), comment out actual network sync while testing migrations

* feat: adding a force new configs flag and logic for timestamp handling / forced configs, fix issue with handling legacy messages

* refactor: re-add the sending of configs

* fix: don't add contacts if they don't exist in the profile manager

* [wip]
fix: trying to consolidate prof pic and key properly

* feat: add logs and fix compile issue with a themes.xml entry, add removing profile picture into logic for profile manager

* fix: force has sent for local user, only prevent setting last seen for open group recipients, allow empty user pics to trigger config sync in settings

* fix: nts threads

* fix: open group avatar loop for open groups we have left

* feat: add a wrapper hash to track home diff util changes for wrapper contact recipient info, add test for dirty state in double set

* feat: add a dump in there as well

* refactor: more test code refactor

* fix: update last seen if later than current

* fix: open group threads and avatar downloads

* fix: add max size and maybe fix the non-200 sub requests for batches (for 421s in particular)

* fix: open group comparison issues potentially, have to update some more outgoing message open group flags for visibility of details etc

* Updated to the latest libSession-util

* Updated logic to delete legacy groups when kicked/left

* Added the legacy group 'joined_at' value

* Replaced incorrect character in JNI

* Fixed an issue where the group keyPair was getting encoded incorrectly

* Updated the code to ignore outdated legacy group control message changes

* Updated the code to ignore messages invalidated by the config

* [Review] Updated the poller to process config messages before standard

* Cleaned up the outdated message logic

* Fixed inverted config dropping flags

* Fixed an issue where the joining a community would read all messages

Stopped using a reversed RecyclerView in all cases (caused the unread issue)
Updated the logic to jump to the newly sent message when sending a message (to be consistent with other platforms)
Updated the logic to refresh the DB unread count when the cursor receives an update

* Updated the conversation to highlight the first unread message on open

* Fixed a couple of bugs with the highlighting

* Fixed a bug where the user profile picture wasn't downloading correctly

* feat: add all namespaces to delete all messages request and signature verification data

* fix: merge namespace hashes for signature returned and

* fix: import correct scroll to bottom

* build: update version code and name

* fix: initial contact generation fix for existing blinded contacts

* fix: initial convo generation fix for existing blinded convos (?)

* fix: conversation unread not doing a check for standard ID prefix

* fix: thread ID not being created for legacy config messages

* fix: don't treat 404 as bad snode

* fix: don't add retrieve profile job if we have one for that address

* build: update build code

* fix: reduce attempts for downloading image, invert unreachable type check

* fix: attempting to fix preventing message processing if group thread is not active for closed groups and initial contact dump only allows conversations with thread, may need further optimisations though

* feat: Added an unread marker and search result focus highlighting

* fix: empty set in appropriate places for current closed groups

* build: update build version code

* fix: fix the notifications and request at appropriate time

* refactor: remove debug logging for thread create and delete

* build: update build number

* fix: new community doesn't break persisting config if the .add request fails

* build: trying to track down broken retrieve avatar job

* feat: update to latest libsession dev

* fix: maybe fix avatar download for new messages

* fix: 404s causing snode errors and trying to retrieve avatars that have already 404'd a lot

* fix: closed group creation sets thread date to formation timestamp

* build: update version code

* build: update version code

* build: remove debuggable release build

* fix: use new permissions for external attachments

* build: update version code

* chore: remove debug logs

* fix: tests and main thread blocking db fetch for path status view

* wip: trying to track down failure to mark conversation as read in delayed group add

* wip: add more logs for initial last Read sync of communities

* wip: maybe the volatile is being updated with 0 on batch message receive?

* fix: maybe syncing read statuses are working now

* chore: remove debug logs

* build: update build number

* fix: trying to improve performance

* fix: add close to banner

* refactor: hide seed reminder in preview

* build: update build number

* fix: maybe requires update thread no matter what

* fix: message request banner shows again

* fix: android tests work again and permissions

* fix: blocked contacts click handler being overridden by something

* Revert "fix: blocked contacts click handler being overridden by something"

This reverts commit 608572fc42.

* build: update build number

* refactor: remove unused dependencies and update minor for sqlcipher

* fix: actually do insert contact, because otherwise name doesn't get set properly

* fix: maybe fix scroll to bottom issue

* build: update build number

* fix: the message time and jump to message queries are more optimized

* fix: maybe fix the last seen issues

* build: update build number

* fix: pfp broken closed groups why

* fix: add admins and members as member list instead of just members

* fix: exclude lgc without membership > 1 and inactive explicitly

* fix: submodule update

* fix: compiles with removal of iterator erase

* fix: unread indicator updates properly in ConversationActivityV2

* fix: unread notifications clear and altered if any notifications exist (prevents clearing read notifications in conversation or on home screen)

* refactor: profile pictures kinda broken

* build: update build number

* refactor: remove full hash from log

* fix: isPinned threadDB call

* refactor: use mutex in all libsession native calls, change timestamp

* refactor: add basic support for blinded v2 prefixes

---------

Co-authored-by: Morgan Pretty <morgan.t.pretty@gmail.com>
This commit is contained in:
0x330a 2023-07-14 18:27:13 +10:00 committed by GitHub
parent 96ec733517
commit ac18f1cbfe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
171 changed files with 8087 additions and 1448 deletions

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

@ -93,17 +93,13 @@ dependencies {
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection' exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
} }
implementation 'com.annimon:stream:1.1.8' 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.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2' implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
implementation 'androidx.sqlite:sqlite-ktx:2.2.0' implementation 'androidx.sqlite:sqlite-ktx:2.3.1'
implementation 'net.zetetic:sqlcipher-android:4.5.3@aar' implementation 'net.zetetic:sqlcipher-android:4.5.4@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(":libsignal")
implementation project(":libsession") implementation project(":libsession")
implementation project(":libsession-util")
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion"
implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version" implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version"
implementation project(":liblazysodium") implementation project(":liblazysodium")
@ -116,52 +112,52 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
implementation "nl.komponents.kovenant:kovenant-android:$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.jakewharton.rxbinding3:rxbinding:3.1.0"
implementation "com.github.tbruyelle:rxpermissions:0.10.2" implementation "com.github.tbruyelle:rxpermissions:0.10.2"
implementation "com.github.ybq:Android-SpinKit:1.4.0" implementation "com.github.ybq:Android-SpinKit:1.4.0"
implementation "com.opencsv:opencsv:4.6" implementation "com.opencsv:opencsv:4.6"
testImplementation "junit:junit:$junitVersion" testImplementation "junit:junit:$junitVersion"
testImplementation 'org.assertj:assertj-core:3.11.1' testImplementation 'org.assertj:assertj-core:3.11.1'
testImplementation "org.mockito:mockito-inline:4.0.0" testImplementation "org.mockito:mockito-inline:4.10.0"
testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
testImplementation 'org.powermock:powermock-api-mockito:1.6.1' androidTestImplementation "org.mockito:mockito-android:4.10.0"
testImplementation 'org.powermock:powermock-module-junit4:1.6.1' androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
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.test:core:$testCoreVersion"
testImplementation "androidx.arch.core:core-testing:2.1.0" testImplementation "androidx.arch.core:core-testing:2.2.0"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
// Core library // Core library
androidTestImplementation 'androidx.test:core:1.4.0' androidTestImplementation "androidx.test:core:$testCoreVersion"
androidTestImplementation('com.adevinta.android:barista:4.2.0') {
exclude group: 'org.jetbrains.kotlin'
}
// AndroidJUnitRunner and JUnit Rules // AndroidJUnitRunner and JUnit Rules
androidTestImplementation 'androidx.test:runner:1.4.0' androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.4.0' androidTestImplementation 'androidx.test:rules:1.5.0'
// Assertions // Assertions
androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.ext:truth:1.4.0' androidTestImplementation 'androidx.test.ext:truth:1.5.0'
androidTestImplementation 'com.google.truth:truth:1.1.3' androidTestImplementation 'com.google.truth:truth:1.1.3'
// Espresso dependencies // Espresso dependencies
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0' androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0' androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.4.0' androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-web:3.4.0' androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1'
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.4.0' androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.4.0' androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
androidTestUtil 'androidx.test:orchestrator:1.4.1' androidTestUtil 'androidx.test:orchestrator:1.4.2'
testImplementation 'org.robolectric:robolectric:4.4' testImplementation 'org.robolectric:robolectric:4.4'
testImplementation 'org.robolectric:shadows-multidex:4.4' testImplementation 'org.robolectric:shadows-multidex:4.4'
} }
def canonicalVersionCode = 338 def canonicalVersionCode = 353
def canonicalVersionName = "1.16.9" def canonicalVersionName = "1.17.0"
def postFixSize = 10 def postFixSize = 10
def abiPostFix = ['armeabi-v7a' : 1, def abiPostFix = ['armeabi-v7a' : 1,

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,97 @@
package network.loki.messenger
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() {
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

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

View File

@ -40,6 +40,7 @@ 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.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;
@ -59,6 +60,8 @@ 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;
@ -106,6 +109,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.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.
@ -116,7 +121,7 @@ import kotlinx.coroutines.Job;
* @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";
@ -137,9 +142,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
private PersistentLogger persistentLogger; private PersistentLogger persistentLogger;
@Inject LokiAPIDatabase lokiAPIDatabase; @Inject LokiAPIDatabase lokiAPIDatabase;
@Inject Storage storage; @Inject public Storage storage;
@Inject MessageDataProvider messageDataProvider; @Inject MessageDataProvider messageDataProvider;
@Inject TextSecurePreferences textSecurePreferences; @Inject TextSecurePreferences textSecurePreferences;
@Inject ConfigFactory configFactory;
CallMessageProcessor callMessageProcessor; CallMessageProcessor callMessageProcessor;
MessagingModuleConfiguration messagingModuleConfiguration; MessagingModuleConfiguration messagingModuleConfiguration;
@ -157,6 +163,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
return (ApplicationContext) context.getApplicationContext(); return (ApplicationContext) context.getApplicationContext();
} }
public TextSecurePreferences getPrefs() {
return EntryPoints.get(getApplicationContext(), AppComponent.class).getPrefs();
}
public DatabaseComponent getDatabaseComponent() { public DatabaseComponent getDatabaseComponent() {
return EntryPoints.get(getApplicationContext(), DatabaseComponent.class); return EntryPoints.get(getApplicationContext(), DatabaseComponent.class);
} }
@ -183,6 +193,15 @@ 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);
@ -191,7 +210,9 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
messagingModuleConfiguration = new MessagingModuleConfiguration(this, messagingModuleConfiguration = new MessagingModuleConfiguration(this,
storage, storage,
messageDataProvider, messageDataProvider,
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this)); ()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this),
configFactory
);
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage); callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
Log.i(TAG, "onCreate()"); Log.i(TAG, "onCreate()");
startKovenant(); startKovenant();
@ -347,7 +368,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() {
@ -440,7 +461,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() {
@ -483,6 +504,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);
} }
}); });
} }
@ -520,6 +542,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
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

@ -260,6 +260,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
CALL_CONNECTED -> { CALL_CONNECTED -> {
wantsToAnswer = false wantsToAnswer = false
} }
else -> { /* do nothing */ }
} }
updateControls(state) updateControls(state)
} }

View File

@ -32,10 +32,10 @@ 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
@ -52,12 +52,19 @@ class ProfilePictureView @JvmOverloads constructor(
.sorted() .sorted()
.take(2) .take(2)
.toMutableList() .toMutableList()
if (members.size <= 1) {
publicKey = ""
displayName = ""
additionalPublicKey = ""
additionalDisplayName = ""
} else {
val pk = members.getOrNull(0)?.serialize() ?: "" val pk = members.getOrNull(0)?.serialize() ?: ""
publicKey = pk publicKey = pk
displayName = getUserDisplayName(pk) displayName = getUserDisplayName(pk)
val apk = members.getOrNull(1)?.serialize() ?: "" val apk = members.getOrNull(1)?.serialize() ?: ""
additionalPublicKey = apk additionalPublicKey = apk
additionalDisplayName = getUserDisplayName(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
@ -108,30 +115,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

@ -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,12 +48,12 @@ 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.root.glide = glide
binding.profilePictureView.root.update(user) binding.profilePictureView.root.update(user)

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

@ -3,28 +3,50 @@ package org.thoughtcrime.securesms.conversation.v2
import android.Manifest import android.Manifest
import android.animation.FloatEvaluator import android.animation.FloatEvaluator
import android.animation.ValueAnimator import android.animation.ValueAnimator
import android.content.* import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.res.Resources import android.content.res.Resources
import android.database.Cursor import android.database.Cursor
import android.graphics.Rect import android.graphics.Rect
import android.graphics.Typeface import android.graphics.Typeface
import android.net.Uri import android.net.Uri
import android.os.* import android.os.AsyncTask
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore import android.provider.MediaStore
import android.text.SpannableStringBuilder
import android.text.SpannedString
import android.text.TextUtils import android.text.TextUtils
import android.text.style.StyleSpan
import android.util.Pair import android.util.Pair
import android.util.TypedValue import android.util.TypedValue
import android.view.* import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.RelativeLayout import android.widget.RelativeLayout
import android.widget.Toast import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.DimenRes import androidx.annotation.DimenRes
import androidx.appcompat.app.AlertDialog
import androidx.core.text.set
import androidx.core.text.toSpannable
import androidx.core.view.drawToBitmap
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.loader.app.LoaderManager import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@ -32,6 +54,11 @@ import androidx.recyclerview.widget.RecyclerView
import com.annimon.stream.Stream import com.annimon.stream.Stream
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
@ -58,8 +85,12 @@ import org.session.libsession.messaging.sending_receiving.link_preview.LinkPrevi
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.* import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.MediaTypes
import org.session.libsession.utilities.Stub
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.concurrent.SimpleTask import org.session.libsession.utilities.concurrent.SimpleTask
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.recipients.RecipientModifiedListener import org.session.libsession.utilities.recipients.RecipientModifiedListener
@ -91,10 +122,25 @@ import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate
import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar
import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel
import org.thoughtcrime.securesms.conversation.v2.utilities.* import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities
import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.crypto.MnemonicUtilities import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.database.* import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.LokiAPIDatabase
import org.thoughtcrime.securesms.database.LokiMessageDatabase
import org.thoughtcrime.securesms.database.LokiThreadDatabase
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.ReactionDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageId
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
@ -107,16 +153,31 @@ import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel.LinkPreviewState import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel.LinkPreviewState
import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaSendActivity import org.thoughtcrime.securesms.mediasend.MediaSendActivity
import org.thoughtcrime.securesms.mms.* import org.thoughtcrime.securesms.mms.AudioSlide
import org.thoughtcrime.securesms.mms.GifSlide
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.ImageSlide
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.mms.VideoSlide
import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment
import org.thoughtcrime.securesms.showExpirationDialog import org.thoughtcrime.securesms.showExpirationDialog
import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.* import org.thoughtcrime.securesms.util.ActivityDispatcher
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.SaveAttachmentTask
import org.thoughtcrime.securesms.util.isScrolledToBottom
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.toPx
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.* import java.util.Locale
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject import javax.inject.Inject
@ -185,11 +246,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
it it
} }
val recipient = Recipient.from(this, address, false) val recipient = Recipient.from(this, address, false)
threadId = threadDb.getOrCreateThreadIdFor(recipient) threadId = storage.getOrCreateThreadIdFor(recipient.address)
} }
} ?: finish() } ?: finish()
} }
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair()) viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair(), contentResolver)
} }
private var actionMode: ActionMode? = null private var actionMode: ActionMode? = null
private var unreadCount = 0 private var unreadCount = 0
@ -210,6 +271,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val searchViewModel: SearchViewModel by viewModels() val searchViewModel: SearchViewModel by viewModels()
var searchViewItem: MenuItem? = null var searchViewItem: MenuItem? = null
private val bufferedLastSeenChannel = Channel<Long>(capacity = 512, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private var emojiPickerVisible = false private var emojiPickerVisible = false
private val isScrolledToBottom: Boolean private val isScrolledToBottom: Boolean
@ -229,11 +291,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english) MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english)
} }
// There is a bug when initially joining a community where all messages will immediately be marked
// as read if we reverse the message list so this is now hard-coded to false
private val reverseMessageList = false
private val adapter by lazy { private val adapter by lazy {
val cursor = mmsSmsDb.getConversation(viewModel.threadId, !isIncomingMessageRequestThread()) val cursor = mmsSmsDb.getConversation(viewModel.threadId, reverseMessageList)
val adapter = ConversationAdapter( val adapter = ConversationAdapter(
this, this,
cursor, cursor,
storage.getLastSeen(viewModel.threadId),
reverseMessageList,
onItemPress = { message, position, view, event -> onItemPress = { message, position, view, event ->
handlePress(message, position, view, event) handlePress(message, position, view, event)
}, },
@ -275,6 +343,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private val cameraButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_camera_24, hasOpaqueBackground = true) } private val cameraButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_camera_24, hasOpaqueBackground = true) }
private val messageToScrollTimestamp = AtomicLong(-1) private val messageToScrollTimestamp = AtomicLong(-1)
private val messageToScrollAuthor = AtomicReference<Address?>(null) private val messageToScrollAuthor = AtomicReference<Address?>(null)
private val firstLoad = AtomicBoolean(true)
private lateinit var reactionDelegate: ConversationReactionDelegate private lateinit var reactionDelegate: ConversationReactionDelegate
private val reactWithAnyEmojiStartPage = -1 private val reactWithAnyEmojiStartPage = -1
@ -319,28 +388,31 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpUiStateObserver() setUpUiStateObserver()
binding!!.scrollToBottomButton.setOnClickListener { binding!!.scrollToBottomButton.setOnClickListener {
val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener
val targetPosition = if (reverseMessageList) 0 else adapter.itemCount
if (layoutManager.isSmoothScrolling) { if (layoutManager.isSmoothScrolling) {
binding?.conversationRecyclerView?.scrollToPosition(0) binding?.conversationRecyclerView?.scrollToPosition(targetPosition)
} else { } else {
// It looks like 'smoothScrollToPosition' will actually load all intermediate items in // It looks like 'smoothScrollToPosition' will actually load all intermediate items in
// order to do the scroll, this can be very slow if there are a lot of messages so // order to do the scroll, this can be very slow if there are a lot of messages so
// instead we check the current position and if there are more than 10 items to scroll // instead we check the current position and if there are more than 10 items to scroll
// we jump instantly to the 10th item and scroll from there (this should happen quick // we jump instantly to the 10th item and scroll from there (this should happen quick
// enough to give a similar scroll effect without having to load everything) // enough to give a similar scroll effect without having to load everything)
val position = layoutManager.findFirstVisibleItemPosition() // val position = if (reverseMessageList) layoutManager.findFirstVisibleItemPosition() else layoutManager.findLastVisibleItemPosition()
if (position > 10) { // val targetBuffer = if (reverseMessageList) 10 else Math.max(0, (adapter.itemCount - 1) - 10)
binding?.conversationRecyclerView?.scrollToPosition(10) // if (position > targetBuffer) {
} // binding?.conversationRecyclerView?.scrollToPosition(targetBuffer)
// }
binding?.conversationRecyclerView?.post { binding?.conversationRecyclerView?.post {
binding?.conversationRecyclerView?.smoothScrollToPosition(0) binding?.conversationRecyclerView?.smoothScrollToPosition(targetPosition)
} }
} }
} }
updateUnreadCountIndicator() updateUnreadCountIndicator()
updateSubtitle() updateSubtitle()
updatePlaceholder()
setUpBlockedBanner() setUpBlockedBanner()
binding!!.searchBottomBar.setEventListener(this) binding!!.searchBottomBar.setEventListener(this)
updateSendAfterApprovalText() updateSendAfterApprovalText()
@ -350,20 +422,30 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val weakActivity = WeakReference(this) val weakActivity = WeakReference(this)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
unreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId)
// Note: We are accessing the `adapter` property because we want it to be loaded on // Note: We are accessing the `adapter` property because we want it to be loaded on
// the background thread to avoid blocking the UI thread and potentially hanging when // the background thread to avoid blocking the UI thread and potentially hanging when
// transitioning to the activity // transitioning to the activity
weakActivity.get()?.adapter ?: return@launch weakActivity.get()?.adapter ?: return@launch
// 'Get' instead of 'GetAndSet' here because we want to trigger the highlight in 'onFirstLoad'
// by triggering 'jumpToMessage' using these values
val messageTimestamp = messageToScrollTimestamp.get()
val author = messageToScrollAuthor.get()
val targetPosition = if (author != null && messageTimestamp >= 0) mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, messageTimestamp, author, reverseMessageList) else -1
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
setUpRecyclerView() setUpRecyclerView()
setUpTypingObserver() setUpTypingObserver()
setUpRecipientObserver() setUpRecipientObserver()
getLatestOpenGroupInfoIfNeeded() getLatestOpenGroupInfoIfNeeded()
setUpSearchResultObserver() setUpSearchResultObserver()
scrollToFirstUnreadMessageIfNeeded()
if (author != null && messageTimestamp >= 0 && targetPosition >= 0) {
binding?.conversationRecyclerView?.scrollToPosition(targetPosition)
}
else {
scrollToFirstUnreadMessageIfNeeded(true)
}
} }
} }
@ -371,16 +453,25 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
ViewUtil.findStubById(this, R.id.conversation_reaction_scrubber_stub) ViewUtil.findStubById(this, R.id.conversation_reaction_scrubber_stub)
reactionDelegate = ConversationReactionDelegate(reactionOverlayStub) reactionDelegate = ConversationReactionDelegate(reactionOverlayStub)
reactionDelegate.setOnReactionSelectedListener(this) reactionDelegate.setOnReactionSelectedListener(this)
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
// only update the conversation every 3 seconds maximum
// channel is rendezvous and shouldn't block on try send calls as often as we want
val bufferedFlow = bufferedLastSeenChannel.consumeAsFlow()
bufferedFlow.filter {
it > storage.getLastSeen(viewModel.threadId)
}.collectLatest { latestMessageRead ->
withContext(Dispatchers.IO) {
storage.markConversationAsRead(viewModel.threadId, latestMessageRead)
}
}
}
}
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(viewModel.threadId) ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(viewModel.threadId)
val recipient = viewModel.recipient ?: return
lifecycleScope.launch(Dispatchers.IO) {
threadDb.markAllAsRead(viewModel.threadId, recipient.isOpenGroupRecipient)
}
contentResolver.registerContentObserver( contentResolver.registerContentObserver(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
@ -412,18 +503,40 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<Cursor> { override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<Cursor> {
return ConversationLoader(viewModel.threadId, !isIncomingMessageRequestThread(), this@ConversationActivityV2) return ConversationLoader(viewModel.threadId, reverseMessageList, this@ConversationActivityV2)
} }
override fun onLoadFinished(loader: Loader<Cursor>, cursor: Cursor?) { override fun onLoadFinished(loader: Loader<Cursor>, cursor: Cursor?) {
val oldCount = adapter.itemCount
val newCount = cursor?.count ?: 0
adapter.changeCursor(cursor) adapter.changeCursor(cursor)
if (cursor != null) { if (cursor != null) {
val messageTimestamp = messageToScrollTimestamp.getAndSet(-1) val messageTimestamp = messageToScrollTimestamp.getAndSet(-1)
val author = messageToScrollAuthor.getAndSet(null) val author = messageToScrollAuthor.getAndSet(null)
val initialUnreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId)
// Update the unreadCount value to be loaded from the database since we got a new message
if (firstLoad.get() || oldCount != newCount || initialUnreadCount != unreadCount) {
// Update the unreadCount value to be loaded from the database since we got a new
// message (we need to store it in a local variable as it can get overwritten on
// another thread before the 'firstLoad.getAndSet(false)' case below)
unreadCount = initialUnreadCount
updateUnreadCountIndicator()
}
if (author != null && messageTimestamp >= 0) { if (author != null && messageTimestamp >= 0) {
jumpToMessage(author, messageTimestamp, null) jumpToMessage(author, messageTimestamp, firstLoad.get(), null)
}
else if (firstLoad.getAndSet(false)) {
scrollToFirstUnreadMessageIfNeeded(true)
handleRecyclerViewScrolled()
}
else if (oldCount != newCount) {
handleRecyclerViewScrolled()
} }
} }
updatePlaceholder()
} }
override fun onLoaderReset(cursor: Loader<Cursor>) { override fun onLoaderReset(cursor: Loader<Cursor>) {
@ -433,7 +546,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// called from onCreate // called from onCreate
private fun setUpRecyclerView() { private fun setUpRecyclerView() {
binding!!.conversationRecyclerView.adapter = adapter binding!!.conversationRecyclerView.adapter = adapter
val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, !isIncomingMessageRequestThread()) val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, reverseMessageList)
binding!!.conversationRecyclerView.layoutManager = layoutManager binding!!.conversationRecyclerView.layoutManager = layoutManager
// Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will) // Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will)
LoaderManager.getInstance(this).restartLoader(0, null, this) LoaderManager.getInstance(this).restartLoader(0, null, this)
@ -442,6 +555,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
handleRecyclerViewScrolled() handleRecyclerViewScrolled()
} }
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
}
}) })
binding!!.conversationRecyclerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> binding!!.conversationRecyclerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
@ -577,7 +694,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name)
binding?.blockedBanner?.isVisible = recipient.isBlocked binding?.blockedBanner?.isVisible = recipient.isBlocked
binding?.blockedBanner?.setOnClickListener { viewModel.unblock(this@ConversationActivityV2) } binding?.blockedBanner?.setOnClickListener { viewModel.unblock() }
} }
private fun setUpLinkPreviewObserver() { private fun setUpLinkPreviewObserver() {
@ -610,15 +727,37 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (uiState.isMessageRequestAccepted == true) { if (uiState.isMessageRequestAccepted == true) {
binding?.messageRequestBar?.visibility = View.GONE binding?.messageRequestBar?.visibility = View.GONE
} }
if (!uiState.conversationExists && !isFinishing) {
// Conversation should be deleted now, just go back
finish()
}
} }
} }
} }
private fun scrollToFirstUnreadMessageIfNeeded() { private fun scrollToFirstUnreadMessageIfNeeded(isFirstLoad: Boolean = false, shouldHighlight: Boolean = false): Int {
val lastSeenTimestamp = threadDb.getLastSeenAndHasSent(viewModel.threadId).first() val lastSeenTimestamp = threadDb.getLastSeenAndHasSent(viewModel.threadId).first()
val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return -1
if (lastSeenItemPosition <= 3) { return }
// If this is triggered when first opening a conversation then we want to position the top
// of the first unread message in the middle of the screen
if (isFirstLoad && !reverseMessageList) {
layoutManager?.scrollToPositionWithOffset(lastSeenItemPosition, ((layoutManager?.height ?: 0) / 2))
if (shouldHighlight) { highlightViewAtPosition(lastSeenItemPosition) }
return lastSeenItemPosition
}
if (lastSeenItemPosition <= 3) { return lastSeenItemPosition }
binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition)
return lastSeenItemPosition
}
private fun highlightViewAtPosition(position: Int) {
binding?.conversationRecyclerView?.post {
(layoutManager?.findViewByPosition(position) as? VisibleMessageView)?.playHighlight()
}
} }
override fun onPrepareOptionsMenu(menu: Menu): Boolean { override fun onPrepareOptionsMenu(menu: Menu): Boolean {
@ -702,11 +841,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun acceptMessageRequest() { private fun acceptMessageRequest() {
binding?.messageRequestBar?.isVisible = false binding?.messageRequestBar?.isVisible = false
binding?.conversationRecyclerView?.layoutManager =
LinearLayoutManager(this, LinearLayoutManager.VERTICAL, true)
adapter.notifyDataSetChanged()
viewModel.acceptMessageRequest() viewModel.acceptMessageRequest()
LoaderManager.getInstance(this).restartLoader(0, null, this)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2)
} }
@ -904,17 +1040,60 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
private fun handleRecyclerViewScrolled() { private fun handleRecyclerViewScrolled() {
// FIXME: Checking isScrolledToBottom is a quick fix for an issue where the
// typing indicator overlays the recycler view when scrolled up
val binding = binding ?: return val binding = binding ?: return
val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible
binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom
showScrollToBottomButtonIfApplicable() showScrollToBottomButtonIfApplicable()
val firstVisiblePosition = layoutManager?.findFirstVisibleItemPosition() ?: -1 val maybeTargetVisiblePosition = if (reverseMessageList) layoutManager?.findFirstVisibleItemPosition() else layoutManager?.findLastVisibleItemPosition()
unreadCount = min(unreadCount, firstVisiblePosition).coerceAtLeast(0) val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView.NO_POSITION
if (!firstLoad.get() && targetVisiblePosition != RecyclerView.NO_POSITION) {
val visibleItemTimestamp = adapter.getTimestampForItemAt(targetVisiblePosition)
if (visibleItemTimestamp != null) {
bufferedLastSeenChannel.trySend(visibleItemTimestamp)
}
}
if (reverseMessageList) {
unreadCount = min(unreadCount, targetVisiblePosition).coerceAtLeast(0)
}
else {
val layoutUnreadCount = layoutManager?.let { (it.itemCount - 1) - it.findLastVisibleItemPosition() }
?: RecyclerView.NO_POSITION
unreadCount = min(unreadCount, layoutUnreadCount).coerceAtLeast(0)
}
updateUnreadCountIndicator() updateUnreadCountIndicator()
} }
private fun updatePlaceholder() {
val recipient = viewModel.recipient
?: return Log.w("Loki", "recipient was null in placeholder update")
val binding = binding ?: return
val openGroup = viewModel.openGroup
val (textResource, insertParam) = when {
recipient.isLocalNumber -> R.string.activity_conversation_empty_state_note_to_self to null
openGroup != null && !openGroup.canWrite -> R.string.activity_conversation_empty_state_read_only to recipient.toShortString()
else -> R.string.activity_conversation_empty_state_default to recipient.toShortString()
}
val showPlaceholder = adapter.itemCount == 0
binding.placeholderText.isVisible = showPlaceholder
if (showPlaceholder) {
if (insertParam != null) {
val span = getText(textResource) as SpannedString
val annotations = span.getSpans(0, span.length, StyleSpan::class.java)
val boldSpan = annotations.first()
val spannedParam = insertParam.toSpannable()
spannedParam[0 until spannedParam.length] = StyleSpan(boldSpan.style)
val originalStart = span.getSpanStart(boldSpan)
val originalEnd = span.getSpanEnd(boldSpan)
val newString = SpannableStringBuilder(span)
.replace(originalStart, originalEnd, spannedParam)
binding.placeholderText.text = newString
} else {
binding.placeholderText.setText(textResource)
}
}
}
private fun showScrollToBottomButtonIfApplicable() { private fun showScrollToBottomButtonIfApplicable() {
binding?.scrollToBottomButton?.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0 binding?.scrollToBottomButton?.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0
} }
@ -970,7 +1149,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
title(R.string.RecipientPreferenceActivity_block_this_contact_question) title(R.string.RecipientPreferenceActivity_block_this_contact_question)
text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact) text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact)
destructiveButton(R.string.RecipientPreferenceActivity_block, R.string.AccessibilityId_block_confirm) { destructiveButton(R.string.RecipientPreferenceActivity_block, R.string.AccessibilityId_block_confirm) {
viewModel.block(this@ConversationActivityV2) viewModel.block()
if (deleteThread) { if (deleteThread) {
viewModel.deleteThread() viewModel.deleteThread()
finish() finish()
@ -1005,7 +1184,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (group?.isActive == false) { return } if (group?.isActive == false) { return }
} }
showExpirationDialog(thread.expireMessages) { expirationTime -> showExpirationDialog(thread.expireMessages) { expirationTime ->
recipientDb.setExpireMessages(thread, expirationTime) storage.setExpirationTimer(thread.address.serialize(), expirationTime)
val message = ExpirationTimerUpdate(expirationTime) val message = ExpirationTimerUpdate(expirationTime)
message.recipient = thread.address.serialize() message.recipient = thread.address.serialize()
message.sentTimestamp = SnodeAPI.nowWithOffset message.sentTimestamp = SnodeAPI.nowWithOffset
@ -1022,7 +1201,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
destructiveButton( destructiveButton(
R.string.ConversationActivity_unblock, R.string.ConversationActivity_unblock,
R.string.AccessibilityId_block_confirm R.string.AccessibilityId_block_confirm
) { viewModel.unblock(this@ConversationActivityV2) } ) { viewModel.unblock() }
cancelButton() cancelButton()
} }
} }
@ -1368,11 +1547,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
return return
} }
val binding = binding ?: return val binding = binding ?: return
if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) { val sentMessageInfo = if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) {
sendAttachments(listOf(), getMessageBody(), binding.inputBar.quote, binding.inputBar.linkPreview) sendAttachments(listOf(), getMessageBody(), binding.inputBar.quote, binding.inputBar.linkPreview)
} else { } else {
sendTextOnlyMessage() sendTextOnlyMessage()
} }
// Jump to the newly sent message once it gets added
if (sentMessageInfo != null) {
messageToScrollAuthor.set(sentMessageInfo.first)
messageToScrollTimestamp.set(sentMessageInfo.second)
}
} }
override fun commitInputContent(contentUri: Uri) { override fun commitInputContent(contentUri: Uri) {
@ -1390,19 +1575,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} }
private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false) { private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair<Address, Long>? {
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient ?: return null
val sentTimestamp = SnodeAPI.nowWithOffset
processMessageRequestApproval() processMessageRequestApproval()
val text = getMessageBody() val text = getMessageBody()
val userPublicKey = textSecurePreferences.getLocalNumber() val userPublicKey = textSecurePreferences.getLocalNumber()
val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey) val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey)
if (text.contains(seed) && !isNoteToSelf && !hasPermissionToSendSeed) { if (text.contains(seed) && !isNoteToSelf && !hasPermissionToSendSeed) {
val dialog = SendSeedDialog { sendTextOnlyMessage(true) } val dialog = SendSeedDialog { sendTextOnlyMessage(true) }
return dialog.show(supportFragmentManager, "Send Seed Dialog") dialog.show(supportFragmentManager, "Send Seed Dialog")
return null
} }
// Create the message // Create the message
val message = VisibleMessage() val message = VisibleMessage()
message.sentTimestamp = SnodeAPI.nowWithOffset message.sentTimestamp = sentTimestamp
message.text = text message.text = text
val outgoingTextMessage = OutgoingTextMessage.from(message, recipient) val outgoingTextMessage = OutgoingTextMessage.from(message, recipient)
// Clear the input bar // Clear the input bar
@ -1419,14 +1606,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
MessageSender.send(message, recipient.address) MessageSender.send(message, recipient.address)
// Send a typing stopped message // Send a typing stopped message
ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId)
return Pair(recipient.address, sentTimestamp)
} }
private fun sendAttachments(attachments: List<Attachment>, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null) { private fun sendAttachments(attachments: List<Attachment>, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null): Pair<Address, Long>? {
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient ?: return null
val sentTimestamp = SnodeAPI.nowWithOffset
processMessageRequestApproval() processMessageRequestApproval()
// Create the message // Create the message
val message = VisibleMessage() val message = VisibleMessage()
message.sentTimestamp = SnodeAPI.nowWithOffset message.sentTimestamp = sentTimestamp
message.text = body message.text = body
val quote = quotedMessage?.let { val quote = quotedMessage?.let {
val quotedAttachments = (it as? MmsMessageRecord)?.slideDeck?.asAttachments() ?: listOf() val quotedAttachments = (it as? MmsMessageRecord)?.slideDeck?.asAttachments() ?: listOf()
@ -1460,6 +1649,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
MessageSender.send(message, recipient.address, attachments, quote, linkPreview) MessageSender.send(message, recipient.address, attachments, quote, linkPreview)
// Send a typing stopped message // Send a typing stopped message
ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId)
return Pair(recipient.address, sentTimestamp)
} }
private fun showGIFPicker() { private fun showGIFPicker() {
@ -1829,7 +2019,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (result == null) return@Observer if (result == null) return@Observer
if (result.getResults().isNotEmpty()) { if (result.getResults().isNotEmpty()) {
result.getResults()[result.position]?.let { result.getResults()[result.position]?.let {
jumpToMessage(it.messageRecipient.address, it.sentTimestampMs) { jumpToMessage(it.messageRecipient.address, it.sentTimestampMs, true) {
searchViewModel.onMissingResult() } searchViewModel.onMissingResult() }
} }
} }
@ -1866,15 +2056,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
this.searchViewModel.onMoveDown() this.searchViewModel.onMoveDown()
} }
private fun jumpToMessage(author: Address, timestamp: Long, onMessageNotFound: Runnable?) { private fun jumpToMessage(author: Address, timestamp: Long, highlight: Boolean, onMessageNotFound: Runnable?) {
SimpleTask.run(lifecycle, { SimpleTask.run(lifecycle, {
mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, timestamp, author) mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, timestamp, author, reverseMessageList)
}) { p: Int -> moveToMessagePosition(p, onMessageNotFound) } }) { p: Int -> moveToMessagePosition(p, highlight, onMessageNotFound) }
} }
private fun moveToMessagePosition(position: Int, onMessageNotFound: Runnable?) { private fun moveToMessagePosition(position: Int, highlight: Boolean, onMessageNotFound: Runnable?) {
if (position >= 0) { if (position >= 0) {
binding?.conversationRecyclerView?.scrollToPosition(position) binding?.conversationRecyclerView?.scrollToPosition(position)
if (highlight) {
runOnUiThread {
highlightViewAtPosition(position)
}
}
} else { } else {
onMessageNotFound?.run() onMessageNotFound?.run()
} }

View File

@ -31,10 +31,14 @@ 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 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
) )
@ -183,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
} }
@ -217,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) {
if (isReversed) {
cursor.moveToPosition(i) cursor.moveToPosition(i)
val message = messageDB.readerFor(cursor).current val (outgoing, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor)
if (message.isOutgoing || message.dateReceived <= lastSeenTimestamp) { return i } 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
} }
@ -231,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
} }
@ -241,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

@ -1,10 +1,10 @@
package org.thoughtcrime.securesms.conversation.v2 package org.thoughtcrime.securesms.conversation.v2
import android.content.Context import android.content.ContentResolver
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
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
@ -21,15 +21,16 @@ 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.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 org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import java.util.UUID import java.util.UUID
class ConversationViewModel( class ConversationViewModel(
val threadId: Long, val threadId: Long,
val edKeyPair: KeyPair?, val edKeyPair: KeyPair?,
private val contentResolver: ContentResolver,
private val repository: ConversationRepository, private val repository: ConversationRepository,
private val storage: Storage private val storage: Storage
) : ViewModel() { ) : ViewModel() {
@ -37,7 +38,7 @@ class ConversationViewModel(
val showSendAfterApprovalText: Boolean val showSendAfterApprovalText: Boolean
get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false
private val _uiState = MutableStateFlow(ConversationUiState()) private val _uiState = MutableStateFlow(ConversationUiState(conversationExists = true))
val uiState: StateFlow<ConversationUiState> = _uiState val uiState: StateFlow<ConversationUiState> = _uiState
private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce { private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce {
@ -61,6 +62,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)
@ -81,27 +94,17 @@ class ConversationViewModel(
repository.inviteContacts(threadId, contacts) repository.inviteContacts(threadId, contacts)
} }
fun block(context: Context) { fun block() {
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for block action") val recipient = recipient ?: return Log.w("Loki", "Recipient was null for block action")
if (recipient.isContactRecipient) { if (recipient.isContactRecipient) {
repository.setBlocked(recipient, true) repository.setBlocked(recipient, true)
// TODO: Remove in UserConfig branch
GlobalScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
} }
} }
fun unblock(context: Context) { fun unblock() {
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for unblock action") val recipient = recipient ?: return Log.w("Loki", "Recipient was null for unblock action")
if (recipient.isContactRecipient) { if (recipient.isContactRecipient) {
repository.setBlocked(recipient, false) repository.setBlocked(recipient, false)
// TODO: Remove in UserConfig branch
GlobalScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
} }
} }
@ -198,19 +201,20 @@ class ConversationViewModel(
@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: Storage private val storage: Storage
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ConversationViewModel(threadId, edKeyPair, repository, storage) as T return ConversationViewModel(threadId, edKeyPair, contentResolver, repository, storage) as T
} }
} }
} }
@ -219,7 +223,8 @@ 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?) { data class RetrieveOnce<T>(val retrieval: () -> T?) {

View File

@ -39,12 +39,7 @@ class BlockedDialog(private val recipient: Recipient, private val context: Conte
} }
private fun unblock() { private fun unblock() {
DatabaseComponent.get(requireContext()).recipientDatabase().setBlocked(recipient, false) MessagingModuleConfiguration.shared.storage.setBlocked(listOf(recipient), false)
dismiss() dismiss()
// TODO: Remove in UserConfig branch
GlobalScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
} }
} }

View File

@ -36,7 +36,7 @@ class JoinOpenGroupDialog(private val name: String, private val url: String) : D
ThreadUtils.queue { ThreadUtils.queue {
try { try {
openGroup.apply { OpenGroupManager.add(server, room, 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()

View File

@ -63,17 +63,18 @@ 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)
} }
@ -86,7 +87,7 @@ object ConversationMenuHelper {
if (thread.isContactRecipient) { if (thread.isContactRecipient) {
if (thread.isBlocked) { if (thread.isBlocked) {
inflater.inflate(R.menu.menu_conversation_unblock, menu) inflater.inflate(R.menu.menu_conversation_unblock, menu)
} else { } else if (!thread.isLocalNumber) {
inflater.inflate(R.menu.menu_conversation_block, menu) inflater.inflate(R.menu.menu_conversation_block, menu)
} }
} }
@ -309,7 +310,7 @@ object ConversationMenuHelper {
val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString() val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString()
val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey) val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey)
if (isClosedGroup) MessageSender.leave(groupPublicKey, true) if (isClosedGroup) MessageSender.leave(groupPublicKey, notifyUser = false)
else onLeaveFailed() else onLeaveFailed()
} catch (e: Exception) { } catch (e: Exception) {
onLeaveFailed() onLeaveFailed()

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,9 +14,7 @@ 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.children
@ -28,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
@ -39,9 +37,10 @@ import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.SmsMessageRecord import org.thoughtcrime.securesms.database.model.SmsMessageRecord
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.GlowViewUtilities
import org.thoughtcrime.securesms.util.SearchUtil import org.thoughtcrime.securesms.util.SearchUtil
import org.thoughtcrime.securesms.util.getAccentColor import org.thoughtcrime.securesms.util.getAccentColor
import java.util.* import java.util.Locale
import kotlin.math.roundToInt import kotlin.math.roundToInt
class VisibleMessageContentView : ConstraintLayout { class VisibleMessageContentView : ConstraintLayout {
@ -69,12 +68,10 @@ class VisibleMessageContentView : ConstraintLayout {
onAttachmentNeedsDownload: (Long, Long) -> Unit onAttachmentNeedsDownload: (Long, Long) -> Unit
) { ) {
// Background // Background
val background = getBackground(message.isOutgoing)
val color = if (message.isOutgoing) context.getAccentColor() val color = if (message.isOutgoing) context.getAccentColor()
else context.getColorFromAttr(R.attr.message_received_background_color) else context.getColorFromAttr(R.attr.message_received_background_color)
val filter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN) binding.contentParent.mainColor = color
background.colorFilter = filter binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius)
binding.contentParent.background = background
val onlyBodyMessage = message is SmsMessageRecord val onlyBodyMessage = message is SmsMessageRecord
val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null
@ -131,7 +128,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) {
@ -244,11 +240,6 @@ class VisibleMessageContentView : ConstraintLayout {
private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean = private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean =
listOf<View>(albumThumbnailView.root, linkPreviewView.root, voiceMessageView.root, quoteView.root).none { it.isVisible } listOf<View>(albumThumbnailView.root, linkPreviewView.root, voiceMessageView.root, quoteView.root).none { it.isVisible }
private fun getBackground(isOutgoing: Boolean): Drawable {
val backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_alone else R.drawable.message_bubble_background_received_alone
return ResourcesCompat.getDrawable(resources, backgroundID, context.theme)!!
}
fun recycle() { fun recycle() {
arrayOf( arrayOf(
binding.deletedMessageView.root, binding.deletedMessageView.root,
@ -266,6 +257,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

@ -111,6 +111,8 @@ 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.messageContentView.root.disableClipping() binding.messageContentView.root.disableClipping()
} }
@ -125,6 +127,7 @@ class VisibleMessageView : LinearLayout {
searchQuery: String?, searchQuery: String?,
contact: Contact?, contact: Contact?,
senderSessionID: String, senderSessionID: String,
lastSeen: Long,
delegate: VisibleMessageViewDelegate?, delegate: VisibleMessageViewDelegate?,
onAttachmentNeedsDownload: (Long, Long) -> Unit onAttachmentNeedsDownload: (Long, Long) -> Unit
) { ) {
@ -164,6 +167,7 @@ class VisibleMessageView : LinearLayout {
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))
@ -177,7 +181,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
@ -191,6 +195,8 @@ 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
@ -409,6 +415,10 @@ class VisibleMessageView : LinearLayout {
binding.profilePictureView.root.recycle() binding.profilePictureView.root.recycle()
binding.messageContentView.root.recycle() binding.messageContentView.root.recycle()
} }
fun playHighlight() {
binding.messageContentView.root.playHighlight()
}
// endregion // endregion
// region Interaction // region Interaction

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,9 +245,14 @@ 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)
.request(Manifest.permission.READ_MEDIA_IMAGES);
} else {
builder = builder.request(Manifest.permission.READ_EXTERNAL_STORAGE);
}
builder.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio))
.withRationaleDialog(activity.getString(R.string.ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage), R.drawable.ic_baseline_photo_library_24) .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)) .onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode))
.execute(); .execute();

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

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

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

@ -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
@ -41,16 +39,13 @@ 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
@ -162,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)
} }
} }
@ -205,25 +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 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(
@ -259,7 +235,7 @@ 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)
} }
} }
@ -316,10 +292,7 @@ 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 }
get(context).threadDatabase().decrementUnread(threadId, 1, mentionChange)
}
markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId) markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId)
} }
@ -343,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)",
@ -567,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())
@ -632,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)
@ -651,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()
@ -686,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
@ -705,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
@ -794,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
@ -932,7 +881,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()
@ -949,7 +898,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()
@ -1147,7 +1096,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()

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,13 +62,14 @@ 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[] RECIPIENT_PROJECTION = new String[] { private static final String[] RECIPIENT_PROJECTION = new String[] {
BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED, BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED,
PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI, PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI,
SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL, SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
UNIDENTIFIED_ACCESS_MODE, UNIDENTIFIED_ACCESS_MODE,
FORCE_SMS_SELECTION, NOTIFY_TYPE, FORCE_SMS_SELECTION, NOTIFY_TYPE, WRAPPER_HASH
}; };
static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION) static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
@ -136,6 +137,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;
@ -154,18 +160,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();
} }
} }
@ -194,6 +196,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;
@ -225,7 +228,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 void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) { public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) {
@ -252,6 +255,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);
@ -268,14 +289,6 @@ public class RecipientDatabase extends Database {
notifyRecipientListeners(); notifyRecipientListeners();
} }
public void setBlocked(@NonNull Recipient recipient, 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 Iterable<Recipient> recipients, boolean blocked) { public void setBlocked(@NonNull Iterable<Recipient> recipients, boolean blocked) {
SQLiteDatabase db = getWritableDatabase(); SQLiteDatabase db = getWritableDatabase();
db.beginTransaction(); db.beginTransaction();

View File

@ -2,10 +2,12 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import androidx.core.database.getStringOrNull
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) {
@ -43,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()
} }

View File

@ -93,6 +93,7 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
fun cancelPendingMessageSendJobs(threadID: Long) { fun cancelPendingMessageSendJobs(threadID: Long) {
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
val attachmentUploadJobKeys = mutableListOf<String>() val attachmentUploadJobKeys = mutableListOf<String>()
database.beginTransaction()
database.getAll(sessionJobTable, "$jobType = ?", arrayOf( AttachmentUploadJob.KEY )) { cursor -> database.getAll(sessionJobTable, "$jobType = ?", arrayOf( AttachmentUploadJob.KEY )) { cursor ->
val job = jobFromCursor(cursor) as AttachmentUploadJob? val job = jobFromCursor(cursor) as AttachmentUploadJob?
if (job != null && job.threadID == threadID.toString()) { attachmentUploadJobKeys.add(job.id!!) } if (job != null && job.threadID == threadID.toString()) { attachmentUploadJobKeys.add(job.id!!) }
@ -103,16 +104,20 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
if (job != null && job.message.threadID == threadID) { messageSendJobKeys.add(job.id!!) } if (job != null && job.message.threadID == threadID) { messageSendJobKeys.add(job.id!!) }
} }
if (attachmentUploadJobKeys.isNotEmpty()) { if (attachmentUploadJobKeys.isNotEmpty()) {
val attachmentUploadJobKeysAsString = attachmentUploadJobKeys.joinToString(", ") attachmentUploadJobKeys.forEach {
database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} IN (?)", database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} = ?",
arrayOf( AttachmentUploadJob.KEY, attachmentUploadJobKeysAsString )) arrayOf( AttachmentUploadJob.KEY, it ))
}
} }
if (messageSendJobKeys.isNotEmpty()) { if (messageSendJobKeys.isNotEmpty()) {
val messageSendJobKeysAsString = messageSendJobKeys.joinToString(", ") messageSendJobKeys.forEach {
database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} IN (?)", database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} = ?",
arrayOf( MessageSendJob.KEY, messageSendJobKeysAsString )) arrayOf( MessageSendJob.KEY, it ))
} }
} }
database.setTransactionSuccessful()
database.endTransaction()
}
fun isJobCanceled(job: Job): Boolean { fun isJobCanceled(job: Job): Boolean {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase

View File

@ -148,7 +148,7 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(id); long threadId = getThreadIdForMessage(id);
DatabaseComponent.get(context).threadDatabase().update(threadId, false); DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
} }
@ -234,10 +234,6 @@ public class SmsDatabase extends MessagingDatabase {
contentValues.put(BODY, ""); contentValues.put(BODY, "");
contentValues.put(HAS_MENTION, 0); contentValues.put(HAS_MENTION, 0);
database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)}); database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)});
long threadId = getThreadIdForMessage(messageId);
if (!read) {
DatabaseComponent.get(context).threadDatabase().decrementUnread(threadId, 1, (hasMention ? 1 : 0));
}
updateTypeBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_DELETED_TYPE); updateTypeBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_DELETED_TYPE);
} }
@ -256,7 +252,7 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(id); long threadId = getThreadIdForMessage(id);
DatabaseComponent.get(context).threadDatabase().update(threadId, false); DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
} }
@ -319,7 +315,7 @@ public class SmsDatabase extends MessagingDatabase {
ID + " = ?", ID + " = ?",
new String[] {String.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))}); new String[] {String.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))});
DatabaseComponent.get(context).threadDatabase().update(threadId, false); DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
foundMessage = true; foundMessage = true;
} }
@ -337,6 +333,9 @@ public class SmsDatabase extends MessagingDatabase {
} }
} }
public List<MarkedMessageInfo> setMessagesRead(long threadId, long beforeTime) {
return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1) AND " + DATE_SENT + " <= ?", new String[]{threadId+"", beforeTime+""});
}
public List<MarkedMessageInfo> setMessagesRead(long threadId) { public List<MarkedMessageInfo> setMessagesRead(long threadId) {
return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)", new String[] {String.valueOf(threadId)}); return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)", new String[] {String.valueOf(threadId)});
} }
@ -400,14 +399,14 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(messageId); long threadId = getThreadIdForMessage(messageId);
DatabaseComponent.get(context).threadDatabase().update(threadId, true); DatabaseComponent.get(context).threadDatabase().update(threadId, true, true);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
notifyConversationListListeners(); notifyConversationListListeners();
return new Pair<>(messageId, threadId); return new Pair<>(messageId, threadId);
} }
protected Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runIncrement, boolean runThreadUpdate) { protected Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runThreadUpdate) {
if (message.isSecureMessage()) { if (message.isSecureMessage()) {
type |= Types.SECURE_MESSAGE_BIT; type |= Types.SECURE_MESSAGE_BIT;
} else if (message.isGroup()) { } else if (message.isGroup()) {
@ -486,12 +485,8 @@ public class SmsDatabase extends MessagingDatabase {
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
long messageId = db.insert(TABLE_NAME, null, values); long messageId = db.insert(TABLE_NAME, null, values);
if (unread && runIncrement) {
DatabaseComponent.get(context).threadDatabase().incrementUnread(threadId, 1, (message.hasMention() ? 1 : 0));
}
if (runThreadUpdate) { if (runThreadUpdate) {
DatabaseComponent.get(context).threadDatabase().update(threadId, true); DatabaseComponent.get(context).threadDatabase().update(threadId, true, true);
} }
if (message.getSubscriptionId() != -1) { if (message.getSubscriptionId() != -1) {
@ -504,16 +499,16 @@ public class SmsDatabase extends MessagingDatabase {
} }
} }
public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, boolean runIncrement, boolean runThreadUpdate) { public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, boolean runThreadUpdate) {
return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, runIncrement, runThreadUpdate); return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, runThreadUpdate);
} }
public Optional<InsertResult> insertCallMessage(IncomingTextMessage message) { public Optional<InsertResult> insertCallMessage(IncomingTextMessage message) {
return insertMessageInbox(message, 0, 0, true, true); return insertMessageInbox(message, 0, 0, true);
} }
public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long serverTimestamp, boolean runIncrement, boolean runThreadUpdate) { public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long serverTimestamp, boolean runThreadUpdate) {
return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp, runIncrement, runThreadUpdate); return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp, runThreadUpdate);
} }
public Optional<InsertResult> insertMessageOutbox(long threadId, OutgoingTextMessage message, long serverTimestamp, boolean runThreadUpdate) { public Optional<InsertResult> insertMessageOutbox(long threadId, OutgoingTextMessage message, long serverTimestamp, boolean runThreadUpdate) {
@ -567,9 +562,12 @@ public class SmsDatabase extends MessagingDatabase {
} }
if (runThreadUpdate) { if (runThreadUpdate) {
DatabaseComponent.get(context).threadDatabase().update(threadId, true); DatabaseComponent.get(context).threadDatabase().update(threadId, true, true);
}
long lastSeen = DatabaseComponent.get(context).threadDatabase().getLastSeenAndHasSent(threadId).first();
if (lastSeen < message.getSentTimestampMillis()) {
DatabaseComponent.get(context).threadDatabase().setLastSeen(threadId, message.getSentTimestampMillis());
} }
DatabaseComponent.get(context).threadDatabase().setLastSeen(threadId);
DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true); DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true);
@ -616,7 +614,7 @@ public class SmsDatabase extends MessagingDatabase {
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
long threadId = getThreadIdForMessage(messageId); long threadId = getThreadIdForMessage(messageId);
db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""}); db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""});
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false); boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
return threadDeleted; return threadDeleted;
} }
@ -640,7 +638,7 @@ public class SmsDatabase extends MessagingDatabase {
ID + " IN (" + StringUtils.join(argsArray, ',') + ")", ID + " IN (" + StringUtils.join(argsArray, ',') + ")",
argValues argValues
); );
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false); boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
return threadDeleted; return threadDeleted;
} }

View File

@ -2,16 +2,43 @@ package org.thoughtcrime.securesms.database
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED
import network.loki.messenger.libsession_util.Contacts
import network.loki.messenger.libsession_util.ConversationVolatileConfig
import network.loki.messenger.libsession_util.UserGroupsConfig
import network.loki.messenger.libsession_util.UserProfile
import network.loki.messenger.libsession_util.util.BaseCommunityInfo
import network.loki.messenger.libsession_util.util.Conversation
import network.loki.messenger.libsession_util.util.ExpiryMode
import network.loki.messenger.libsession_util.util.GroupInfo
import network.loki.messenger.libsession_util.util.UserPic
import org.session.libsession.avatars.AvatarHelper import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.database.StorageProtocol import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.BlindedIdMapping import org.session.libsession.messaging.BlindedIdMapping
import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.calls.CallMessageType
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.* import org.session.libsession.messaging.jobs.AttachmentUploadJob
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
import org.session.libsession.messaging.jobs.ConfigurationSyncJob
import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob
import org.session.libsession.messaging.jobs.Job
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageReceiveJob
import org.session.libsession.messaging.jobs.MessageSendJob
import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob
import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.ConfigurationMessage
import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.control.MessageRequestResponse
import org.session.libsession.messaging.messages.signal.* import org.session.libsession.messaging.messages.signal.IncomingEncryptedMessage
import org.session.libsession.messaging.messages.signal.IncomingGroupMessage
import org.session.libsession.messaging.messages.signal.IncomingMediaMessage
import org.session.libsession.messaging.messages.signal.IncomingTextMessage
import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
import org.session.libsession.messaging.messages.visible.Attachment import org.session.libsession.messaging.messages.visible.Attachment
import org.session.libsession.messaging.messages.visible.Profile import org.session.libsession.messaging.messages.visible.Profile
import org.session.libsession.messaging.messages.visible.Reaction import org.session.libsession.messaging.messages.visible.Reaction
@ -23,12 +50,15 @@ 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.data_extraction.DataExtractionNotificationInfoMessage import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.messaging.utilities.UpdateMessageData
import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.utilities.* import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
@ -36,23 +66,103 @@ import org.session.libsession.utilities.ProfileKeyUtil
import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.crypto.ecc.DjbECPrivateKey
import org.session.libsignal.crypto.ecc.DjbECPublicKey
import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.crypto.ecc.ECKeyPair
import org.session.libsignal.messages.SignalServiceAttachmentPointer import org.session.libsignal.messages.SignalServiceAttachmentPointer
import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.messages.SignalServiceGroup
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.guava.Optional
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.ClosedGroupManager
import org.thoughtcrime.securesms.groups.GroupManager
import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob
import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.SessionMetaProtocol import org.thoughtcrime.securesms.util.SessionMetaProtocol
import java.security.MessageDigest import java.security.MessageDigest
import network.loki.messenger.libsession_util.util.Contact as LibSessionContact
class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), StorageProtocol { open class Storage(context: Context, helper: SQLCipherOpenHelper, private val configFactory: ConfigFactory) : Database(context, helper), StorageProtocol,
ThreadDatabase.ConversationThreadUpdateListener {
override fun threadCreated(address: Address, threadId: Long) {
val localUserAddress = getUserPublicKey() ?: return
if (!getRecipientApproved(address) && localUserAddress != address.serialize()) return // don't store unapproved / message requests
val volatile = configFactory.convoVolatile ?: return
if (address.isGroup) {
val groups = configFactory.userGroups ?: return
if (address.isClosedGroup) {
val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize())
val closedGroup = getGroup(address.toGroupString())
if (closedGroup != null && closedGroup.isActive) {
val legacyGroup = groups.getOrConstructLegacyGroupInfo(sessionId)
groups.set(legacyGroup)
val newVolatileParams = volatile.getOrConstructLegacyGroup(sessionId).copy(
lastRead = SnodeAPI.nowWithOffset,
)
volatile.set(newVolatileParams)
}
} else if (address.isOpenGroup) {
// these should be added on the group join / group info fetch
Log.w("Loki", "Thread created called for open group address, not adding any extra information")
}
} else if (address.isContact) {
// non-standard contact prefixes: 15, 00 etc shouldn't be stored in config
if (SessionId(address.serialize()).prefix != IdPrefix.STANDARD) return
// don't update our own address into the contacts DB
if (getUserPublicKey() != address.serialize()) {
val contacts = configFactory.contacts ?: return
contacts.upsertContact(address.serialize()) {
priority = ConfigBase.PRIORITY_VISIBLE
}
} else {
val userProfile = configFactory.user ?: return
userProfile.setNtsPriority(ConfigBase.PRIORITY_VISIBLE)
DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true)
}
val newVolatileParams = volatile.getOrConstructOneToOne(address.serialize())
volatile.set(newVolatileParams)
}
}
override fun threadDeleted(address: Address, threadId: Long) {
val volatile = configFactory.convoVolatile ?: return
if (address.isGroup) {
val groups = configFactory.userGroups ?: return
if (address.isClosedGroup) {
val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize())
volatile.eraseLegacyClosedGroup(sessionId)
groups.eraseLegacyGroup(sessionId)
} else if (address.isOpenGroup) {
// these should be removed in the group leave / handling new configs
Log.w("Loki", "Thread delete called for open group address, expecting to be handled elsewhere")
}
} else {
// non-standard contact prefixes: 15, 00 etc shouldn't be stored in config
if (SessionId(address.serialize()).prefix != IdPrefix.STANDARD) return
volatile.eraseOneToOne(address.serialize())
if (getUserPublicKey() != address.serialize()) {
val contacts = configFactory.contacts ?: return
contacts.upsertContact(address.serialize()) {
priority = PRIORITY_HIDDEN
}
} else {
val userProfile = configFactory.user ?: return
userProfile.setNtsPriority(PRIORITY_HIDDEN)
}
}
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
override fun getUserPublicKey(): String? { override fun getUserPublicKey(): String? {
return TextSecurePreferences.getLocalNumber(context) return TextSecurePreferences.getLocalNumber(context)
@ -74,6 +184,25 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
database.setProfileAvatar(recipient, profileAvatar) database.setProfileAvatar(recipient, profileAvatar)
} }
override fun setProfilePicture(recipient: Recipient, newProfilePicture: String?, newProfileKey: ByteArray?) {
val db = DatabaseComponent.get(context).recipientDatabase()
db.setProfileAvatar(recipient, newProfilePicture)
db.setProfileKey(recipient, newProfileKey)
}
override fun setUserProfilePicture(newProfilePicture: String?, newProfileKey: ByteArray?) {
val ourRecipient = fromSerialized(getUserPublicKey()!!).let {
Recipient.from(context, it, false)
}
ourRecipient.resolve().profileKey = newProfileKey
TextSecurePreferences.setProfileKey(context, newProfileKey?.let { Base64.encodeBytes(it) })
TextSecurePreferences.setProfilePictureURL(context, newProfilePicture)
if (newProfileKey != null) {
JobQueue.shared.add(RetrieveProfileAvatarJob(newProfilePicture, ourRecipient.address))
}
}
override fun getOrGenerateRegistrationID(): Int { override fun getOrGenerateRegistrationID(): Int {
var registrationID = TextSecurePreferences.getLocalRegistrationId(context) var registrationID = TextSecurePreferences.getLocalRegistrationId(context)
if (registrationID == 0) { if (registrationID == 0) {
@ -94,19 +223,56 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return database.getAttachmentsForMessage(messageID) return database.getAttachmentsForMessage(messageID)
} }
override fun markConversationAsRead(threadId: Long, updateLastSeen: Boolean) { override fun getLastSeen(threadId: Long): Long {
val threadDb = DatabaseComponent.get(context).threadDatabase() val threadDb = DatabaseComponent.get(context).threadDatabase()
threadDb.setRead(threadId, updateLastSeen) return threadDb.getLastSeenAndHasSent(threadId)?.first() ?: 0L
} }
override fun incrementUnread(threadId: Long, amount: Int, unreadMentionAmount: Int) { override fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean) {
val threadDb = DatabaseComponent.get(context).threadDatabase() val threadDb = DatabaseComponent.get(context).threadDatabase()
threadDb.incrementUnread(threadId, amount, unreadMentionAmount) getRecipientForThread(threadId)?.let { recipient ->
val currentLastRead = threadDb.getLastSeenAndHasSent(threadId).first()
// don't set the last read in the volatile if we didn't set it in the DB
if (!threadDb.markAllAsRead(threadId, recipient.isGroupRecipient, lastSeenTime, force) && !force) return
// don't process configs for inbox recipients
if (recipient.isOpenGroupInboxRecipient) return
configFactory.convoVolatile?.let { config ->
val convo = when {
// recipient closed group
recipient.isClosedGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize()))
// recipient is open group
recipient.isOpenGroupRecipient -> {
val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return
BaseCommunityInfo.parseFullUrl(openGroupJoinUrl)?.let { (base, room, pubKey) ->
config.getOrConstructCommunity(base, room, pubKey)
} ?: return
}
// otherwise recipient is one to one
recipient.isContactRecipient -> {
// don't process non-standard session IDs though
val sessionId = SessionId(recipient.address.serialize())
if (sessionId.prefix != IdPrefix.STANDARD) return
config.getOrConstructOneToOne(recipient.address.serialize())
}
else -> throw NullPointerException("Weren't expecting to have a convo with address ${recipient.address.serialize()}")
}
convo.lastRead = lastSeenTime
if (convo.unread) {
convo.unread = lastSeenTime <= currentLastRead
notifyConversationListListeners()
}
config.set(convo)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
}
} }
override fun updateThread(threadId: Long, unarchive: Boolean) { override fun updateThread(threadId: Long, unarchive: Boolean) {
val threadDb = DatabaseComponent.get(context).threadDatabase() val threadDb = DatabaseComponent.get(context).threadDatabase()
threadDb.update(threadId, unarchive) threadDb.update(threadId, unarchive, false)
} }
override fun persist(message: VisibleMessage, override fun persist(message: VisibleMessage,
@ -115,7 +281,6 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
groupPublicKey: String?, groupPublicKey: String?,
openGroupID: String?, openGroupID: String?,
attachments: List<Attachment>, attachments: List<Attachment>,
runIncrement: Boolean,
runThreadUpdate: Boolean): Long? { runThreadUpdate: Boolean): Long? {
var messageID: Long? = null var messageID: Long? = null
val senderAddress = fromSerialized(message.sender!!) val senderAddress = fromSerialized(message.sender!!)
@ -142,13 +307,16 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
} }
val targetRecipient = Recipient.from(context, targetAddress, false) val targetRecipient = Recipient.from(context, targetAddress, false)
if (!targetRecipient.isGroupRecipient) { if (!targetRecipient.isGroupRecipient) {
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
if (isUserSender || isUserBlindedSender) { if (isUserSender || isUserBlindedSender) {
recipientDb.setApproved(targetRecipient, true) setRecipientApproved(targetRecipient, true)
} else { } else {
recipientDb.setApprovedMe(targetRecipient, true) setRecipientApprovedMe(targetRecipient, true)
} }
} }
if (message.threadID == null && !targetRecipient.isOpenGroupRecipient) {
// open group recipients should explicitly create threads
message.threadID = getOrCreateThreadIdFor(targetAddress)
}
if (message.isMediaMessage() || attachments.isNotEmpty()) { if (message.isMediaMessage() || attachments.isNotEmpty()) {
val quote: Optional<QuoteModel> = if (quotes != null) Optional.of(quotes) else Optional.absent() val quote: Optional<QuoteModel> = if (quotes != null) Optional.of(quotes) else Optional.absent()
val linkPreviews: Optional<List<LinkPreview>> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! }) val linkPreviews: Optional<List<LinkPreview>> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! })
@ -162,7 +330,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
it.toSignalPointer() it.toSignalPointer()
} }
val mediaMessage = IncomingMediaMessage.from(message, senderAddress, targetRecipient.expireMessages * 1000L, group, signalServiceAttachments, quote, linkPreviews) val mediaMessage = IncomingMediaMessage.from(message, senderAddress, targetRecipient.expireMessages * 1000L, group, signalServiceAttachments, quote, linkPreviews)
mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID ?: -1, message.receivedTimestamp ?: 0, runIncrement, runThreadUpdate) mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID!!, message.receivedTimestamp ?: 0, runThreadUpdate)
} }
if (insertResult.isPresent) { if (insertResult.isPresent) {
messageID = insertResult.get().messageId messageID = insertResult.get().messageId
@ -179,7 +347,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
val textMessage = if (isOpenGroupInvitation) IncomingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, senderAddress, message.sentTimestamp) val textMessage = if (isOpenGroupInvitation) IncomingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, senderAddress, message.sentTimestamp)
else IncomingTextMessage.from(message, senderAddress, group, targetRecipient.expireMessages * 1000L) else IncomingTextMessage.from(message, senderAddress, group, targetRecipient.expireMessages * 1000L)
val encrypted = IncomingEncryptedMessage(textMessage, textMessage.messageBody) val encrypted = IncomingEncryptedMessage(textMessage, textMessage.messageBody)
smsDatabase.insertMessageInbox(encrypted, message.receivedTimestamp ?: 0, runIncrement, runThreadUpdate) smsDatabase.insertMessageInbox(encrypted, message.receivedTimestamp ?: 0, runThreadUpdate)
} }
insertResult.orNull()?.let { result -> insertResult.orNull()?.let { result ->
messageID = result.messageId messageID = result.messageId
@ -225,6 +393,12 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return DatabaseComponent.get(context).sessionJobDatabase().getGroupAvatarDownloadJob(server, room, imageId) return DatabaseComponent.get(context).sessionJobDatabase().getGroupAvatarDownloadJob(server, room, imageId)
} }
override fun getConfigSyncJob(destination: Destination): Job? {
return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(ConfigurationSyncJob.KEY).values.firstOrNull {
(it as? ConfigurationSyncJob)?.destination == destination
}
}
override fun resumeMessageSendJobIfNeeded(messageSendJobID: String) { override fun resumeMessageSendJobIfNeeded(messageSendJobID: String) {
val job = DatabaseComponent.get(context).sessionJobDatabase().getMessageSendJob(messageSendJobID) ?: return val job = DatabaseComponent.get(context).sessionJobDatabase().getMessageSendJob(messageSendJobID) ?: return
JobQueue.shared.resumePendingSendMessage(job) JobQueue.shared.resumePendingSendMessage(job)
@ -234,11 +408,201 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return DatabaseComponent.get(context).sessionJobDatabase().isJobCanceled(job) return DatabaseComponent.get(context).sessionJobDatabase().isJobCanceled(job)
} }
override fun cancelPendingMessageSendJobs(threadID: Long) {
val jobDb = DatabaseComponent.get(context).sessionJobDatabase()
jobDb.cancelPendingMessageSendJobs(threadID)
}
override fun getAuthToken(room: String, server: String): String? { override fun getAuthToken(room: String, server: String): String? {
val id = "$server.$room" val id = "$server.$room"
return DatabaseComponent.get(context).lokiAPIDatabase().getAuthToken(id) return DatabaseComponent.get(context).lokiAPIDatabase().getAuthToken(id)
} }
override fun notifyConfigUpdates(forConfigObject: ConfigBase) {
notifyUpdates(forConfigObject)
}
override fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean {
return configFactory.conversationInConfig(publicKey, groupPublicKey, openGroupId, visibleOnly)
}
override fun canPerformConfigChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean {
return configFactory.canPerformChange(variant, publicKey, changeTimestampMs)
}
fun notifyUpdates(forConfigObject: ConfigBase) {
when (forConfigObject) {
is UserProfile -> updateUser(forConfigObject)
is Contacts -> updateContacts(forConfigObject)
is ConversationVolatileConfig -> updateConvoVolatile(forConfigObject)
is UserGroupsConfig -> updateUserGroups(forConfigObject)
}
}
private fun updateUser(userProfile: UserProfile) {
val userPublicKey = getUserPublicKey() ?: return
// would love to get rid of recipient and context from this
val recipient = Recipient.from(context, fromSerialized(userPublicKey), false)
// update name
val name = userProfile.getName() ?: return
val userPic = userProfile.getPic()
val profileManager = SSKEnvironment.shared.profileManager
if (name.isNotEmpty()) {
TextSecurePreferences.setProfileName(context, name)
profileManager.setName(context, recipient, name)
}
// update pfp
if (userPic == UserPic.DEFAULT) {
clearUserPic()
} else if (userPic.key.isNotEmpty() && userPic.url.isNotEmpty()
&& TextSecurePreferences.getProfilePictureURL(context) != userPic.url) {
setUserProfilePicture(userPic.url, userPic.key)
}
if (userProfile.getNtsPriority() == PRIORITY_HIDDEN) {
// delete nts thread if needed
val ourThread = getThreadId(recipient) ?: return
deleteConversation(ourThread)
} else {
// create note to self thread if needed (?)
val ourThread = getOrCreateThreadIdFor(recipient.address)
DatabaseComponent.get(context).threadDatabase().setHasSent(ourThread, true)
setPinned(ourThread, userProfile.getNtsPriority() > 0)
}
}
private fun updateContacts(contacts: Contacts) {
val extracted = contacts.all().toList()
addLibSessionContacts(extracted)
}
override fun clearUserPic() {
val userPublicKey = getUserPublicKey() ?: return
val recipientDatabase = DatabaseComponent.get(context).recipientDatabase()
// would love to get rid of recipient and context from this
val recipient = Recipient.from(context, fromSerialized(userPublicKey), false)
// clear picture if userPic is null
TextSecurePreferences.setProfileKey(context, null)
ProfileKeyUtil.setEncodedProfileKey(context, null)
recipientDatabase.setProfileAvatar(recipient, null)
TextSecurePreferences.setProfileAvatarId(context, 0)
TextSecurePreferences.setProfilePictureURL(context, null)
Recipient.removeCached(fromSerialized(userPublicKey))
configFactory.user?.setPic(UserPic.DEFAULT)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
private fun updateConvoVolatile(convos: ConversationVolatileConfig) {
val extracted = convos.all()
for (conversation in extracted) {
val threadId = when (conversation) {
is Conversation.OneToOne -> getThreadIdFor(conversation.sessionId, null, null, createThread = false)
is Conversation.LegacyGroup -> getThreadIdFor("", conversation.groupId,null, createThread = false)
is Conversation.Community -> getThreadIdFor("",null, "${conversation.baseCommunityInfo.baseUrl.removeSuffix("/")}.${conversation.baseCommunityInfo.room}", createThread = false)
}
if (threadId != null) {
if (conversation.lastRead > getLastSeen(threadId)) {
markConversationAsRead(threadId, conversation.lastRead, force = true)
}
updateThread(threadId, false)
}
}
}
private fun updateUserGroups(userGroups: UserGroupsConfig) {
val threadDb = DatabaseComponent.get(context).threadDatabase()
val localUserPublicKey = getUserPublicKey() ?: return Log.w(
"Loki",
"No user public key when trying to update user groups from config"
)
val communities = userGroups.allCommunityInfo()
val lgc = userGroups.allLegacyGroupInfo()
val allOpenGroups = getAllOpenGroups()
val toDeleteCommunities = allOpenGroups.filter {
Conversation.Community(BaseCommunityInfo(it.value.server, it.value.room, it.value.publicKey), 0, false).baseCommunityInfo.fullUrl() !in communities.map { it.community.fullUrl() }
}
val existingCommunities: Map<Long, OpenGroup> = allOpenGroups.filterKeys { it !in toDeleteCommunities.keys }
val toAddCommunities = communities.filter { it.community.fullUrl() !in existingCommunities.map { it.value.joinURL } }
val existingJoinUrls = existingCommunities.values.map { it.joinURL }
val existingClosedGroups = getAllGroups(includeInactive = true).filter { it.isClosedGroup }
val lgcIds = lgc.map { it.sessionId }
val toDeleteClosedGroups = existingClosedGroups.filter { group ->
GroupUtil.doubleDecodeGroupId(group.encodedId) !in lgcIds
}
// delete the ones which are not listed in the config
toDeleteCommunities.values.forEach { openGroup ->
OpenGroupManager.delete(openGroup.server, openGroup.room, context)
}
toDeleteClosedGroups.forEach { deleteGroup ->
val threadId = getThreadId(deleteGroup.encodedId)
if (threadId != null) {
ClosedGroupManager.silentlyRemoveGroup(context,threadId,GroupUtil.doubleDecodeGroupId(deleteGroup.encodedId), deleteGroup.encodedId, localUserPublicKey, delete = true)
}
}
toAddCommunities.forEach { toAddCommunity ->
val joinUrl = toAddCommunity.community.fullUrl()
if (!hasBackgroundGroupAddJob(joinUrl)) {
JobQueue.shared.add(BackgroundGroupAddJob(joinUrl))
}
}
for (groupInfo in communities) {
val groupBaseCommunity = groupInfo.community
if (groupBaseCommunity.fullUrl() in existingJoinUrls) {
// add it
val (threadId, _) = existingCommunities.entries.first { (_, v) -> v.joinURL == groupInfo.community.fullUrl() }
threadDb.setPinned(threadId, groupInfo.priority == PRIORITY_PINNED)
}
}
for (group in lgc) {
val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.sessionId }
val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) }
if (existingGroup != null) {
if (group.priority == PRIORITY_HIDDEN && existingThread != null) {
ClosedGroupManager.silentlyRemoveGroup(context,existingThread,GroupUtil.doubleDecodeGroupId(existingGroup.encodedId), existingGroup.encodedId, localUserPublicKey, delete = true)
} else if (existingThread == null) {
Log.w("Loki-DBG", "Existing group had no thread to hide")
} else {
Log.d("Loki-DBG", "Setting existing group pinned status to ${group.priority}")
threadDb.setPinned(existingThread, group.priority == PRIORITY_PINNED)
}
} else {
val members = group.members.keys.map { Address.fromSerialized(it) }
val admins = group.members.filter { it.value /*admin = true*/ }.keys.map { Address.fromSerialized(it) }
val groupId = GroupUtil.doubleEncodeGroupID(group.sessionId)
val title = group.name
val formationTimestamp = (group.joinedAt * 1000L)
createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp)
setProfileSharing(Address.fromSerialized(groupId), true)
// Add the group to the user's set of public keys to poll for
addClosedGroupPublicKey(group.sessionId)
// Store the encryption key pair
val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey))
addClosedGroupEncryptionKeyPair(keyPair, group.sessionId, SnodeAPI.nowWithOffset)
// Set expiration timer
val expireTimer = group.disappearingTimer
setExpirationTimer(groupId, expireTimer.toInt())
// Notify the PN server
PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, group.sessionId, localUserPublicKey)
// Notify the user
val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId))
threadDb.setDate(threadID, formationTimestamp)
insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, title, members.map { it.serialize() }, admins.map { it.serialize() }, threadID, formationTimestamp)
// Don't create config group here, it's from a config update
// Start polling
ClosedGroupPollerV2.shared.startPolling(group.sessionId)
}
}
}
override fun setAuthToken(room: String, server: String, newValue: String) { override fun setAuthToken(room: String, server: String, newValue: String) {
val id = "$server.$room" val id = "$server.$room"
DatabaseComponent.get(context).lokiAPIDatabase().setAuthToken(id, newValue) DatabaseComponent.get(context).lokiAPIDatabase().setAuthToken(id, newValue)
@ -474,6 +838,59 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
DatabaseComponent.get(context).groupDatabase().create(groupId, title, members, avatar, relay, admins, formationTimestamp) DatabaseComponent.get(context).groupDatabase().create(groupId, title, members, avatar, relay, admins, formationTimestamp)
} }
override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map<String, Boolean>, formationTimestamp: Long, encryptionKeyPair: ECKeyPair) {
val volatiles = configFactory.convoVolatile ?: return
val userGroups = configFactory.userGroups ?: return
val groupVolatileConfig = volatiles.getOrConstructLegacyGroup(groupPublicKey)
groupVolatileConfig.lastRead = formationTimestamp
volatiles.set(groupVolatileConfig)
val groupInfo = GroupInfo.LegacyGroupInfo(
sessionId = groupPublicKey,
name = name,
members = members,
priority = ConfigBase.PRIORITY_VISIBLE,
encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = encryptionKeyPair.privateKey.serialize(),
disappearingTimer = 0L,
joinedAt = (formationTimestamp / 1000L)
)
// shouldn't exist, don't use getOrConstruct + copy
userGroups.set(groupInfo)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
override fun updateGroupConfig(groupPublicKey: String) {
val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey)
val groupAddress = fromSerialized(groupID)
// TODO: probably add a check in here for isActive?
// TODO: also check if local user is a member / maybe run delete otherwise?
val existingGroup = getGroup(groupID)
?: return Log.w("Loki-DBG", "No existing group for ${groupPublicKey.take(4)}} when updating group config")
val userGroups = configFactory.userGroups ?: return
if (!existingGroup.isActive) {
userGroups.eraseLegacyGroup(groupPublicKey)
return
}
val name = existingGroup.title
val admins = existingGroup.admins.map { it.serialize() }
val members = existingGroup.members.map { it.serialize() }
val membersMap = GroupUtil.createConfigMemberMap(admins = admins, members = members)
val latestKeyPair = getLatestClosedGroupEncryptionKeyPair(groupPublicKey)
?: return Log.w("Loki-DBG", "No latest closed group encryption key pair for ${groupPublicKey.take(4)}} when updating group config")
val recipientSettings = getRecipientSettings(groupAddress) ?: return
val threadID = getThreadId(groupAddress) ?: return
val groupInfo = userGroups.getOrConstructLegacyGroupInfo(groupPublicKey).copy(
name = name,
members = membersMap,
encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = latestKeyPair.privateKey.serialize(),
priority = if (isPinned(threadID)) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE,
disappearingTimer = recipientSettings.expireMessages.toLong(),
joinedAt = (existingGroup.formationTimestamp / 1000L)
)
userGroups.set(groupInfo)
}
override fun isGroupActive(groupPublicKey: String): Boolean { override fun isGroupActive(groupPublicKey: String): Boolean {
return DatabaseComponent.get(context).groupDatabase().getGroup(GroupUtil.doubleEncodeGroupID(groupPublicKey)).orNull()?.isActive == true return DatabaseComponent.get(context).groupDatabase().getGroup(GroupUtil.doubleEncodeGroupID(groupPublicKey)).orNull()?.isActive == true
} }
@ -504,7 +921,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON()
val infoMessage = IncomingGroupMessage(m, groupID, updateData, true) val infoMessage = IncomingGroupMessage(m, groupID, updateData, true)
val smsDB = DatabaseComponent.get(context).smsDatabase() val smsDB = DatabaseComponent.get(context).smsDatabase()
smsDB.insertMessageInbox(infoMessage, true, true) smsDB.insertMessageInbox(infoMessage, true)
} }
override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection<String>, admins: Collection<String>, threadID: Long, sentTimestamp: Long) { override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection<String>, admins: Collection<String>, threadID: Long, sentTimestamp: Long) {
@ -552,8 +969,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
DatabaseComponent.get(context).lokiAPIDatabase().removeClosedGroupPublicKey(groupPublicKey) DatabaseComponent.get(context).lokiAPIDatabase().removeClosedGroupPublicKey(groupPublicKey)
} }
override fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String) { override fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) {
DatabaseComponent.get(context).lokiAPIDatabase().addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey) DatabaseComponent.get(context).lokiAPIDatabase().addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, timestamp)
} }
override fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String) { override fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String) {
@ -570,9 +987,21 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
.updateTimestampUpdated(groupID, updatedTimestamp) .updateTimestampUpdated(groupID, updatedTimestamp)
} }
override fun setExpirationTimer(groupID: String, duration: Int) { override fun setExpirationTimer(address: String, duration: Int) {
val recipient = Recipient.from(context, fromSerialized(groupID), false) val recipient = Recipient.from(context, fromSerialized(address), false)
DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration); DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration)
if (recipient.isContactRecipient && !recipient.isLocalNumber) {
configFactory.contacts?.upsertContact(address) {
this.expiryMode = if (duration != 0) {
ExpiryMode.AfterRead(duration.toLong())
} else { // = 0 / delete
ExpiryMode.NONE
}
}
if (configFactory.contacts?.needsPush() == true) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
}
} }
override fun setServerCapabilities(server: String, capabilities: List<String>) { override fun setServerCapabilities(server: String, capabilities: List<String>) {
@ -591,16 +1020,29 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
OpenGroupManager.updateOpenGroup(openGroup, context) OpenGroupManager.updateOpenGroup(openGroup, context)
} }
override fun getAllGroups(): List<GroupRecord> { override fun getAllGroups(includeInactive: Boolean): List<GroupRecord> {
return DatabaseComponent.get(context).groupDatabase().allGroups return DatabaseComponent.get(context).groupDatabase().getAllGroups(includeInactive)
} }
override fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? { override fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? {
return OpenGroupManager.addOpenGroup(urlAsString, context) return OpenGroupManager.addOpenGroup(urlAsString, context)
} }
override fun onOpenGroupAdded(server: String) { override fun onOpenGroupAdded(server: String, room: String) {
OpenGroupManager.restartPollerForServer(server.removeSuffix("/")) OpenGroupManager.restartPollerForServer(server.removeSuffix("/"))
val groups = configFactory.userGroups ?: return
val volatileConfig = configFactory.convoVolatile ?: return
val openGroup = getOpenGroup(room, server) ?: return
val (infoServer, infoRoom, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return
val pubKeyHex = Hex.toStringCondensed(pubKey)
val communityInfo = groups.getOrConstructCommunityInfo(infoServer, infoRoom, pubKeyHex)
groups.set(communityInfo)
val volatile = volatileConfig.getOrConstructCommunity(infoServer, infoRoom, pubKey)
if (volatile.lastRead != 0L) {
val threadId = getThreadId(openGroup) ?: return
markConversationAsRead(threadId, volatile.lastRead, force = true)
}
volatileConfig.set(volatile)
} }
override fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean { override fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean {
@ -618,17 +1060,19 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient)
} }
override fun getOrCreateThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?): Long { override fun getThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?, createThread: Boolean): Long? {
val database = DatabaseComponent.get(context).threadDatabase() val database = DatabaseComponent.get(context).threadDatabase()
return if (!openGroupID.isNullOrEmpty()) { return if (!openGroupID.isNullOrEmpty()) {
val recipient = Recipient.from(context, fromSerialized(GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())), false) val recipient = Recipient.from(context, fromSerialized(GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())), false)
database.getThreadIdIfExistsFor(recipient) database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it }
} else if (!groupPublicKey.isNullOrEmpty()) { } else if (!groupPublicKey.isNullOrEmpty()) {
val recipient = Recipient.from(context, fromSerialized(GroupUtil.doubleEncodeGroupID(groupPublicKey)), false) val recipient = Recipient.from(context, fromSerialized(GroupUtil.doubleEncodeGroupID(groupPublicKey)), false)
database.getOrCreateThreadIdFor(recipient) if (createThread) database.getOrCreateThreadIdFor(recipient)
else database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it }
} else { } else {
val recipient = Recipient.from(context, fromSerialized(publicKey), false) val recipient = Recipient.from(context, fromSerialized(publicKey), false)
database.getOrCreateThreadIdFor(recipient) if (createThread) database.getOrCreateThreadIdFor(recipient)
else database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it }
} }
} }
@ -637,6 +1081,10 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return getThreadId(address) return getThreadId(address)
} }
override fun getThreadId(openGroup: OpenGroup): Long? {
return GroupManager.getOpenGroupThreadID("${openGroup.server.removeSuffix("/")}.${openGroup.room}", context)
}
override fun getThreadId(address: Address): Long? { override fun getThreadId(address: Address): Long? {
val recipient = Recipient.from(context, address, false) val recipient = Recipient.from(context, address, false)
return getThreadId(recipient) return getThreadId(recipient)
@ -666,6 +1114,11 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
override fun setContact(contact: Contact) { override fun setContact(contact: Contact) {
DatabaseComponent.get(context).sessionContactDatabase().setContact(contact) DatabaseComponent.get(context).sessionContactDatabase().setContact(contact)
val address = fromSerialized(contact.sessionID)
if (!getRecipientApproved(address)) return
val recipientHash = SSKEnvironment.shared.profileManager.contactUpdatedInternal(contact)
val recipient = Recipient.from(context, address, false)
setRecipientHash(recipient, recipientHash)
} }
override fun getRecipientForThread(threadId: Long): Recipient? { override fun getRecipientForThread(threadId: Long): Recipient? {
@ -677,6 +1130,51 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return if (recipientSettings.isPresent) { recipientSettings.get() } else null return if (recipientSettings.isPresent) { recipientSettings.get() } else null
} }
override fun addLibSessionContacts(contacts: List<LibSessionContact>) {
val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
val moreContacts = contacts.filter { contact ->
val id = SessionId(contact.id)
id.prefix?.isBlinded() == false || mappingDb.getBlindedIdMapping(contact.id).none { it.sessionId != null }
}
val profileManager = SSKEnvironment.shared.profileManager
moreContacts.forEach { contact ->
val address = fromSerialized(contact.id)
val recipient = Recipient.from(context, address, false)
setBlocked(listOf(recipient), contact.blocked, fromConfigUpdate = true)
setRecipientApproved(recipient, contact.approved)
setRecipientApprovedMe(recipient, contact.approvedMe)
if (contact.name.isNotEmpty()) {
profileManager.setName(context, recipient, contact.name)
} else {
profileManager.setName(context, recipient, null)
}
if (contact.nickname.isNotEmpty()) {
profileManager.setNickname(context, recipient, contact.nickname)
} else {
profileManager.setNickname(context, recipient, null)
}
if (contact.profilePicture != UserPic.DEFAULT) {
val (url, key) = contact.profilePicture
if (key.size != ProfileKeyUtil.PROFILE_KEY_BYTES) return@forEach
profileManager.setProfilePicture(context, recipient, url, key)
profileManager.setUnidentifiedAccessMode(context, recipient, Recipient.UnidentifiedAccessMode.UNKNOWN)
} else {
profileManager.setProfilePicture(context, recipient, null, null)
}
if (contact.priority == PRIORITY_HIDDEN) {
getThreadId(fromSerialized(contact.id))?.let { conversationThreadId ->
deleteConversation(conversationThreadId)
}
} else {
getThreadId(fromSerialized(contact.id))?.let { conversationThreadId ->
setPinned(conversationThreadId, contact.priority == PRIORITY_PINNED)
}
}
setRecipientHash(recipient, contact.hashCode().toString())
}
}
override fun addContacts(contacts: List<ConfigurationMessage.Contact>) { override fun addContacts(contacts: List<ConfigurationMessage.Contact>) {
val recipientDatabase = DatabaseComponent.get(context).recipientDatabase() val recipientDatabase = DatabaseComponent.get(context).recipientDatabase()
val threadDatabase = DatabaseComponent.get(context).threadDatabase() val threadDatabase = DatabaseComponent.get(context).threadDatabase()
@ -700,19 +1198,18 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
recipientDatabase.setProfileSharing(recipient, true) recipientDatabase.setProfileSharing(recipient, true)
recipientDatabase.setRegistered(recipient, Recipient.RegisteredState.REGISTERED) recipientDatabase.setRegistered(recipient, Recipient.RegisteredState.REGISTERED)
// create Thread if needed // create Thread if needed
val threadId = threadDatabase.getOrCreateThreadIdFor(recipient) val threadId = threadDatabase.getThreadIdIfExistsFor(recipient)
if (contact.didApproveMe == true) { if (contact.didApproveMe == true) {
recipientDatabase.setApprovedMe(recipient, true) recipientDatabase.setApprovedMe(recipient, true)
} }
if (contact.isApproved == true) { if (contact.isApproved == true && threadId != -1L) {
recipientDatabase.setApproved(recipient, true) setRecipientApproved(recipient, true)
threadDatabase.setHasSent(threadId, true) threadDatabase.setHasSent(threadId, true)
} }
val contactIsBlocked: Boolean? = contact.isBlocked val contactIsBlocked: Boolean? = contact.isBlocked
if (contactIsBlocked != null && recipient.isBlocked != contactIsBlocked) { if (contactIsBlocked != null && recipient.isBlocked != contactIsBlocked) {
recipientDatabase.setBlocked(recipient, contactIsBlocked) setBlocked(listOf(recipient), contactIsBlocked, fromConfigUpdate = true)
threadDatabase.deleteConversation(threadId)
} }
} }
if (contacts.isNotEmpty()) { if (contacts.isNotEmpty()) {
@ -720,6 +1217,11 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
} }
} }
override fun setRecipientHash(recipient: Recipient, recipientHash: String?) {
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
recipientDb.setRecipientHash(recipient, recipientHash)
}
override fun getLastUpdated(threadID: Long): Long { override fun getLastUpdated(threadID: Long): Long {
val threadDB = DatabaseComponent.get(context).threadDatabase() val threadDB = DatabaseComponent.get(context).threadDatabase()
return threadDB.getLastUpdated(threadID) return threadDB.getLastUpdated(threadID)
@ -740,12 +1242,78 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return mmsSmsDb.getConversationCount(threadID) return mmsSmsDb.getConversationCount(threadID)
} }
override fun deleteConversation(threadId: Long) { override fun setPinned(threadID: Long, isPinned: Boolean) {
val threadDB = DatabaseComponent.get(context).threadDatabase() val threadDB = DatabaseComponent.get(context).threadDatabase()
threadDB.deleteConversation(threadId) threadDB.setPinned(threadID, isPinned)
val threadRecipient = getRecipientForThread(threadID) ?: return
if (threadRecipient.isLocalNumber) {
val user = configFactory.user ?: return
user.setNtsPriority(if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE)
} else if (threadRecipient.isContactRecipient) {
val contacts = configFactory.contacts ?: return
contacts.upsertContact(threadRecipient.address.serialize()) {
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
}
} else if (threadRecipient.isGroupRecipient) {
val groups = configFactory.userGroups ?: return
if (threadRecipient.isClosedGroupRecipient) {
val sessionId = GroupUtil.doubleDecodeGroupId(threadRecipient.address.serialize())
val newGroupInfo = groups.getOrConstructLegacyGroupInfo(sessionId).copy (
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
)
groups.set(newGroupInfo)
} else if (threadRecipient.isOpenGroupRecipient) {
val openGroup = getOpenGroup(threadID) ?: return
val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return
val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy (
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
)
groups.set(newGroupInfo)
}
}
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
} }
override fun isPinned(threadID: Long): Boolean {
val threadDB = DatabaseComponent.get(context).threadDatabase()
return threadDB.isPinned(threadID)
}
override fun setThreadDate(threadId: Long, newDate: Long) {
val threadDb = DatabaseComponent.get(context).threadDatabase()
threadDb.setDate(threadId, newDate)
}
override fun deleteConversation(threadID: Long) {
val recipient = getRecipientForThread(threadID)
val threadDB = DatabaseComponent.get(context).threadDatabase()
val groupDB = DatabaseComponent.get(context).groupDatabase()
threadDB.deleteConversation(threadID)
if (recipient != null) {
if (recipient.isContactRecipient) {
if (recipient.isLocalNumber) return
val contacts = configFactory.contacts ?: return
contacts.upsertContact(recipient.address.serialize()) {
this.priority = PRIORITY_HIDDEN
}
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
} else if (recipient.isClosedGroupRecipient) {
// TODO: handle closed group
val volatile = configFactory.convoVolatile ?: return
val groups = configFactory.userGroups ?: return
val groupID = recipient.address.toGroupString()
val closedGroup = getGroup(groupID)
val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize())
if (closedGroup != null) {
groupDB.delete(groupID) // TODO: Should we delete the group? (seems odd to leave it)
volatile.eraseLegacyClosedGroup(groupPublicKey)
groups.eraseLegacyGroup(groupPublicKey)
} else {
Log.w("Loki-DBG", "Failed to find a closed group for ${groupPublicKey.take(4)}")
}
}
}
}
override fun getAttachmentDataUri(attachmentId: AttachmentId): Uri { override fun getAttachmentDataUri(attachmentId: AttachmentId): Uri {
return PartAuthority.getAttachmentDataUri(attachmentId) return PartAuthority.getAttachmentDataUri(attachmentId)
@ -762,6 +1330,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
if (recipient.isBlocked) return if (recipient.isBlocked) return
val threadId = getThreadId(recipient) ?: return
val mediaMessage = IncomingMediaMessage( val mediaMessage = IncomingMediaMessage(
address, address,
sentTimestamp, sentTimestamp,
@ -780,14 +1350,21 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
Optional.of(message) Optional.of(message)
) )
database.insertSecureDecryptedMessageInbox(mediaMessage, -1, runIncrement = true, runThreadUpdate = true) database.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true)
} }
override fun insertMessageRequestResponse(response: MessageRequestResponse) { override fun insertMessageRequestResponse(response: MessageRequestResponse) {
val userPublicKey = getUserPublicKey() val userPublicKey = getUserPublicKey()
val senderPublicKey = response.sender!! val senderPublicKey = response.sender!!
val recipientPublicKey = response.recipient!! val recipientPublicKey = response.recipient!!
if (userPublicKey == null || (userPublicKey != recipientPublicKey && userPublicKey != senderPublicKey)) return
if (
userPublicKey == null
|| (userPublicKey != recipientPublicKey && userPublicKey != senderPublicKey)
// this is true if it is a sync message
|| (userPublicKey == recipientPublicKey && userPublicKey == senderPublicKey)
) return
val recipientDb = DatabaseComponent.get(context).recipientDatabase() val recipientDb = DatabaseComponent.get(context).recipientDatabase()
val threadDB = DatabaseComponent.get(context).threadDatabase() val threadDB = DatabaseComponent.get(context).threadDatabase()
if (userPublicKey == senderPublicKey) { if (userPublicKey == senderPublicKey) {
@ -799,7 +1376,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
val mmsDb = DatabaseComponent.get(context).mmsDatabase() val mmsDb = DatabaseComponent.get(context).mmsDatabase()
val smsDb = DatabaseComponent.get(context).smsDatabase() val smsDb = DatabaseComponent.get(context).smsDatabase()
val sender = Recipient.from(context, fromSerialized(senderPublicKey), false) val sender = Recipient.from(context, fromSerialized(senderPublicKey), false)
val threadId = threadDB.getOrCreateThreadIdFor(sender) val threadId = getOrCreateThreadIdFor(sender.address)
val profile = response.profile val profile = response.profile
if (profile != null) { if (profile != null) {
val profileManager = SSKEnvironment.shared.profileManager val profileManager = SSKEnvironment.shared.profileManager
@ -814,9 +1391,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
val profileKeyChanged = (sender.profileKey == null || !MessageDigest.isEqual(sender.profileKey, newProfileKey)) val profileKeyChanged = (sender.profileKey == null || !MessageDigest.isEqual(sender.profileKey, newProfileKey))
if ((profileKeyValid && profileKeyChanged) || (profileKeyValid && needsProfilePicture)) { if ((profileKeyValid && profileKeyChanged) || (profileKeyValid && needsProfilePicture)) {
profileManager.setProfileKey(context, sender, newProfileKey!!) profileManager.setProfilePicture(context, sender, profile.profilePictureURL!!, newProfileKey!!)
profileManager.setUnidentifiedAccessMode(context, sender, Recipient.UnidentifiedAccessMode.UNKNOWN) profileManager.setUnidentifiedAccessMode(context, sender, Recipient.UnidentifiedAccessMode.UNKNOWN)
profileManager.setProfilePictureURL(context, sender, profile.profilePictureURL!!)
} }
} }
threadDB.setHasSent(threadId, true) threadDB.setHasSent(threadId, true)
@ -873,16 +1449,28 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
Optional.absent(), Optional.absent(),
Optional.absent() Optional.absent()
) )
mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runIncrement = true, runThreadUpdate = true) mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = true)
} }
} }
override fun getRecipientApproved(address: Address): Boolean {
return DatabaseComponent.get(context).recipientDatabase().getApproved(address)
}
override fun setRecipientApproved(recipient: Recipient, approved: Boolean) { override fun setRecipientApproved(recipient: Recipient, approved: Boolean) {
DatabaseComponent.get(context).recipientDatabase().setApproved(recipient, approved) DatabaseComponent.get(context).recipientDatabase().setApproved(recipient, approved)
if (recipient.isLocalNumber || !recipient.isContactRecipient) return
configFactory.contacts?.upsertContact(recipient.address.serialize()) {
this.approved = approved
}
} }
override fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean) { override fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean) {
DatabaseComponent.get(context).recipientDatabase().setApprovedMe(recipient, approvedMe) DatabaseComponent.get(context).recipientDatabase().setApprovedMe(recipient, approvedMe)
if (recipient.isLocalNumber || !recipient.isContactRecipient) return
configFactory.contacts?.upsertContact(recipient.address.serialize()) {
this.approvedMe = approvedMe
}
} }
override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) { override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) {
@ -1012,9 +1600,18 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(MessageId(messageId, mms)) DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(MessageId(messageId, mms))
} }
override fun unblock(toUnblock: Iterable<Recipient>) { override fun setBlocked(recipients: Iterable<Recipient>, isBlocked: Boolean, fromConfigUpdate: Boolean) {
val recipientDb = DatabaseComponent.get(context).recipientDatabase() val recipientDb = DatabaseComponent.get(context).recipientDatabase()
recipientDb.setBlocked(toUnblock, false) recipientDb.setBlocked(recipients, isBlocked)
recipients.filter { it.isContactRecipient && !it.isLocalNumber }.forEach { recipient ->
configFactory.contacts?.upsertContact(recipient.address.serialize()) {
this.blocked = isBlocked
}
}
val contactsConfig = configFactory.contacts ?: return
if (contactsConfig.needsPush() && !fromConfigUpdate) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
} }
override fun blockedContacts(): List<Recipient> { override fun blockedContacts(): List<Recipient> {

View File

@ -64,7 +64,6 @@ import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.util.SessionMetaProtocol; import org.thoughtcrime.securesms.util.SessionMetaProtocol;
import java.io.Closeable; import java.io.Closeable;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedList; import java.util.LinkedList;
@ -74,6 +73,11 @@ import java.util.Set;
public class ThreadDatabase extends Database { public class ThreadDatabase extends Database {
public interface ConversationThreadUpdateListener {
void threadCreated(@NonNull Address address, long threadId);
void threadDeleted(@NonNull Address address, long threadId);
}
private static final String TAG = ThreadDatabase.class.getSimpleName(); private static final String TAG = ThreadDatabase.class.getSimpleName();
private final Map<Long, Address> addressCache = new HashMap<>(); private final Map<Long, Address> addressCache = new HashMap<>();
@ -141,10 +145,16 @@ public class ThreadDatabase extends Database {
"ADD COLUMN " + UNREAD_MENTION_COUNT + " INTEGER DEFAULT 0;"; "ADD COLUMN " + UNREAD_MENTION_COUNT + " INTEGER DEFAULT 0;";
} }
private ConversationThreadUpdateListener updateListener;
public ThreadDatabase(Context context, SQLCipherOpenHelper databaseHelper) { public ThreadDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper); super(context, databaseHelper);
} }
public void setUpdateListener(ConversationThreadUpdateListener updateListener) {
this.updateListener = updateListener;
}
private long createThreadForRecipient(Address address, boolean group, int distributionType) { private long createThreadForRecipient(Address address, boolean group, int distributionType) {
ContentValues contentValues = new ContentValues(4); ContentValues contentValues = new ContentValues(4);
long date = SnodeAPI.getNowWithOffset(); long date = SnodeAPI.getNowWithOffset();
@ -207,10 +217,14 @@ public class ThreadDatabase extends Database {
} }
private void deleteThread(long threadId) { private void deleteThread(long threadId) {
Recipient recipient = getRecipientForThreadId(threadId);
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.delete(TABLE_NAME, ID_WHERE, new String[] {threadId + ""}); int numberRemoved = db.delete(TABLE_NAME, ID_WHERE, new String[] {threadId + ""});
addressCache.remove(threadId); addressCache.remove(threadId);
notifyConversationListListeners(); notifyConversationListListeners();
if (updateListener != null && numberRemoved > 0 && recipient != null) {
updateListener.threadDeleted(recipient.getAddress(), threadId);
}
} }
private void deleteThreads(Set<Long> threadIds) { private void deleteThreads(Set<Long> threadIds) {
@ -278,7 +292,7 @@ public class ThreadDatabase extends Database {
DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate); DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate);
DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate); DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate);
update(threadId, false); update(threadId, false, true);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
} }
} finally { } finally {
@ -291,10 +305,34 @@ public class ThreadDatabase extends Database {
Log.i("ThreadDatabase", "Trimming thread: " + threadId + " before :"+timestamp); Log.i("ThreadDatabase", "Trimming thread: " + threadId + " before :"+timestamp);
DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp); DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp);
DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp); DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp);
update(threadId, false); update(threadId, false, true);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
} }
public List<MarkedMessageInfo> setRead(long threadId, long lastReadTime) {
final List<MarkedMessageInfo> smsRecords = DatabaseComponent.get(context).smsDatabase().setMessagesRead(threadId, lastReadTime);
final List<MarkedMessageInfo> mmsRecords = DatabaseComponent.get(context).mmsDatabase().setMessagesRead(threadId, lastReadTime);
if (smsRecords.isEmpty() && mmsRecords.isEmpty()) {
return Collections.emptyList();
}
ContentValues contentValues = new ContentValues(2);
contentValues.put(READ, smsRecords.isEmpty() && mmsRecords.isEmpty());
contentValues.put(LAST_SEEN, lastReadTime);
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""});
notifyConversationListListeners();
return new LinkedList<MarkedMessageInfo>() {{
addAll(smsRecords);
addAll(mmsRecords);
}};
}
public List<MarkedMessageInfo> setRead(long threadId, boolean lastSeen) { public List<MarkedMessageInfo> setRead(long threadId, boolean lastSeen) {
ContentValues contentValues = new ContentValues(1); ContentValues contentValues = new ContentValues(1);
contentValues.put(READ, 1); contentValues.put(READ, 1);
@ -319,30 +357,6 @@ public class ThreadDatabase extends Database {
}}; }};
} }
public void incrementUnread(long threadId, int amount, int unreadMentionAmount) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.execSQL("UPDATE " + TABLE_NAME + " SET " + READ + " = 0, " +
UNREAD_COUNT + " = " + UNREAD_COUNT + " + ?, " +
UNREAD_MENTION_COUNT + " = " + UNREAD_MENTION_COUNT + " + ? WHERE " + ID + " = ?",
new String[] {
String.valueOf(amount),
String.valueOf(unreadMentionAmount),
String.valueOf(threadId)
});
}
public void decrementUnread(long threadId, int amount, int unreadMentionAmount) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.execSQL("UPDATE " + TABLE_NAME + " SET " + READ + " = 0, " +
UNREAD_COUNT + " = " + UNREAD_COUNT + " - ?, " +
UNREAD_MENTION_COUNT + " = " + UNREAD_MENTION_COUNT + " - ? WHERE " + ID + " = ? AND " + UNREAD_COUNT + " > 0",
new String[] {
String.valueOf(amount),
String.valueOf(unreadMentionAmount),
String.valueOf(threadId)
});
}
public void setDistributionType(long threadId, int distributionType) { public void setDistributionType(long threadId, int distributionType) {
ContentValues contentValues = new ContentValues(1); ContentValues contentValues = new ContentValues(1);
contentValues.put(TYPE, distributionType); contentValues.put(TYPE, distributionType);
@ -352,6 +366,14 @@ public class ThreadDatabase extends Database {
notifyConversationListListeners(); notifyConversationListListeners();
} }
public void setDate(long threadId, long date) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(DATE, date);
SQLiteDatabase db = databaseHelper.getWritableDatabase();
int updated = db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""});
if (updated > 0) notifyConversationListListeners();
}
public int getDistributionType(long threadId) { public int getDistributionType(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase(); SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = db.query(TABLE_NAME, new String[]{TYPE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null); Cursor cursor = db.query(TABLE_NAME, new String[]{TYPE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);
@ -427,9 +449,9 @@ public class ThreadDatabase extends Database {
" ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS + " ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS +
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
" ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID +
" WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " + MESSAGE_COUNT + " = " + UNREAD_COUNT + " AND " + " WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL"; GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL";
cursor = db.rawQuery(query, null); cursor = db.rawQuery(query, null);
@ -481,7 +503,7 @@ public class ThreadDatabase extends Database {
} }
public Cursor getApprovedConversationList() { public Cursor getApprovedConversationList() {
String where = "((" + MESSAGE_COUNT + " != 0 AND (" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%')) OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " + String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%') OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " +
"AND " + ARCHIVED + " = 0 "; "AND " + ARCHIVED + " = 0 ";
return getConversationList(where); return getConversationList(where);
} }
@ -517,21 +539,50 @@ public class ThreadDatabase extends Database {
return db.rawQuery(query, null); return db.rawQuery(query, null);
} }
public void setLastSeen(long threadId, long timestamp) { /**
* @param threadId
* @param timestamp
* @return true if we have set the last seen for the thread, false if there were no messages in the thread
*/
public boolean setLastSeen(long threadId, long timestamp) {
// edge case where we set the last seen time for a conversation before it loads messages (joining community for example)
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
Recipient forThreadId = getRecipientForThreadId(threadId);
if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && forThreadId != null && forThreadId.isOpenGroupRecipient()) return false;
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
ContentValues contentValues = new ContentValues(1); ContentValues contentValues = new ContentValues(1);
if (timestamp == -1) { long lastSeenTime = timestamp == -1 ? SnodeAPI.getNowWithOffset() : timestamp;
contentValues.put(LAST_SEEN, SnodeAPI.getNowWithOffset()); contentValues.put(LAST_SEEN, lastSeenTime);
} else { db.beginTransaction();
contentValues.put(LAST_SEEN, timestamp);
}
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)}); db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)});
String smsCountSubQuery = "SELECT COUNT(*) FROM "+SmsDatabase.TABLE_NAME+" AS s WHERE t."+ID+" = s."+SmsDatabase.THREAD_ID+" AND s."+SmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND s."+SmsDatabase.READ+" = 0";
String smsMentionCountSubQuery = "SELECT COUNT(*) FROM "+SmsDatabase.TABLE_NAME+" AS s WHERE t."+ID+" = s."+SmsDatabase.THREAD_ID+" AND s."+SmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND s."+SmsDatabase.READ+" = 0 AND s."+SmsDatabase.HAS_MENTION+" = 1";
String smsReactionCountSubQuery = "SELECT COUNT(*) FROM "+SmsDatabase.TABLE_NAME+" AS s WHERE t."+ID+" = s."+SmsDatabase.THREAD_ID+" AND s."+SmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND s."+SmsDatabase.REACTIONS_UNREAD+" = 1";
String mmsCountSubQuery = "SELECT COUNT(*) FROM "+MmsDatabase.TABLE_NAME+" AS m WHERE t."+ID+" = m."+MmsDatabase.THREAD_ID+" AND m."+MmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND m."+MmsDatabase.READ+" = 0";
String mmsMentionCountSubQuery = "SELECT COUNT(*) FROM "+MmsDatabase.TABLE_NAME+" AS m WHERE t."+ID+" = m."+MmsDatabase.THREAD_ID+" AND m."+MmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND m."+MmsDatabase.READ+" = 0 AND m."+MmsDatabase.HAS_MENTION+" = 1";
String mmsReactionCountSubQuery = "SELECT COUNT(*) FROM "+MmsDatabase.TABLE_NAME+" AS m WHERE t."+ID+" = m."+MmsDatabase.THREAD_ID+" AND m."+MmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND m."+MmsDatabase.REACTIONS_UNREAD+" = 1";
String allSmsUnread = "(("+smsCountSubQuery+") + ("+smsReactionCountSubQuery+"))";
String allMmsUnread = "(("+mmsCountSubQuery+") + ("+mmsReactionCountSubQuery+"))";
String allUnread = "(("+allSmsUnread+") + ("+allMmsUnread+"))";
String allUnreadMention = "(("+smsMentionCountSubQuery+") + ("+mmsMentionCountSubQuery+"))";
String reflectUpdates = "UPDATE "+TABLE_NAME+" AS t SET "+UNREAD_COUNT+" = "+allUnread+", "+UNREAD_MENTION_COUNT+" = "+allUnreadMention+" WHERE "+ID+" = ?";
db.execSQL(reflectUpdates, new Object[]{threadId});
db.setTransactionSuccessful();
db.endTransaction();
notifyConversationListeners(threadId);
notifyConversationListListeners(); notifyConversationListListeners();
return true;
} }
public void setLastSeen(long threadId) { /**
setLastSeen(threadId, -1); * @param threadId
* @return true if we have set the last seen for the thread, false if there were no messages in the thread
*/
public boolean setLastSeen(long threadId) {
return setLastSeen(threadId, -1);
} }
public Pair<Long, Boolean> getLastSeenAndHasSent(long threadId) { public Pair<Long, Boolean> getLastSeenAndHasSent(long threadId) {
@ -634,13 +685,19 @@ public class ThreadDatabase extends Database {
try { try {
cursor = db.query(TABLE_NAME, new String[]{ID}, where, recipientsArg, null, null, null); cursor = db.query(TABLE_NAME, new String[]{ID}, where, recipientsArg, null, null, null);
long threadId;
boolean created = false;
if (cursor != null && cursor.moveToFirst()) { if (cursor != null && cursor.moveToFirst()) {
return cursor.getLong(cursor.getColumnIndexOrThrow(ID)); threadId = cursor.getLong(cursor.getColumnIndexOrThrow(ID));
} else { } else {
DatabaseComponent.get(context).recipientDatabase().setProfileSharing(recipient, true); DatabaseComponent.get(context).recipientDatabase().setProfileSharing(recipient, true);
return createThreadForRecipient(recipient.getAddress(), recipient.isGroupRecipient(), distributionType); threadId = createThreadForRecipient(recipient.getAddress(), recipient.isGroupRecipient(), distributionType);
created = true;
} }
if (created && updateListener != null) {
updateListener.threadCreated(recipient.getAddress(), threadId);
}
return threadId;
} finally { } finally {
if (cursor != null) if (cursor != null)
cursor.close(); cursor.close();
@ -679,13 +736,14 @@ public class ThreadDatabase extends Database {
new String[] {String.valueOf(threadId)}); new String[] {String.valueOf(threadId)});
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
notifyConversationListListeners();
} }
public boolean update(long threadId, boolean unarchive) { public boolean update(long threadId, boolean unarchive, boolean shouldDeleteOnEmpty) {
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
long count = mmsSmsDatabase.getConversationCount(threadId); long count = mmsSmsDatabase.getConversationCount(threadId);
boolean shouldDeleteEmptyThread = deleteThreadOnEmpty(threadId); boolean shouldDeleteEmptyThread = shouldDeleteOnEmpty && deleteThreadOnEmpty(threadId);
if (count == 0 && shouldDeleteEmptyThread) { if (count == 0 && shouldDeleteEmptyThread) {
deleteThread(threadId); deleteThread(threadId);
@ -708,12 +766,10 @@ public class ThreadDatabase extends Database {
updateThread(threadId, count, getFormattedBodyFor(record), getAttachmentUriFor(record), updateThread(threadId, count, getFormattedBodyFor(record), getAttachmentUriFor(record),
record.getTimestamp(), record.getDeliveryStatus(), record.getDeliveryReceiptCount(), record.getTimestamp(), record.getDeliveryStatus(), record.getDeliveryReceiptCount(),
record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount()); record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount());
notifyConversationListListeners();
return false; return false;
} else { } else {
if (shouldDeleteEmptyThread) { if (shouldDeleteEmptyThread) {
deleteThread(threadId); deleteThread(threadId);
notifyConversationListListeners();
return true; return true;
} }
return false; return false;
@ -721,6 +777,8 @@ public class ThreadDatabase extends Database {
} finally { } finally {
if (reader != null) if (reader != null)
reader.close(); reader.close();
notifyConversationListListeners();
notifyConversationListeners(threadId);
} }
} }
@ -732,10 +790,32 @@ public class ThreadDatabase extends Database {
new String[] {String.valueOf(threadId)}); new String[] {String.valueOf(threadId)});
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
notifyConversationListListeners();
} }
public void markAllAsRead(long threadId, boolean isGroupRecipient) { public boolean isPinned(long threadId) {
List<MarkedMessageInfo> messages = setRead(threadId, true); SQLiteDatabase db = getReadableDatabase();
Cursor cursor = db.query(TABLE_NAME, new String[]{IS_PINNED}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);
try {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0) == 1;
}
return false;
} finally {
if (cursor != null) cursor.close();
}
}
/**
* @param threadId
* @param isGroupRecipient
* @param lastSeenTime
* @return true if we have set the last seen for the thread, false if there were no messages in the thread
*/
public boolean markAllAsRead(long threadId, boolean isGroupRecipient, long lastSeenTime, boolean force) {
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && !force) return false;
List<MarkedMessageInfo> messages = setRead(threadId, lastSeenTime);
if (isGroupRecipient) { if (isGroupRecipient) {
for (MarkedMessageInfo message: messages) { for (MarkedMessageInfo message: messages) {
MarkReadReceiver.scheduleDeletion(context, message.getExpirationInfo()); MarkReadReceiver.scheduleDeletion(context, message.getExpirationInfo());
@ -743,7 +823,8 @@ public class ThreadDatabase extends Database {
} else { } else {
MarkReadReceiver.process(context, messages); MarkReadReceiver.process(context, messages);
} }
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, false, 0); ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, threadId);
return setLastSeen(threadId, lastSeenTime);
} }
private boolean deleteThreadOnEmpty(long threadId) { private boolean deleteThreadOnEmpty(long threadId) {

View File

@ -11,7 +11,6 @@ import androidx.core.app.NotificationCompat;
import net.zetetic.database.sqlcipher.SQLiteConnection; import net.zetetic.database.sqlcipher.SQLiteConnection;
import net.zetetic.database.sqlcipher.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import net.zetetic.database.sqlcipher.SQLiteDatabaseHook; import net.zetetic.database.sqlcipher.SQLiteDatabaseHook;
import net.zetetic.database.sqlcipher.SQLiteException;
import net.zetetic.database.sqlcipher.SQLiteOpenHelper; import net.zetetic.database.sqlcipher.SQLiteOpenHelper;
import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.TextSecurePreferences;
@ -19,6 +18,7 @@ import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.crypto.DatabaseSecret; import org.thoughtcrime.securesms.crypto.DatabaseSecret;
import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase; import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase;
import org.thoughtcrime.securesms.database.ConfigDatabase;
import org.thoughtcrime.securesms.database.DraftDatabase; import org.thoughtcrime.securesms.database.DraftDatabase;
import org.thoughtcrime.securesms.database.EmojiSearchDatabase; import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupDatabase;
@ -39,6 +39,7 @@ import org.thoughtcrime.securesms.database.SessionJobDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities;
import java.io.File; import java.io.File;
@ -85,9 +86,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int lokiV38 = 59; private static final int lokiV38 = 59;
private static final int lokiV39 = 60; private static final int lokiV39 = 60;
private static final int lokiV40 = 61; private static final int lokiV40 = 61;
private static final int lokiV41 = 62;
private static final int lokiV42 = 63;
// Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
private static final int DATABASE_VERSION = lokiV40; private static final int DATABASE_VERSION = lokiV42;
private static final int MIN_DATABASE_VERSION = lokiV7; private static final int MIN_DATABASE_VERSION = lokiV7;
private static final String CIPHER3_DATABASE_NAME = "signal.db"; private static final String CIPHER3_DATABASE_NAME = "signal.db";
public static final String DATABASE_NAME = "signal_v4.db"; public static final String DATABASE_NAME = "signal_v4.db";
@ -147,7 +150,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
connection.execute("PRAGMA cipher_page_size = 4096;", null, null); connection.execute("PRAGMA cipher_page_size = 4096;", null, null);
} }
private static SQLiteDatabase open(String path, DatabaseSecret databaseSecret, boolean useSQLCipher4) throws SQLiteException { private static SQLiteDatabase open(String path, DatabaseSecret databaseSecret, boolean useSQLCipher4) {
return SQLiteDatabase.openDatabase(path, databaseSecret.asString(), null, SQLiteDatabase.OPEN_READWRITE, new SQLiteDatabaseHook() { return SQLiteDatabase.openDatabase(path, databaseSecret.asString(), null, SQLiteDatabase.OPEN_READWRITE, new SQLiteDatabaseHook() {
@Override @Override
public void preKey(SQLiteConnection connection) { SQLCipherOpenHelper.applySQLCipherPragmas(connection, useSQLCipher4); } public void preKey(SQLiteConnection connection) { SQLCipherOpenHelper.applySQLCipherPragmas(connection, useSQLCipher4); }
@ -340,6 +343,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(ThreadDatabase.getUnreadMentionCountCommand()); db.execSQL(ThreadDatabase.getUnreadMentionCountCommand());
db.execSQL(SmsDatabase.CREATE_HAS_MENTION_COMMAND); db.execSQL(SmsDatabase.CREATE_HAS_MENTION_COMMAND);
db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND); db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND);
db.execSQL(ConfigDatabase.CREATE_CONFIG_TABLE_COMMAND);
executeStatements(db, SmsDatabase.CREATE_INDEXS); executeStatements(db, SmsDatabase.CREATE_INDEXS);
executeStatements(db, MmsDatabase.CREATE_INDEXS); executeStatements(db, MmsDatabase.CREATE_INDEXS);
@ -351,6 +355,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
executeStatements(db, ReactionDatabase.CREATE_INDEXS); executeStatements(db, ReactionDatabase.CREATE_INDEXS);
executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS); executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS);
db.execSQL(RecipientDatabase.getAddWrapperHash());
} }
@Override @Override
@ -583,6 +588,16 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND); db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND);
} }
if (oldVersion < lokiV41) {
db.execSQL(ConfigDatabase.CREATE_CONFIG_TABLE_COMMAND);
db.execSQL(ConfigurationMessageUtilities.DELETE_INACTIVE_GROUPS);
db.execSQL(ConfigurationMessageUtilities.DELETE_INACTIVE_ONE_TO_ONES);
}
if (oldVersion < lokiV42) {
db.execSQL(RecipientDatabase.getAddWrapperHash());
}
db.setTransactionSuccessful(); db.setTransactionSuccessful();
} finally { } finally {
db.endTransaction(); db.endTransaction();

View File

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.dependencies
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.session.libsession.utilities.AppTextSecurePreferences import org.session.libsession.utilities.AppTextSecurePreferences
@ -20,3 +21,9 @@ abstract class AppModule {
abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository
} }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AppComponent {
fun getPrefs(): TextSecurePreferences
}

View File

@ -0,0 +1,251 @@
package org.thoughtcrime.securesms.dependencies
import android.content.Context
import android.os.Trace
import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.Contacts
import network.loki.messenger.libsession_util.ConversationVolatileConfig
import network.loki.messenger.libsession_util.UserGroupsConfig
import network.loki.messenger.libsession_util.UserProfile
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsession.utilities.ConfigFactoryUpdateListener
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.ConfigDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
import org.thoughtcrime.securesms.groups.GroupManager
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
class ConfigFactory(
private val context: Context,
private val configDatabase: ConfigDatabase,
private val maybeGetUserInfo: () -> Pair<ByteArray, String>?
) :
ConfigFactoryProtocol {
companion object {
// This is a buffer period within which we will process messages which would result in a
// config change, any message which would normally result in a config change which was sent
// before `lastConfigMessage.timestamp - configChangeBufferPeriod` will not actually have
// it's changes applied (control text will still be added though)
val configChangeBufferPeriod: Long = (2 * 60 * 1000)
}
fun keyPairChanged() { // this should only happen restoring or clearing data
_userConfig?.free()
_contacts?.free()
_convoVolatileConfig?.free()
_userConfig = null
_contacts = null
_convoVolatileConfig = null
}
private val userLock = Object()
private var _userConfig: UserProfile? = null
private val contactsLock = Object()
private var _contacts: Contacts? = null
private val convoVolatileLock = Object()
private var _convoVolatileConfig: ConversationVolatileConfig? = null
private val userGroupsLock = Object()
private var _userGroups: UserGroupsConfig? = null
private val isConfigForcedOn = TextSecurePreferences.hasForcedNewConfig(context)
private val listeners: MutableList<ConfigFactoryUpdateListener> = mutableListOf()
fun registerListener(listener: ConfigFactoryUpdateListener) {
listeners += listener
}
fun unregisterListener(listener: ConfigFactoryUpdateListener) {
listeners -= listener
}
private inline fun <T> synchronizedWithLog(lock: Any, body: ()->T): T {
Trace.beginSection("synchronizedWithLog")
val result = synchronized(lock) {
body()
}
Trace.endSection()
return result
}
override val user: UserProfile?
get() = synchronizedWithLog(userLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
if (_userConfig == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val userDump = configDatabase.retrieveConfigAndHashes(
SharedConfigMessage.Kind.USER_PROFILE.name,
publicKey
)
_userConfig = if (userDump != null) {
UserProfile.newInstance(secretKey, userDump)
} else {
ConfigurationMessageUtilities.generateUserProfileConfigDump()?.let { dump ->
UserProfile.newInstance(secretKey, dump)
} ?: UserProfile.newInstance(secretKey)
}
}
_userConfig
}
override val contacts: Contacts?
get() = synchronizedWithLog(contactsLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
if (_contacts == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val contactsDump = configDatabase.retrieveConfigAndHashes(
SharedConfigMessage.Kind.CONTACTS.name,
publicKey
)
_contacts = if (contactsDump != null) {
Contacts.newInstance(secretKey, contactsDump)
} else {
ConfigurationMessageUtilities.generateContactConfigDump()?.let { dump ->
Contacts.newInstance(secretKey, dump)
} ?: Contacts.newInstance(secretKey)
}
}
_contacts
}
override val convoVolatile: ConversationVolatileConfig?
get() = synchronizedWithLog(convoVolatileLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
if (_convoVolatileConfig == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val convoDump = configDatabase.retrieveConfigAndHashes(
SharedConfigMessage.Kind.CONVO_INFO_VOLATILE.name,
publicKey
)
_convoVolatileConfig = if (convoDump != null) {
ConversationVolatileConfig.newInstance(secretKey, convoDump)
} else {
ConfigurationMessageUtilities.generateConversationVolatileDump(context)
?.let { dump ->
ConversationVolatileConfig.newInstance(secretKey, dump)
} ?: ConversationVolatileConfig.newInstance(secretKey)
}
}
_convoVolatileConfig
}
override val userGroups: UserGroupsConfig?
get() = synchronizedWithLog(userGroupsLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
if (_userGroups == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val userGroupsDump = configDatabase.retrieveConfigAndHashes(
SharedConfigMessage.Kind.GROUPS.name,
publicKey
)
_userGroups = if (userGroupsDump != null) {
UserGroupsConfig.Companion.newInstance(secretKey, userGroupsDump)
} else {
ConfigurationMessageUtilities.generateUserGroupDump(context)?.let { dump ->
UserGroupsConfig.Companion.newInstance(secretKey, dump)
} ?: UserGroupsConfig.newInstance(secretKey)
}
}
_userGroups
}
override fun getUserConfigs(): List<ConfigBase> =
listOfNotNull(user, contacts, convoVolatile, userGroups)
private fun persistUserConfigDump(timestamp: Long) = synchronized(userLock) {
val dumped = user?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return
configDatabase.storeConfig(SharedConfigMessage.Kind.USER_PROFILE.name, publicKey, dumped, timestamp)
}
private fun persistContactsConfigDump(timestamp: Long) = synchronized(contactsLock) {
val dumped = contacts?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return
configDatabase.storeConfig(SharedConfigMessage.Kind.CONTACTS.name, publicKey, dumped, timestamp)
}
private fun persistConvoVolatileConfigDump(timestamp: Long) = synchronized(convoVolatileLock) {
val dumped = convoVolatile?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return
configDatabase.storeConfig(
SharedConfigMessage.Kind.CONVO_INFO_VOLATILE.name,
publicKey,
dumped,
timestamp
)
}
private fun persistUserGroupsConfigDump(timestamp: Long) = synchronized(userGroupsLock) {
val dumped = userGroups?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return
configDatabase.storeConfig(SharedConfigMessage.Kind.GROUPS.name, publicKey, dumped, timestamp)
}
override fun persist(forConfigObject: ConfigBase, timestamp: Long) {
try {
listeners.forEach { listener ->
listener.notifyUpdates(forConfigObject)
}
when (forConfigObject) {
is UserProfile -> persistUserConfigDump(timestamp)
is Contacts -> persistContactsConfigDump(timestamp)
is ConversationVolatileConfig -> persistConvoVolatileConfigDump(timestamp)
is UserGroupsConfig -> persistUserGroupsConfigDump(timestamp)
else -> throw UnsupportedOperationException("Can't support type of ${forConfigObject::class.simpleName} yet")
}
} catch (e: Exception) {
Log.e("Loki", "failed to persist ${forConfigObject.javaClass.simpleName}", e)
}
}
override fun conversationInConfig(
publicKey: String?,
groupPublicKey: String?,
openGroupId: String?,
visibleOnly: Boolean
): Boolean {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true
val (_, userPublicKey) = maybeGetUserInfo() ?: return true
if (openGroupId != null) {
val userGroups = userGroups ?: return false
val threadId = GroupManager.getOpenGroupThreadID(openGroupId, context)
val openGroup = get(context).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return false
// Not handling the `hidden` behaviour for communities so just indicate the existence
return (userGroups.getCommunityInfo(openGroup.server, openGroup.room) != null)
}
else if (groupPublicKey != null) {
val userGroups = userGroups ?: return false
// Not handling the `hidden` behaviour for legacy groups so just indicate the existence
return (userGroups.getLegacyGroupInfo(groupPublicKey) != null)
}
else if (publicKey == userPublicKey) {
val user = user ?: return false
return (!visibleOnly || user.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN)
}
else if (publicKey != null) {
val contacts = contacts ?: return false
val targetContact = contacts.get(publicKey) ?: return false
return (!visibleOnly || targetContact.priority != ConfigBase.PRIORITY_HIDDEN)
}
return false
}
override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true
val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey)
// Ensure the change occurred after the last config message was handled (minus the buffer period)
return (changeTimestampMs >= (lastUpdateTimestampMs - ConfigFactory.configChangeBufferPeriod))
}
}

View File

@ -45,4 +45,5 @@ interface DatabaseComponent {
fun attachmentProvider(): MessageDataProvider fun attachmentProvider(): MessageDataProvider
fun blindedIdMappingDatabase(): BlindedIdMappingDatabase fun blindedIdMappingDatabase(): BlindedIdMappingDatabase
fun groupMemberDatabase(): GroupMemberDatabase fun groupMemberDatabase(): GroupMemberDatabase
fun configDatabase(): ConfigDatabase
} }

View File

@ -6,7 +6,6 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.MessageDataProvider
import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider
import org.thoughtcrime.securesms.crypto.AttachmentSecret import org.thoughtcrime.securesms.crypto.AttachmentSecret
@ -132,10 +131,18 @@ object DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = Storage(context,openHelper) fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage {
val storage = Storage(context,openHelper, configFactory)
threadDatabase.setUpdateListener(storage)
return storage
}
@Provides @Provides
@Singleton @Singleton
fun provideAttachmentProvider(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper): MessageDataProvider = DatabaseAttachmentProvider(context, openHelper) fun provideAttachmentProvider(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper): MessageDataProvider = DatabaseAttachmentProvider(context, openHelper)
@Provides
@Singleton
fun provideConfigDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper): ConfigDatabase = ConfigDatabase(context, openHelper)
} }

View File

@ -1,4 +0,0 @@
package org.thoughtcrime.securesms.dependencies;
public interface InjectableType {
}

View File

@ -0,0 +1,36 @@
package org.thoughtcrime.securesms.dependencies
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.session.libsession.utilities.ConfigFactoryUpdateListener
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.database.ConfigDatabase
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object SessionUtilModule {
private fun maybeUserEdSecretKey(context: Context): ByteArray? {
val edKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return null
return edKey.secretKey.asBytes
}
@Provides
@Singleton
fun provideConfigFactory(@ApplicationContext context: Context, configDatabase: ConfigDatabase): ConfigFactory =
ConfigFactory(context, configDatabase) {
val localUserPublicKey = TextSecurePreferences.getLocalNumber(context)
val secretKey = maybeUserEdSecretKey(context)
if (localUserPublicKey == null || secretKey == null) null
else secretKey to localUserPublicKey
}.apply {
registerListener(context as ConfigFactoryUpdateListener)
}
}

View File

@ -98,7 +98,7 @@ class NewMessageFragment : Fragment() {
private fun hideLoader() { private fun hideLoader() {
binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) { override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation) super.onAnimationEnd(animation)
binding.loader.visibility = View.GONE binding.loader.visibility = View.GONE
} }

View File

@ -0,0 +1,64 @@
package org.thoughtcrime.securesms.groups
import android.content.Context
import network.loki.messenger.libsession_util.ConfigBase
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.crypto.ecc.DjbECPublicKey
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.dependencies.ConfigFactory
object ClosedGroupManager {
fun silentlyRemoveGroup(context: Context, threadId: Long, groupPublicKey: String, groupID: String, userPublicKey: String, delete: Boolean = true) {
val storage = MessagingModuleConfiguration.shared.storage
// Mark the group as inactive
storage.setActive(groupID, false)
storage.removeClosedGroupPublicKey(groupPublicKey)
// Remove the key pairs
storage.removeAllClosedGroupEncryptionKeyPairs(groupPublicKey)
storage.removeMember(groupID, Address.fromSerialized(userPublicKey))
// Notify the PN server
PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Unsubscribe, groupPublicKey, userPublicKey)
// Stop polling
ClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
storage.cancelPendingMessageSendJobs(threadId)
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)
if (delete) {
storage.deleteConversation(threadId)
}
}
fun ConfigFactory.removeLegacyGroup(group: GroupRecord): Boolean {
val groups = userGroups ?: return false
if (!group.isClosedGroup) return false
val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId())
return groups.eraseLegacyGroup(groupPublicKey)
}
fun ConfigFactory.updateLegacyGroup(groupRecipientSettings: Recipient.RecipientSettings, group: GroupRecord) {
val groups = userGroups ?: return
if (!group.isClosedGroup) return
val storage = MessagingModuleConfiguration.shared.storage
val threadId = storage.getThreadId(group.encodedId) ?: return
val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId())
val latestKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: return
val legacyInfo = groups.getOrConstructLegacyGroupInfo(groupPublicKey)
val latestMemberMap = GroupUtil.createConfigMemberMap(group.members.map(Address::serialize), group.admins.map(Address::serialize))
val toSet = legacyInfo.copy(
members = latestMemberMap,
name = group.title,
disappearingTimer = groupRecipientSettings.expireMessages.toLong(),
priority = if (storage.isPinned(threadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE,
encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = latestKeyPair.privateKey.serialize()
)
groups.set(toSet)
}
}

View File

@ -16,6 +16,7 @@ import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import nl.komponents.kovenant.Promise import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.task import nl.komponents.kovenant.task
@ -28,16 +29,28 @@ import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.contacts.SelectContactsActivity import org.thoughtcrime.securesms.contacts.SelectContactsActivity
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup
import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.fadeIn import org.thoughtcrime.securesms.util.fadeIn
import org.thoughtcrime.securesms.util.fadeOut import org.thoughtcrime.securesms.util.fadeOut
import java.io.IOException import java.io.IOException
import javax.inject.Inject
@AndroidEntryPoint
class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
@Inject
lateinit var groupConfigFactory: ConfigFactory
@Inject
lateinit var storage: Storage
private val originalMembers = HashSet<String>() private val originalMembers = HashSet<String>()
private val zombies = HashSet<String>() private val zombies = HashSet<String>()
private val members = HashSet<String>() private val members = HashSet<String>()
@ -289,7 +302,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
isLoading = true isLoading = true
loaderContainer.fadeIn() loaderContainer.fadeIn()
val promise: Promise<Any, Exception> = if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) { val promise: Promise<Any, Exception> = if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) {
MessageSender.explicitLeave(groupPublicKey!!, true) MessageSender.explicitLeave(groupPublicKey!!, false)
} else { } else {
task { task {
if (hasNameChanged) { if (hasNameChanged) {
@ -306,6 +319,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
promise.successUi { promise.successUi {
loaderContainer.fadeOut() loaderContainer.fadeOut()
isLoading = false isLoading = false
updateGroupConfig()
finish() finish()
}.failUi { exception -> }.failUi { exception ->
val message = if (exception is MessageSender.Error) exception.description else "An error occurred" val message = if (exception is MessageSender.Error) exception.description else "An error occurred"
@ -316,5 +330,13 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
} }
} }
class GroupMembers(val members: List<String>, val zombieMembers: List<String>) { } private fun updateGroupConfig() {
val latestRecipient = storage.getRecipientSettings(Address.fromSerialized(groupID))
?: return Log.w("Loki", "No recipient settings when trying to update group config")
val latestGroup = storage.getGroup(groupID)
?: return Log.w("Loki", "No group record when trying to update group config")
groupConfigFactory.updateLegacyGroup(latestRecipient, latestGroup)
}
class GroupMembers(val members: List<String>, val zombieMembers: List<String>)
} }

View File

@ -6,6 +6,7 @@ import android.graphics.Bitmap;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import org.session.libsession.messaging.MessagingModuleConfiguration;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.DistributionTypes; import org.session.libsession.utilities.DistributionTypes;
import org.session.libsession.utilities.GroupUtil; import org.session.libsession.utilities.GroupUtil;
@ -16,11 +17,14 @@ import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.BitmapUtil;
import java.io.IOException;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import network.loki.messenger.libsession_util.UserGroupsConfig;
public class GroupManager { public class GroupManager {
public static long getOpenGroupThreadID(String id, @NonNull Context context) { public static long getOpenGroupThreadID(String id, @NonNull Context context) {

View File

@ -55,7 +55,7 @@ class JoinCommunityFragment : Fragment() {
fun hideLoader() { fun hideLoader() {
binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) { override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation) super.onAnimationEnd(animation)
binding.loader.visibility = View.GONE binding.loader.visibility = View.GONE
} }
@ -79,7 +79,7 @@ class JoinCommunityFragment : Fragment() {
val openGroupID = "$sanitizedServer.${openGroup.room}" val openGroupID = "$sanitizedServer.${openGroup.room}"
OpenGroupManager.add(sanitizedServer, openGroup.room, openGroup.serverPublicKey, requireContext()) OpenGroupManager.add(sanitizedServer, openGroup.room, openGroup.serverPublicKey, requireContext())
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
storage.onOpenGroupAdded(sanitizedServer) storage.onOpenGroupAdded(sanitizedServer, openGroup.room)
val threadID = GroupManager.getOpenGroupThreadID(openGroupID, requireContext()) val threadID = GroupManager.getOpenGroupThreadID(openGroupID, requireContext())
val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())

View File

@ -9,8 +9,8 @@ import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import java.util.concurrent.Executors import java.util.concurrent.Executors
object OpenGroupManager { object OpenGroupManager {
@ -40,7 +40,13 @@ object OpenGroupManager {
if (isPolling) { return } if (isPolling) { return }
isPolling = true isPolling = true
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val servers = storage.getAllOpenGroups().values.map { it.server }.toSet() val (serverGroups, toDelete) = storage.getAllOpenGroups().values.partition { storage.getThreadId(it) != null }
toDelete.forEach { openGroup ->
Log.w("Loki", "Need to delete a group")
delete(openGroup.server, openGroup.room, MessagingModuleConfiguration.shared.context)
}
val servers = serverGroups.map { it.server }.toSet()
synchronized(pollUpdaterLock) { synchronized(pollUpdaterLock) {
servers.forEach { server -> servers.forEach { server ->
pollers[server]?.stop() // Shouldn't be necessary pollers[server]?.stop() // Shouldn't be necessary
@ -58,14 +64,14 @@ object OpenGroupManager {
} }
@WorkerThread @WorkerThread
fun add(server: String, room: String, publicKey: String, context: Context): OpenGroupApi.RoomInfo? { fun add(server: String, room: String, publicKey: String, context: Context): Pair<Long,OpenGroupApi.RoomInfo?> {
val openGroupID = "$server.$room" val openGroupID = "$server.$room"
val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context) val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context)
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val threadDB = DatabaseComponent.get(context).lokiThreadDatabase() val threadDB = DatabaseComponent.get(context).lokiThreadDatabase()
// Check it it's added already // Check it it's added already
val existingOpenGroup = threadDB.getOpenGroupChat(threadID) val existingOpenGroup = threadDB.getOpenGroupChat(threadID)
if (existingOpenGroup != null) { return null } if (existingOpenGroup != null) { return threadID to null }
// Clear any existing data if needed // Clear any existing data if needed
storage.removeLastDeletionServerID(room, server) storage.removeLastDeletionServerID(room, server)
storage.removeLastMessageServerID(room, server) storage.removeLastMessageServerID(room, server)
@ -86,7 +92,7 @@ object OpenGroupManager {
pollInfo = info.toPollInfo(), pollInfo = info.toPollInfo(),
createGroupIfMissingWithPublicKey = publicKey createGroupIfMissingWithPublicKey = publicKey
) )
return info return threadID to info
} }
fun restartPollerForServer(server: String) { fun restartPollerForServer(server: String) {
@ -102,23 +108,27 @@ object OpenGroupManager {
} }
} }
@WorkerThread
fun delete(server: String, room: String, context: Context) { fun delete(server: String, room: String, context: Context) {
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val configFactory = MessagingModuleConfiguration.shared.configFactory
val threadDB = DatabaseComponent.get(context).threadDatabase() val threadDB = DatabaseComponent.get(context).threadDatabase()
val openGroupID = "$server.$room" val openGroupID = "${server.removeSuffix("/")}.$room"
val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context) val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context)
val recipient = threadDB.getRecipientForThreadId(threadID) ?: return val recipient = threadDB.getRecipientForThreadId(threadID) ?: return
threadDB.setThreadArchived(threadID) threadDB.setThreadArchived(threadID)
val groupID = recipient.address.serialize() val groupID = recipient.address.serialize()
// Stop the poller if needed // Stop the poller if needed
val openGroups = storage.getAllOpenGroups().filter { it.value.server == server } val openGroups = storage.getAllOpenGroups().filter { it.value.server == server }
if (openGroups.count() == 1) { if (openGroups.isNotEmpty()) {
synchronized(pollUpdaterLock) { synchronized(pollUpdaterLock) {
val poller = pollers[server] val poller = pollers[server]
poller?.stop() poller?.stop()
pollers.remove(server) pollers.remove(server)
} }
} }
configFactory.userGroups?.eraseCommunity(server, room)
configFactory.convoVolatile?.eraseCommunity(server, room)
// Delete // Delete
storage.removeLastDeletionServerID(room, server) storage.removeLastDeletionServerID(room, server)
storage.removeLastMessageServerID(room, server) storage.removeLastMessageServerID(room, server)
@ -126,19 +136,19 @@ object OpenGroupManager {
storage.removeLastOutboxMessageId(server) storage.removeLastOutboxMessageId(server)
val lokiThreadDB = DatabaseComponent.get(context).lokiThreadDatabase() val lokiThreadDB = DatabaseComponent.get(context).lokiThreadDatabase()
lokiThreadDB.removeOpenGroupChat(threadID) lokiThreadDB.removeOpenGroupChat(threadID)
ThreadUtils.queue { storage.deleteConversation(threadID) // Must be invoked on a background thread
threadDB.deleteConversation(threadID) // Must be invoked on a background thread
GroupManager.deleteGroup(groupID, context) // Must be invoked on a background thread GroupManager.deleteGroup(groupID, context) // Must be invoked on a background thread
} ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
} }
@WorkerThread
fun addOpenGroup(urlAsString: String, context: Context): OpenGroupApi.RoomInfo? { fun addOpenGroup(urlAsString: String, context: Context): OpenGroupApi.RoomInfo? {
val url = HttpUrl.parse(urlAsString) ?: return null val url = HttpUrl.parse(urlAsString) ?: return null
val server = OpenGroup.getServer(urlAsString) val server = OpenGroup.getServer(urlAsString)
val room = url.pathSegments().firstOrNull() ?: return null val room = url.pathSegments().firstOrNull() ?: return null
val publicKey = url.queryParameter("public_key") ?: return null val publicKey = url.queryParameter("public_key") ?: return null
return add(server.toString().removeSuffix("/"), room, publicKey, context) // assume migrated from calling function return add(server.toString().removeSuffix("/"), room, publicKey, context).second // assume migrated from calling function
} }
fun updateOpenGroup(openGroup: OpenGroup, context: Context) { fun updateOpenGroup(openGroup: OpenGroup, context: Context) {

View File

@ -7,10 +7,15 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.UiModeUtilities import org.thoughtcrime.securesms.util.UiModeUtilities
import org.thoughtcrime.securesms.util.getConversationUnread
import javax.inject.Inject
@AndroidEntryPoint
class ConversationOptionsBottomSheet(private val parentContext: Context) : BottomSheetDialogFragment(), View.OnClickListener { class ConversationOptionsBottomSheet(private val parentContext: Context) : BottomSheetDialogFragment(), View.OnClickListener {
private lateinit var binding: FragmentConversationBottomSheetBinding private lateinit var binding: FragmentConversationBottomSheetBinding
//FIXME AC: Supplying a threadRecord directly into the field from an activity //FIXME AC: Supplying a threadRecord directly into the field from an activity
@ -19,6 +24,8 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
// if we want to use dialog fragments properly. // if we want to use dialog fragments properly.
lateinit var thread: ThreadRecord lateinit var thread: ThreadRecord
@Inject lateinit var configFactory: ConfigFactory
var onViewDetailsTapped: (() -> Unit?)? = null var onViewDetailsTapped: (() -> Unit?)? = null
var onCopyConversationId: (() -> Unit?)? = null var onCopyConversationId: (() -> Unit?)? = null
var onPinTapped: (() -> Unit)? = null var onPinTapped: (() -> Unit)? = null
@ -77,7 +84,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted
binding.notificationsTextView.setOnClickListener(this) binding.notificationsTextView.setOnClickListener(this)
binding.deleteTextView.setOnClickListener(this) binding.deleteTextView.setOnClickListener(this)
binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 || configFactory.convoVolatile?.getConversationUnread(thread) == true
binding.markAllAsReadTextView.setOnClickListener(this) binding.markAllAsReadTextView.setOnClickListener(this)
binding.pinTextView.isVisible = !thread.isPinned binding.pinTextView.isVisible = !thread.isPinned
binding.unpinTextView.isVisible = thread.isPinned binding.unpinTextView.isVisible = thread.isPinned

View File

@ -6,12 +6,12 @@ import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.util.AttributeSet import android.util.AttributeSet
import android.util.TypedValue import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewConversationBinding import network.loki.messenger.databinding.ViewConversationBinding
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
@ -19,12 +19,19 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.hig
import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL
import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_NONE import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_NONE
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
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.getAccentColor import org.thoughtcrime.securesms.util.getAccentColor
import org.thoughtcrime.securesms.util.getConversationUnread
import java.util.Locale import java.util.Locale
import javax.inject.Inject
@AndroidEntryPoint
class ConversationView : LinearLayout { class ConversationView : LinearLayout {
@Inject lateinit var configFactory: ConfigFactory
private val binding: ViewConversationBinding by lazy { ViewConversationBinding.bind(this) } private val binding: ViewConversationBinding by lazy { ViewConversationBinding.bind(this) }
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
var thread: ThreadRecord? = null var thread: ThreadRecord? = null
@ -71,7 +78,7 @@ class ConversationView : LinearLayout {
// This would also not trigger the disappearing message timer which may or may not be desirable // This would also not trigger the disappearing message timer which may or may not be desirable
binding.accentView.visibility = if (unreadCount > 0 && !thread.isRead) View.VISIBLE else View.INVISIBLE binding.accentView.visibility = if (unreadCount > 0 && !thread.isRead) View.VISIBLE else View.INVISIBLE
} }
val formattedUnreadCount = if (thread.isRead) { val formattedUnreadCount = if (unreadCount == 0) {
null null
} else { } else {
if (unreadCount < 10000) unreadCount.toString() else "9999+" if (unreadCount < 10000) unreadCount.toString() else "9999+"
@ -80,6 +87,7 @@ class ConversationView : LinearLayout {
val textSize = if (unreadCount < 1000) 12.0f else 10.0f val textSize = if (unreadCount < 1000) 12.0f else 10.0f
binding.unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) binding.unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize)
binding.unreadCountIndicator.isVisible = (unreadCount != 0 && !thread.isRead) binding.unreadCountIndicator.isVisible = (unreadCount != 0 && !thread.isRead)
|| (configFactory.convoVolatile?.getConversationUnread(thread) == true)
binding.unreadMentionTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) binding.unreadMentionTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize)
binding.unreadMentionIndicator.isVisible = (thread.unreadMentionCount != 0 && thread.recipient.address.isGroup) binding.unreadMentionIndicator.isVisible = (thread.unreadMentionCount != 0 && thread.recipient.address.isGroup)
val senderDisplayName = getUserDisplayName(thread.recipient) val senderDisplayName = getUserDisplayName(thread.recipient)
@ -128,7 +136,7 @@ class ConversationView : LinearLayout {
return if (recipient.isLocalNumber) { return if (recipient.isLocalNumber) {
context.getString(R.string.note_to_self) context.getString(R.string.note_to_self)
} else { } else {
recipient.name // Internally uses the Contact API recipient.toShortString() // Internally uses the Contact API
} }
} }
// endregion // endregion

View File

@ -1,18 +1,22 @@
package org.thoughtcrime.securesms.home package org.thoughtcrime.securesms.home
import android.Manifest
import android.app.NotificationManager
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.ClipData
import android.content.ClipboardManager
import android.os.Bundle import android.os.Bundle
import android.text.SpannableString import android.text.SpannableString
import android.widget.Toast import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -26,11 +30,14 @@ import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityHomeBinding import network.loki.messenger.databinding.ActivityHomeBinding
import network.loki.messenger.databinding.ViewMessageRequestBannerBinding import network.loki.messenger.databinding.ViewMessageRequestBannerBinding
import network.loki.messenger.libsession_util.ConfigBase
import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode import org.greenrobot.eventbus.ThreadMode
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
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.GroupUtil import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.ProfilePictureModifiedEvent import org.session.libsession.utilities.ProfilePictureModifiedEvent
@ -48,8 +55,10 @@ import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter
@ -60,6 +69,7 @@ import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.onboarding.SeedActivity import org.thoughtcrime.securesms.onboarding.SeedActivity
import org.thoughtcrime.securesms.onboarding.SeedReminderViewDelegate import org.thoughtcrime.securesms.onboarding.SeedReminderViewDelegate
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.SettingsActivity import org.thoughtcrime.securesms.preferences.SettingsActivity
import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.showMuteDialog import org.thoughtcrime.securesms.showMuteDialog
@ -80,6 +90,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
SeedReminderViewDelegate, SeedReminderViewDelegate,
GlobalSearchInputLayout.GlobalSearchInputLayoutListener { GlobalSearchInputLayout.GlobalSearchInputLayoutListener {
companion object {
const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING"
}
private lateinit var binding: ActivityHomeBinding private lateinit var binding: ActivityHomeBinding
private lateinit var glide: GlideRequests private lateinit var glide: GlideRequests
private var broadcastReceiver: BroadcastReceiver? = null private var broadcastReceiver: BroadcastReceiver? = null
@ -87,8 +102,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
@Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var threadDb: ThreadDatabase
@Inject lateinit var mmsSmsDatabase: MmsSmsDatabase @Inject lateinit var mmsSmsDatabase: MmsSmsDatabase
@Inject lateinit var recipientDatabase: RecipientDatabase @Inject lateinit var recipientDatabase: RecipientDatabase
@Inject lateinit var storage: Storage
@Inject lateinit var groupDatabase: GroupDatabase @Inject lateinit var groupDatabase: GroupDatabase
@Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var textSecurePreferences: TextSecurePreferences
@Inject lateinit var configFactory: ConfigFactory
private val globalSearchViewModel by viewModels<GlobalSearchViewModel>() private val globalSearchViewModel by viewModels<GlobalSearchViewModel>()
private val homeViewModel by viewModels<HomeViewModel>() private val homeViewModel by viewModels<HomeViewModel>()
@ -97,7 +114,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
get() = textSecurePreferences.getLocalNumber()!! get() = textSecurePreferences.getLocalNumber()!!
private val homeAdapter: HomeAdapter by lazy { private val homeAdapter: HomeAdapter by lazy {
HomeAdapter(context = this, listener = this) HomeAdapter(context = this, configFactory = configFactory, listener = this)
} }
private val globalSearchAdapter = GlobalSearchAdapter { model -> private val globalSearchAdapter = GlobalSearchAdapter { model ->
@ -158,6 +175,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} }
binding.sessionToolbar.disableClipping() binding.sessionToolbar.disableClipping()
// Set up seed reminder view // Set up seed reminder view
lifecycleScope.launchWhenStarted {
val hasViewedSeed = textSecurePreferences.getHasViewedSeed() val hasViewedSeed = textSecurePreferences.getHasViewedSeed()
if (!hasViewedSeed) { if (!hasViewedSeed) {
binding.seedReminderView.isVisible = true binding.seedReminderView.isVisible = true
@ -168,6 +186,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} else { } else {
binding.seedReminderView.isVisible = false binding.seedReminderView.isVisible = false
} }
}
setupMessageRequestsBanner() setupMessageRequestsBanner()
// Set up recycler view // Set up recycler view
binding.globalSearchInputLayout.listener = this binding.globalSearchInputLayout.listener = this
@ -176,6 +195,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
binding.recyclerView.adapter = homeAdapter binding.recyclerView.adapter = homeAdapter
binding.globalSearchRecycler.adapter = globalSearchAdapter binding.globalSearchRecycler.adapter = globalSearchAdapter
binding.configOutdatedView.setOnClickListener {
textSecurePreferences.setHasLegacyConfig(false)
updateLegacyConfigView()
}
// Set up empty state view // Set up empty state view
binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() } binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() }
IP2Country.configureIfNeeded(this@HomeActivity) IP2Country.configureIfNeeded(this@HomeActivity)
@ -192,6 +216,15 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
this.broadcastReceiver = broadcastReceiver this.broadcastReceiver = broadcastReceiver
LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter("blockedContactsChanged")) LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter("blockedContactsChanged"))
// subscribe to outdated config updates, this should be removed after long enough time for device migration
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
TextSecurePreferences.events.filter { it == TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG }.collect {
updateLegacyConfigView()
}
}
}
lifecycleScope.launchWhenStarted { lifecycleScope.launchWhenStarted {
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
// Double check that the long poller is up // Double check that the long poller is up
@ -212,6 +245,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} }
} }
} }
// monitor the global search VM query // monitor the global search VM query
launch { launch {
binding.globalSearchInputLayout.query binding.globalSearchInputLayout.query
@ -264,6 +298,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} }
} }
EventBus.getDefault().register(this@HomeActivity) EventBus.getDefault().register(this@HomeActivity)
if (intent.hasExtra(FROM_ONBOARDING)
&& intent.getBooleanExtra(FROM_ONBOARDING, false)
&& !(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()
) {
Permissions.with(this)
.request(Manifest.permission.POST_NOTIFICATIONS)
.execute()
}
} }
override fun onInputFocusChanged(hasFocus: Boolean) { override fun onInputFocusChanged(hasFocus: Boolean) {
@ -312,6 +354,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} }
} }
private fun updateLegacyConfigView() {
binding.configOutdatedView.isVisible = ConfigBase.isNewConfigEnabled(textSecurePreferences.hasForcedNewConfig(), SnodeAPI.nowWithOffset)
&& textSecurePreferences.getHasLegacyConfig()
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(true) ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(true)
@ -322,6 +369,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
if (textSecurePreferences.getHasViewedSeed()) { if (textSecurePreferences.getHasViewedSeed()) {
binding.seedReminderView.isVisible = false binding.seedReminderView.isVisible = false
} }
updateLegacyConfigView()
// TODO: remove this after enough updates that we can rely on ConfigBase.isNewConfigEnabled to always return true
// This will only run if we aren't using new configs, as they are schedule to sync when there are changes applied
if (textSecurePreferences.getConfigurationMessageSynced()) { if (textSecurePreferences.getConfigurationMessageSynced()) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity) ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity)
@ -493,9 +545,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact) text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact)
button(R.string.RecipientPreferenceActivity_block) { button(R.string.RecipientPreferenceActivity_block) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
recipientDatabase.setBlocked(thread.recipient, true) storage.setBlocked(listOf(thread.recipient), true)
// TODO: Remove in UserConfig branch
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@HomeActivity)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
binding.recyclerView.adapter!!.notifyDataSetChanged() binding.recyclerView.adapter!!.notifyDataSetChanged()
} }
@ -511,9 +562,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
text(R.string.RecipientPreferenceActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact) text(R.string.RecipientPreferenceActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact)
button(R.string.RecipientPreferenceActivity_unblock) { button(R.string.RecipientPreferenceActivity_unblock) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
recipientDatabase.setBlocked(thread.recipient, false) storage.setBlocked(listOf(thread.recipient), false)
// TODO: Remove in UserConfig branch
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@HomeActivity)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
binding.recyclerView.adapter!!.notifyDataSetChanged() binding.recyclerView.adapter!!.notifyDataSetChanged()
} }
@ -554,14 +604,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private fun setConversationPinned(threadId: Long, pinned: Boolean) { private fun setConversationPinned(threadId: Long, pinned: Boolean) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
threadDb.setPinned(threadId, pinned) storage.setPinned(threadId, pinned)
homeViewModel.tryUpdateChannel() homeViewModel.tryUpdateChannel()
} }
} }
private fun markAllAsRead(thread: ThreadRecord) { private fun markAllAsRead(thread: ThreadRecord) {
ThreadUtils.queue { ThreadUtils.queue {
threadDb.markAllAsRead(thread.threadId, thread.recipient.isOpenGroupRecipient) MessagingModuleConfiguration.shared.storage.markConversationAsRead(thread.threadId, SnodeAPI.nowWithOffset)
} }
} }

View File

@ -10,10 +10,12 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID import androidx.recyclerview.widget.RecyclerView.NO_ID
import network.loki.messenger.R import network.loki.messenger.R
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
class HomeAdapter( class HomeAdapter(
private val context: Context, private val context: Context,
private val configFactory: ConfigFactory,
private val listener: ConversationClickListener private val listener: ConversationClickListener
) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), ListUpdateCallback { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), ListUpdateCallback {
@ -29,7 +31,7 @@ class HomeAdapter(
get() = _data.toList() get() = _data.toList()
set(newData) { set(newData) {
val previousData = _data.toList() val previousData = _data.toList()
val diff = HomeDiffUtil(previousData, newData, context) val diff = HomeDiffUtil(previousData, newData, context, configFactory)
val diffResult = DiffUtil.calculateDiff(diff) val diffResult = DiffUtil.calculateDiff(diff)
_data = newData _data = newData
diffResult.dispatchUpdatesTo(this as ListUpdateCallback) diffResult.dispatchUpdatesTo(this as ListUpdateCallback)

View File

@ -3,11 +3,14 @@ package org.thoughtcrime.securesms.home
import android.content.Context import android.content.Context
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.getConversationUnread
class HomeDiffUtil( class HomeDiffUtil(
private val old: List<ThreadRecord>, private val old: List<ThreadRecord>,
private val new: List<ThreadRecord>, private val new: List<ThreadRecord>,
private val context: Context private val context: Context,
private val configFactory: ConfigFactory
): DiffUtil.Callback() { ): DiffUtil.Callback() {
override fun getOldListSize(): Int = old.size override fun getOldListSize(): Int = old.size
@ -42,7 +45,9 @@ class HomeDiffUtil(
oldItem.isFailed == newItem.isFailed && oldItem.isFailed == newItem.isFailed &&
oldItem.isDelivered == newItem.isDelivered && oldItem.isDelivered == newItem.isDelivered &&
oldItem.isSent == newItem.isSent && oldItem.isSent == newItem.isSent &&
oldItem.isPending == newItem.isPending oldItem.isPending == newItem.isPending &&
oldItem.lastSeen == newItem.lastSeen &&
configFactory.convoVolatile?.getConversationUnread(newItem) != true
) )
} }

View File

@ -9,9 +9,14 @@ import android.graphics.Paint
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.lifecycle.coroutineScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionRequestAPI
import org.thoughtcrime.securesms.conversation.v2.ViewUtil
import org.thoughtcrime.securesms.util.getColorWithID import org.thoughtcrime.securesms.util.getColorWithID
import org.thoughtcrime.securesms.util.toPx import org.thoughtcrime.securesms.util.toPx
@ -29,6 +34,8 @@ class PathStatusView : View {
result result
} }
private var updateJob: Job? = null
constructor(context: Context) : super(context) { constructor(context: Context) : super(context) {
initialize() initialize()
} }
@ -87,7 +94,10 @@ class PathStatusView : View {
private fun handlePathsBuiltEvent() { update() } private fun handlePathsBuiltEvent() { update() }
private fun update() { private fun update() {
if (OnionRequestAPI.paths.isNotEmpty()) { if (updateJob?.isActive != true) { // false or null
updateJob = ViewUtil.getActivityLifecycle(this)?.coroutineScope?.launchWhenStarted {
val paths = withContext(Dispatchers.IO) { OnionRequestAPI.paths }
if (paths.isNotEmpty()) {
setBackgroundResource(R.drawable.accent_dot) setBackgroundResource(R.drawable.accent_dot)
val hasPathsColor = context.getColor(R.color.accent_green) val hasPathsColor = context.getColor(R.color.accent_green)
mainColor = hasPathsColor mainColor = hasPathsColor
@ -99,6 +109,8 @@ class PathStatusView : View {
sessionShadowColor = pathsBuildingColor sessionShadowColor = pathsBuildingColor
} }
} }
}
}
override fun onDraw(c: Canvas) { override fun onDraw(c: Canvas) {
val w = width.toFloat() val w = width.toFloat()

View File

@ -25,9 +25,7 @@ import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.UiModeUtilities
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -61,6 +59,7 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
profilePictureView.root.update(recipient) profilePictureView.root.update(recipient)
nameTextViewContainer.visibility = View.VISIBLE nameTextViewContainer.visibility = View.VISIBLE
nameTextViewContainer.setOnClickListener { nameTextViewContainer.setOnClickListener {
if (recipient.isOpenGroupInboxRecipient || recipient.isOpenGroupOutboxRecipient) return@setOnClickListener
nameTextViewContainer.visibility = View.INVISIBLE nameTextViewContainer.visibility = View.INVISIBLE
nameEditTextContainer.visibility = View.VISIBLE nameEditTextContainer.visibility = View.VISIBLE
nicknameEditText.text = null nicknameEditText.text = null
@ -87,8 +86,14 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
} }
nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally
publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient && !threadRecipient.isOpenGroupInboxRecipient nameEditIcon.isVisible = threadRecipient.isContactRecipient
messageButton.isVisible = !threadRecipient.isOpenGroupRecipient || IdPrefix.fromValue(publicKey) == IdPrefix.BLINDED && !threadRecipient.isOpenGroupInboxRecipient
&& !threadRecipient.isOpenGroupOutboxRecipient
publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient
&& !threadRecipient.isOpenGroupInboxRecipient
&& !threadRecipient.isOpenGroupOutboxRecipient
messageButton.isVisible = !threadRecipient.isOpenGroupRecipient || IdPrefix.fromValue(publicKey)?.isBlinded() == true
publicKeyTextView.text = publicKey publicKeyTextView.text = publicKey
publicKeyTextView.setOnLongClickListener { publicKeyTextView.setOnLongClickListener {
val clipboard = val clipboard =
@ -130,10 +135,10 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
newNickName = nicknameEditText.text.toString() newNickName = nicknameEditText.text.toString()
} }
val publicKey = recipient.address.serialize() val publicKey = recipient.address.serialize()
val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase() val storage = MessagingModuleConfiguration.shared.storage
val contact = contactDB.getContactWithSessionID(publicKey) ?: Contact(publicKey) val contact = storage.getContactWithSessionID(publicKey) ?: Contact(publicKey)
contact.nickname = newNickName contact.nickname = newNickName
contactDB.setContact(contact) storage.setContact(contact)
nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally
} }

View File

@ -12,6 +12,7 @@ import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
@ -76,6 +77,8 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) {
} }
binding.searchResultSubtitle.text = getHighlight(query, membersString) binding.searchResultSubtitle.text = getHighlight(query, membersString)
} }
is Header, // do nothing for header
is SavedMessages -> Unit // do nothing for saved messages (displays note to self)
} }
} }

View File

@ -154,7 +154,7 @@ class KeyboardPageSearchView @JvmOverloads constructor(
.setDuration(REVEAL_DURATION) .setDuration(REVEAL_DURATION)
.alpha(0f) .alpha(0f)
.setListener(object : AnimationCompleteListener() { .setListener(object : AnimationCompleteListener() {
override fun onAnimationEnd(animation: Animator?) { override fun onAnimationEnd(animation: Animator) {
visibility = INVISIBLE visibility = INVISIBLE
} }
}) })

View File

@ -49,7 +49,7 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
adapter.glide = glide adapter.glide = glide
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
binding.clearAllMessageRequestsButton.setOnClickListener { deleteAllAndBlock() } binding.clearAllMessageRequestsButton.setOnClickListener { deleteAll() }
} }
override fun onResume() { override fun onResume() {
@ -113,9 +113,9 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
binding.clearAllMessageRequestsButton.isVisible = threadCount != 0 binding.clearAllMessageRequestsButton.isVisible = threadCount != 0
} }
private fun deleteAllAndBlock() { private fun deleteAll() {
fun doDeleteAllAndBlock() { fun doDeleteAllAndBlock() {
viewModel.clearAllMessageRequests() viewModel.clearAllMessageRequests(false)
LoaderManager.getInstance(this).restartLoader(0, null, this) LoaderManager.getInstance(this).restartLoader(0, null, this)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@MessageRequestsActivity) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@MessageRequestsActivity)

View File

@ -48,6 +48,7 @@ class MessageRequestsAdapter(
private fun showPopupMenu(view: MessageRequestView) { private fun showPopupMenu(view: MessageRequestView) {
val popupMenu = PopupMenu(ContextThemeWrapper(context, R.style.PopupMenu_MessageRequests), view) val popupMenu = PopupMenu(ContextThemeWrapper(context, R.style.PopupMenu_MessageRequests), view)
popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu) popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu)
popupMenu.menu.findItem(R.id.menu_block_message_request)?.isVisible = !view.thread!!.recipient.isOpenGroupInboxRecipient
popupMenu.setOnMenuItemClickListener { menuItem -> popupMenu.setOnMenuItemClickListener { menuItem ->
if (menuItem.itemId == R.id.menu_delete_message_request) { if (menuItem.itemId == R.id.menu_delete_message_request) {
listener.onDeleteConversationClick(view.thread!!) listener.onDeleteConversationClick(view.thread!!)

View File

@ -25,8 +25,8 @@ class MessageRequestsViewModel @Inject constructor(
repository.deleteMessageRequest(thread) repository.deleteMessageRequest(thread)
} }
fun clearAllMessageRequests() = viewModelScope.launch { fun clearAllMessageRequests(block: Boolean) = viewModelScope.launch {
repository.clearAllMessageRequests() repository.clearAllMessageRequests(block)
} }
} }

View File

@ -60,7 +60,6 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilit
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities; import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities;
import org.thoughtcrime.securesms.crypto.KeyPairUtilities; import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
import org.thoughtcrime.securesms.database.LokiThreadDatabase; import org.thoughtcrime.securesms.database.LokiThreadDatabase;
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase;
@ -160,8 +159,9 @@ public class DefaultMessageNotifier implements MessageNotifier {
executor.cancel(); executor.cancel();
} }
private void cancelActiveNotifications(@NonNull Context context) { private boolean cancelActiveNotifications(@NonNull Context context) {
NotificationManager notifications = ServiceUtil.getNotificationManager(context); NotificationManager notifications = ServiceUtil.getNotificationManager(context);
boolean hasNotifications = notifications.getActiveNotifications().length > 0;
notifications.cancel(SUMMARY_NOTIFICATION_ID); notifications.cancel(SUMMARY_NOTIFICATION_ID);
try { try {
@ -175,6 +175,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
Log.w(TAG, e); Log.w(TAG, e);
notifications.cancelAll(); notifications.cancelAll();
} }
return hasNotifications;
} }
private void cancelOrphanedNotifications(@NonNull Context context, NotificationState notificationState) { private void cancelOrphanedNotifications(@NonNull Context context, NotificationState notificationState) {
@ -240,10 +241,6 @@ public class DefaultMessageNotifier implements MessageNotifier {
!(recipient.isApproved() || threads.getLastSeenAndHasSent(threadId).second())) { !(recipient.isApproved() || threads.getLastSeenAndHasSent(threadId).second())) {
TextSecurePreferences.removeHasHiddenMessageRequests(context); TextSecurePreferences.removeHasHiddenMessageRequests(context);
} }
if (isVisible && recipient != null) {
List<MarkedMessageInfo> messageIds = threads.setRead(threadId, false);
if (SessionMetaProtocol.shouldSendReadReceipt(recipient)) { MarkReadReceiver.process(context, messageIds); }
}
if (!TextSecurePreferences.isNotificationsEnabled(context) || if (!TextSecurePreferences.isNotificationsEnabled(context) ||
(recipient != null && recipient.isMuted())) (recipient != null && recipient.isMuted()))
@ -251,11 +248,21 @@ public class DefaultMessageNotifier implements MessageNotifier {
return; return;
} }
if (!isVisible && !homeScreenVisible) { if ((!isVisible && !homeScreenVisible) || hasExistingNotifications(context)) {
updateNotification(context, signal, 0); updateNotification(context, signal, 0);
} }
} }
private boolean hasExistingNotifications(Context context) {
NotificationManager notifications = ServiceUtil.getNotificationManager(context);
try {
StatusBarNotification[] activeNotifications = notifications.getActiveNotifications();
return activeNotifications.length > 0;
} catch (Exception e) {
return false;
}
}
@Override @Override
public void updateNotification(@NonNull Context context, boolean signal, int reminderCount) public void updateNotification(@NonNull Context context, boolean signal, int reminderCount)
{ {
@ -267,8 +274,8 @@ public class DefaultMessageNotifier implements MessageNotifier {
if ((telcoCursor == null || telcoCursor.isAfterLast()) || !TextSecurePreferences.hasSeenWelcomeScreen(context)) if ((telcoCursor == null || telcoCursor.isAfterLast()) || !TextSecurePreferences.hasSeenWelcomeScreen(context))
{ {
cancelActiveNotifications(context);
updateBadge(context, 0); updateBadge(context, 0);
cancelActiveNotifications(context);
clearReminder(context); clearReminder(context);
return; return;
} }

View File

@ -12,6 +12,8 @@ import androidx.core.app.NotificationManagerCompat;
import com.annimon.stream.Collectors; import com.annimon.stream.Collectors;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import org.session.libsession.database.StorageProtocol;
import org.session.libsession.messaging.MessagingModuleConfiguration;
import org.session.libsession.messaging.messages.control.ReadReceipt; import org.session.libsession.messaging.messages.control.ReadReceipt;
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.snode.SnodeAPI;
@ -27,7 +29,6 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.util.SessionMetaProtocol; import org.thoughtcrime.securesms.util.SessionMetaProtocol;
import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -52,18 +53,12 @@ public class MarkReadReceiver extends BroadcastReceiver {
new AsyncTask<Void, Void, Void>() { new AsyncTask<Void, Void, Void>() {
@Override @Override
protected Void doInBackground(Void... params) { protected Void doInBackground(Void... params) {
List<MarkedMessageInfo> messageIdsCollection = new LinkedList<>(); long currentTime = SnodeAPI.getNowWithOffset();
for (long threadId : threadIds) { for (long threadId : threadIds) {
Log.i(TAG, "Marking as read: " + threadId); Log.i(TAG, "Marking as read: " + threadId);
List<MarkedMessageInfo> messageIds = DatabaseComponent.get(context).threadDatabase().setRead(threadId, true); StorageProtocol storage = MessagingModuleConfiguration.getShared().getStorage();
messageIdsCollection.addAll(messageIds); storage.markConversationAsRead(threadId,currentTime, true);
} }
process(context, messageIdsCollection);
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context);
return null; return null;
} }
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);

View File

@ -15,6 +15,7 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentPagerAdapter import androidx.fragment.app.FragmentPagerAdapter
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
@ -34,12 +35,19 @@ import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.crypto.MnemonicUtilities import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate
import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import javax.inject.Inject
@AndroidEntryPoint
class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDelegate { class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDelegate {
@Inject
lateinit var configFactory: ConfigFactory
private lateinit var binding: ActivityLinkDeviceBinding private lateinit var binding: ActivityLinkDeviceBinding
internal val database: LokiAPIDatabaseProtocol internal val database: LokiAPIDatabaseProtocol
get() = SnodeModule.shared.storage get() = SnodeModule.shared.storage
@ -112,6 +120,7 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel
val keyPairGenerationResult = KeyPairUtilities.generate(seed) val keyPairGenerationResult = KeyPairUtilities.generate(seed)
val x25519KeyPair = keyPairGenerationResult.x25519KeyPair val x25519KeyPair = keyPairGenerationResult.x25519KeyPair
KeyPairUtilities.store(this@LinkDeviceActivity, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair) KeyPairUtilities.store(this@LinkDeviceActivity, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair)
configFactory.keyPairChanged()
val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false) val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(this@LinkDeviceActivity, registrationID) TextSecurePreferences.setLocalRegistrationId(this@LinkDeviceActivity, registrationID)
@ -124,9 +133,8 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel
.setAction(R.string.registration_activity__skip) { register(true) } .setAction(R.string.registration_activity__skip) { register(true) }
val skipJob = launch { val skipJob = launch {
delay(30_000L) delay(15_000L)
snackBar.show() snackBar.show()
// show a dialog or something saying do you want to skip this bit?
} }
// start polling and wait for updated message // start polling and wait for updated message
ApplicationContext.getInstance(this@LinkDeviceActivity).apply { ApplicationContext.getInstance(this@LinkDeviceActivity).apply {

View File

@ -164,6 +164,7 @@ class PNModeActivity : BaseActionBarActivity() {
application.registerForFCMIfNeeded(true) application.registerForFCMIfNeeded(true)
val intent = Intent(this, HomeActivity::class.java) val intent = Intent(this, HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
intent.putExtra(HomeActivity.FROM_ONBOARDING, true)
show(intent) show(intent)
} }
// endregion // endregion

View File

@ -11,6 +11,7 @@ import android.text.style.ClickableSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.view.View import android.view.View
import android.widget.Toast import android.widget.Toast
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityRecoveryPhraseRestoreBinding import network.loki.messenger.databinding.ActivityRecoveryPhraseRestoreBinding
import org.session.libsession.snode.SnodeModule import org.session.libsession.snode.SnodeModule
@ -23,10 +24,17 @@ import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.crypto.MnemonicUtilities import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import javax.inject.Inject
@AndroidEntryPoint
class RecoveryPhraseRestoreActivity : BaseActionBarActivity() { class RecoveryPhraseRestoreActivity : BaseActionBarActivity() {
@Inject
lateinit var configFactory: ConfigFactory
private lateinit var binding: ActivityRecoveryPhraseRestoreBinding private lateinit var binding: ActivityRecoveryPhraseRestoreBinding
internal val database: LokiAPIDatabaseProtocol internal val database: LokiAPIDatabaseProtocol
get() = SnodeModule.shared.storage get() = SnodeModule.shared.storage
@ -81,6 +89,7 @@ class RecoveryPhraseRestoreActivity : BaseActionBarActivity() {
val keyPairGenerationResult = KeyPairUtilities.generate(seed) val keyPairGenerationResult = KeyPairUtilities.generate(seed)
val x25519KeyPair = keyPairGenerationResult.x25519KeyPair val x25519KeyPair = keyPairGenerationResult.x25519KeyPair
KeyPairUtilities.store(this, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair) KeyPairUtilities.store(this, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair)
configFactory.keyPairChanged()
val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false) val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(this, registrationID) TextSecurePreferences.setLocalRegistrationId(this, registrationID)

View File

@ -16,6 +16,7 @@ import android.text.style.StyleSpan
import android.view.View import android.view.View
import android.widget.Toast import android.widget.Toast
import com.goterl.lazysodium.utils.KeyPair import com.goterl.lazysodium.utils.KeyPair
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityRegisterBinding import network.loki.messenger.databinding.ActivityRegisterBinding
import org.session.libsession.snode.SnodeModule import org.session.libsession.snode.SnodeModule
@ -26,10 +27,17 @@ import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import javax.inject.Inject
@AndroidEntryPoint
class RegisterActivity : BaseActionBarActivity() { class RegisterActivity : BaseActionBarActivity() {
@Inject
lateinit var configFactory: ConfigFactory
private lateinit var binding: ActivityRegisterBinding private lateinit var binding: ActivityRegisterBinding
internal val database: LokiAPIDatabaseProtocol internal val database: LokiAPIDatabaseProtocol
get() = SnodeModule.shared.storage get() = SnodeModule.shared.storage
@ -119,6 +127,7 @@ class RegisterActivity : BaseActionBarActivity() {
database.clearReceivedMessageHashValues() database.clearReceivedMessageHashValues()
KeyPairUtilities.store(this, seed!!, ed25519KeyPair!!, x25519KeyPair!!) KeyPairUtilities.store(this, seed!!, ed25519KeyPair!!, x25519KeyPair!!)
configFactory.keyPairChanged()
val userHexEncodedPublicKey = x25519KeyPair!!.hexEncodedPublicKey val userHexEncodedPublicKey = x25519KeyPair!!.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false) val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(this, registrationID) TextSecurePreferences.setLocalRegistrationId(this, registrationID)

View File

@ -22,7 +22,7 @@ class BlockedContactsActivity: PassphraseRequiredActionBarActivity() {
showSessionDialog { showSessionDialog {
title(viewModel.getTitle(this@BlockedContactsActivity)) title(viewModel.getTitle(this@BlockedContactsActivity))
text(viewModel.getMessage(this@BlockedContactsActivity)) text(viewModel.getMessage(this@BlockedContactsActivity))
button(R.string.continue_2) { viewModel.unblock(this@BlockedContactsActivity) } button(R.string.continue_2) { viewModel.unblock() }
cancelButton() cancelButton()
} }
} }

View File

@ -63,13 +63,9 @@ class BlockedContactsViewModel @Inject constructor(private val storage: Storage)
return _state return _state
} }
fun unblock(context: Context) { fun unblock() {
storage.unblock(state.selectedItems) storage.setBlocked(state.selectedItems, false)
_state.value = state.copy(selectedItems = emptySet()) _state.value = state.copy(selectedItems = emptySet())
// TODO: Remove in UserConfig branch
GlobalScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
} }
fun select(selectedItem: Recipient, isSelected: Boolean) { fun select(selectedItem: Recipient, isSelected: Boolean) {

View File

@ -24,8 +24,6 @@ import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.components.CustomDefaultPreference; import org.thoughtcrime.securesms.components.CustomDefaultPreference;
import org.thoughtcrime.securesms.conversation.v2.ViewUtil; import org.thoughtcrime.securesms.conversation.v2.ViewUtil;
import org.thoughtcrime.securesms.preferences.widgets.ColorPickerPreference;
import org.thoughtcrime.securesms.preferences.widgets.ColorPickerPreferenceDialogFragmentCompat;
import network.loki.messenger.R; import network.loki.messenger.R;
@ -60,9 +58,7 @@ public abstract class CorrectedPreferenceFragment extends PreferenceFragmentComp
public void onDisplayPreferenceDialog(Preference preference) { public void onDisplayPreferenceDialog(Preference preference) {
DialogFragment dialogFragment = null; DialogFragment dialogFragment = null;
if (preference instanceof ColorPickerPreference) { if (preference instanceof CustomDefaultPreference) {
dialogFragment = ColorPickerPreferenceDialogFragmentCompat.newInstance(preference.getKey());
} else if (preference instanceof CustomDefaultPreference) {
dialogFragment = CustomDefaultPreference.CustomDefaultPreferenceDialogFragmentCompat.newInstance(preference.getKey()); dialogFragment = CustomDefaultPreference.CustomDefaultPreferenceDialogFragmentCompat.newInstance(preference.getKey());
} }

View File

@ -2,7 +2,10 @@ package org.thoughtcrime.securesms.preferences
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.* import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.AsyncTask import android.os.AsyncTask
import android.os.Bundle import android.os.Bundle
@ -17,14 +20,18 @@ import android.view.View
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Toast import android.widget.Toast
import androidx.core.view.isVisible import androidx.core.view.isVisible
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.BuildConfig import network.loki.messenger.BuildConfig
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivitySettingsBinding import network.loki.messenger.databinding.ActivitySettingsBinding
import network.loki.messenger.libsession_util.util.UserPic
import nl.komponents.kovenant.Promise import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.all import nl.komponents.kovenant.all
import nl.komponents.kovenant.ui.alwaysUi import nl.komponents.kovenant.ui.alwaysUi
import nl.komponents.kovenant.ui.successUi import nl.komponents.kovenant.ui.successUi
import org.session.libsession.avatars.AvatarHelper import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.avatars.ProfileContactPhoto
import org.session.libsession.utilities.* import org.session.libsession.utilities.*
import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol
@ -32,6 +39,7 @@ import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.avatar.AvatarSelection import org.thoughtcrime.securesms.avatar.AvatarSelection
import org.thoughtcrime.securesms.components.ProfilePictureView import org.thoughtcrime.securesms.components.ProfilePictureView
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.home.PathActivity import org.thoughtcrime.securesms.home.PathActivity
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideApp
@ -48,9 +56,14 @@ import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show import org.thoughtcrime.securesms.util.show
import java.io.File import java.io.File
import java.security.SecureRandom import java.security.SecureRandom
import java.util.Date import javax.inject.Inject
@AndroidEntryPoint
class SettingsActivity : PassphraseRequiredActionBarActivity() { class SettingsActivity : PassphraseRequiredActionBarActivity() {
@Inject
lateinit var configFactory: ConfigFactory
private lateinit var binding: ActivitySettingsBinding private lateinit var binding: ActivitySettingsBinding
private var displayNameEditActionMode: ActionMode? = null private var displayNameEditActionMode: ActionMode? = null
set(value) { field = value; handleDisplayNameEditActionModeChanged() } set(value) { field = value; handleDisplayNameEditActionModeChanged() }
@ -76,7 +89,9 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
glide = GlideApp.with(this) glide = GlideApp.with(this)
with(binding) { with(binding) {
setupProfilePictureView(profilePictureView.root) setupProfilePictureView(profilePictureView.root)
profilePictureView.root.setOnClickListener { showEditProfilePictureUI() } profilePictureView.root.setOnClickListener {
showEditProfilePictureUI()
}
ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) }
btnGroupNameDisplay.text = displayName btnGroupNameDisplay.text = displayName
publicKeyTextView.text = hexEncodedPublicKey publicKeyTextView.text = hexEncodedPublicKey
@ -204,28 +219,37 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
val promises = mutableListOf<Promise<*, Exception>>() val promises = mutableListOf<Promise<*, Exception>>()
if (displayName != null) { if (displayName != null) {
TextSecurePreferences.setProfileName(this, displayName) TextSecurePreferences.setProfileName(this, displayName)
configFactory.user?.setName(displayName)
} }
val encodedProfileKey = ProfileKeyUtil.generateEncodedProfileKey(this) val encodedProfileKey = ProfileKeyUtil.generateEncodedProfileKey(this)
if (isUpdatingProfilePicture) { if (isUpdatingProfilePicture) {
if (profilePicture != null) { if (profilePicture != null) {
promises.add(ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, this)) promises.add(ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, this))
} else { } else {
TextSecurePreferences.setLastProfilePictureUpload(this, System.currentTimeMillis()) MessagingModuleConfiguration.shared.storage.clearUserPic()
TextSecurePreferences.setProfilePictureURL(this, null)
} }
} }
val compoundPromise = all(promises) val compoundPromise = all(promises)
compoundPromise.successUi { // Do this on the UI thread so that it happens before the alwaysUi clause below compoundPromise.successUi { // Do this on the UI thread so that it happens before the alwaysUi clause below
val userConfig = configFactory.user
if (isUpdatingProfilePicture) { if (isUpdatingProfilePicture) {
AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture) AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture)
TextSecurePreferences.setProfileAvatarId(this, profilePicture?.let { SecureRandom().nextInt() } ?: 0 ) TextSecurePreferences.setProfileAvatarId(this, profilePicture?.let { SecureRandom().nextInt() } ?: 0 )
TextSecurePreferences.setLastProfilePictureUpload(this, Date().time)
ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey) ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey)
// new config
val url = TextSecurePreferences.getProfilePictureURL(this)
val profileKey = ProfileKeyUtil.getProfileKey(this)
if (profilePicture == null) {
userConfig?.setPic(UserPic.DEFAULT)
} else if (!url.isNullOrEmpty() && profileKey.isNotEmpty()) {
userConfig?.setPic(UserPic(url, profileKey))
}
}
if (userConfig != null && userConfig.needsDump()) {
configFactory.persist(userConfig, SnodeAPI.nowWithOffset)
} }
if (profilePicture != null || displayName != null) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@SettingsActivity) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@SettingsActivity)
} }
}
compoundPromise.alwaysUi { compoundPromise.alwaysUi {
if (displayName != null) { if (displayName != null) {
binding.btnGroupNameDisplay.text = displayName binding.btnGroupNameDisplay.text = displayName

View File

@ -1,251 +0,0 @@
package org.thoughtcrime.securesms.preferences.widgets;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import androidx.core.content.ContextCompat;
import androidx.core.content.res.TypedArrayUtils;
import androidx.preference.DialogPreference;
import androidx.preference.PreferenceViewHolder;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.widget.ImageView;
import com.takisoft.colorpicker.ColorPickerDialog;
import com.takisoft.colorpicker.ColorPickerDialog.Size;
import com.takisoft.colorpicker.ColorStateDrawable;
import network.loki.messenger.R;
public class ColorPickerPreference extends DialogPreference {
private static final String TAG = ColorPickerPreference.class.getSimpleName();
private int[] colors;
private CharSequence[] colorDescriptions;
private int color;
private int columns;
private int size;
private boolean sortColors;
private ImageView colorWidget;
private OnPreferenceChangeListener listener;
public ColorPickerPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ColorPickerPreference, defStyleAttr, 0);
int colorsId = a.getResourceId(R.styleable.ColorPickerPreference_colors, R.array.color_picker_default_colors);
if (colorsId != 0) {
colors = context.getResources().getIntArray(colorsId);
}
colorDescriptions = a.getTextArray(R.styleable.ColorPickerPreference_colorDescriptions);
color = a.getColor(R.styleable.ColorPickerPreference_currentColor, 0);
columns = a.getInt(R.styleable.ColorPickerPreference_columns, 3);
size = a.getInt(R.styleable.ColorPickerPreference_colorSize, 2);
sortColors = a.getBoolean(R.styleable.ColorPickerPreference_sortColors, false);
a.recycle();
setWidgetLayoutResource(R.layout.preference_widget_color_swatch);
}
public ColorPickerPreference(Context context, AttributeSet attrs, int defStyleAttr) {
this(context, attrs, defStyleAttr, 0);
}
@SuppressLint("RestrictedApi")
public ColorPickerPreference(Context context, AttributeSet attrs) {
this(context, attrs, TypedArrayUtils.getAttr(context, R.attr.dialogPreferenceStyle,
android.R.attr.dialogPreferenceStyle));
}
public ColorPickerPreference(Context context) {
this(context, null);
}
@Override
public void setOnPreferenceChangeListener(OnPreferenceChangeListener listener) {
super.setOnPreferenceChangeListener(listener);
this.listener = listener;
}
@Override
public void onBindViewHolder(PreferenceViewHolder holder) {
super.onBindViewHolder(holder);
colorWidget = (ImageView) holder.findViewById(R.id.color_picker_widget);
setColorOnWidget(color);
}
private void setColorOnWidget(int color) {
if (colorWidget == null) {
return;
}
Drawable[] colorDrawable = new Drawable[]
{ContextCompat.getDrawable(getContext(), R.drawable.colorpickerpreference_pref_swatch)};
colorWidget.setImageDrawable(new ColorStateDrawable(colorDrawable, color));
}
/**
* Returns the current color.
*
* @return The current color.
*/
public int getColor() {
return color;
}
/**
* Sets the current color.
*
* @param color The current color.
*/
public void setColor(int color) {
setInternalColor(color, false);
}
/**
* Returns all of the available colors.
*
* @return The available colors.
*/
public int[] getColors() {
return colors;
}
/**
* Sets the available colors.
*
* @param colors The available colors.
*/
public void setColors(int[] colors) {
this.colors = colors;
}
/**
* Returns whether the available colors should be sorted automatically based on their HSV
* values.
*
* @return Whether the available colors should be sorted automatically based on their HSV
* values.
*/
public boolean isSortColors() {
return sortColors;
}
/**
* Sets whether the available colors should be sorted automatically based on their HSV
* values. The sorting does not modify the order of the original colors supplied via
* {@link #setColors(int[])} or the XML attribute {@code app:colors}.
*
* @param sortColors Whether the available colors should be sorted automatically based on their
* HSV values.
*/
public void setSortColors(boolean sortColors) {
this.sortColors = sortColors;
}
/**
* Returns the available colors' descriptions that can be used by accessibility services.
*
* @return The available colors' descriptions.
*/
public CharSequence[] getColorDescriptions() {
return colorDescriptions;
}
/**
* Sets the available colors' descriptions that can be used by accessibility services.
*
* @param colorDescriptions The available colors' descriptions.
*/
public void setColorDescriptions(CharSequence[] colorDescriptions) {
this.colorDescriptions = colorDescriptions;
}
/**
* Returns the number of columns to be used in the picker dialog for displaying the available
* colors. If the value is less than or equals to 0, the number of columns will be determined
* automatically by the system using FlexboxLayoutManager.
*
* @return The number of columns to be used in the picker dialog.
* @see com.google.android.flexbox.FlexboxLayoutManager
*/
public int getColumns() {
return columns;
}
/**
* Sets the number of columns to be used in the picker dialog for displaying the available
* colors. If the value is less than or equals to 0, the number of columns will be determined
* automatically by the system using FlexboxLayoutManager.
*
* @param columns The number of columns to be used in the picker dialog. Use 0 to set it to
* 'auto' mode.
* @see com.google.android.flexbox.FlexboxLayoutManager
*/
public void setColumns(int columns) {
this.columns = columns;
}
/**
* Returns the size of the color swatches in the dialog. It can be either
* {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}.
*
* @return The size of the color swatches in the dialog.
* @see ColorPickerDialog#SIZE_SMALL
* @see ColorPickerDialog#SIZE_LARGE
*/
@Size
public int getSize() {
return size;
}
/**
* Sets the size of the color swatches in the dialog. It can be either
* {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}.
*
* @param size The size of the color swatches in the dialog. It can be either
* {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}.
* @see ColorPickerDialog#SIZE_SMALL
* @see ColorPickerDialog#SIZE_LARGE
*/
public void setSize(@Size int size) {
this.size = size;
}
private void setInternalColor(int color, boolean force) {
int oldColor = getPersistedInt(0);
boolean changed = oldColor != color;
if (changed || force) {
this.color = color;
persistInt(color);
setColorOnWidget(color);
if (listener != null) listener.onPreferenceChange(this, color);
notifyChanged();
}
}
@Override
protected Object onGetDefaultValue(TypedArray a, int index) {
return a.getString(index);
}
@Override
protected void onSetInitialValue(boolean restoreValue, Object defaultValueObj) {
final String defaultValue = (String) defaultValueObj;
setInternalColor(restoreValue ? getPersistedInt(0) : (!TextUtils.isEmpty(defaultValue) ? Color.parseColor(defaultValue) : 0), true);
}
}

View File

@ -1,64 +0,0 @@
package org.thoughtcrime.securesms.preferences.widgets;
import android.app.Dialog;
import android.content.DialogInterface;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.preference.PreferenceDialogFragmentCompat;
import com.takisoft.colorpicker.ColorPickerDialog;
import com.takisoft.colorpicker.OnColorSelectedListener;
public class ColorPickerPreferenceDialogFragmentCompat extends PreferenceDialogFragmentCompat implements OnColorSelectedListener {
private int pickedColor;
public static ColorPickerPreferenceDialogFragmentCompat newInstance(String key) {
ColorPickerPreferenceDialogFragmentCompat fragment = new ColorPickerPreferenceDialogFragmentCompat();
Bundle b = new Bundle(1);
b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key);
fragment.setArguments(b);
return fragment;
}
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
ColorPickerPreference pref = getColorPickerPreference();
ColorPickerDialog.Params params = new ColorPickerDialog.Params.Builder(getContext())
.setSelectedColor(pref.getColor())
.setColors(pref.getColors())
.setColorContentDescriptions(pref.getColorDescriptions())
.setSize(pref.getSize())
.setSortColors(pref.isSortColors())
.setColumns(pref.getColumns())
.build();
ColorPickerDialog dialog = new ColorPickerDialog(getActivity(), this, params);
dialog.setTitle(pref.getDialogTitle());
return dialog;
}
@Override
public void onDialogClosed(boolean positiveResult) {
ColorPickerPreference preference = getColorPickerPreference();
if (positiveResult) {
preference.setColor(pickedColor);
}
}
@Override
public void onColorSelected(int color) {
this.pickedColor = color;
super.onClick(getDialog(), DialogInterface.BUTTON_POSITIVE);
}
ColorPickerPreference getColorPickerPreference() {
return (ColorPickerPreference) getPreference();
}
}

View File

@ -23,9 +23,11 @@ import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.SessionJobDatabase import org.thoughtcrime.securesms.database.SessionJobDatabase
import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@ -62,7 +64,7 @@ interface ConversationRepository {
suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf<Unit> suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf<Unit>
suspend fun clearAllMessageRequests(): ResultOf<Unit> suspend fun clearAllMessageRequests(block: Boolean): ResultOf<Unit>
suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf<Unit> suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf<Unit>
@ -82,8 +84,10 @@ class DefaultConversationRepository @Inject constructor(
private val mmsDb: MmsDatabase, private val mmsDb: MmsDatabase,
private val mmsSmsDb: MmsSmsDatabase, private val mmsSmsDb: MmsSmsDatabase,
private val recipientDb: RecipientDatabase, private val recipientDb: RecipientDatabase,
private val storage: Storage,
private val lokiMessageDb: LokiMessageDatabase, private val lokiMessageDb: LokiMessageDatabase,
private val sessionJobDb: SessionJobDatabase private val sessionJobDb: SessionJobDatabase,
private val configFactory: ConfigFactory
) : ConversationRepository { ) : ConversationRepository {
override fun maybeGetRecipientForThreadId(threadId: Long): Recipient? { override fun maybeGetRecipientForThreadId(threadId: Long): Recipient? {
@ -125,8 +129,9 @@ class DefaultConversationRepository @Inject constructor(
} }
} }
// This assumes that recipient.isContactRecipient is true
override fun setBlocked(recipient: Recipient, blocked: Boolean) { override fun setBlocked(recipient: Recipient, blocked: Boolean) {
recipientDb.setBlocked(recipient, blocked) storage.setBlocked(listOf(recipient), blocked)
} }
override fun deleteLocally(recipient: Recipient, message: MessageRecord) { override fun deleteLocally(recipient: Recipient, message: MessageRecord) {
@ -139,7 +144,7 @@ class DefaultConversationRepository @Inject constructor(
} }
override fun setApproved(recipient: Recipient, isApproved: Boolean) { override fun setApproved(recipient: Recipient, isApproved: Boolean) {
recipientDb.setApproved(recipient, isApproved) storage.setRecipientApproved(recipient, isApproved)
} }
override suspend fun deleteForEveryone( override suspend fun deleteForEveryone(
@ -250,29 +255,33 @@ class DefaultConversationRepository @Inject constructor(
override suspend fun deleteThread(threadId: Long): ResultOf<Unit> { override suspend fun deleteThread(threadId: Long): ResultOf<Unit> {
sessionJobDb.cancelPendingMessageSendJobs(threadId) sessionJobDb.cancelPendingMessageSendJobs(threadId)
threadDb.deleteConversation(threadId) storage.deleteConversation(threadId)
return ResultOf.Success(Unit) return ResultOf.Success(Unit)
} }
override suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf<Unit> { override suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf<Unit> {
sessionJobDb.cancelPendingMessageSendJobs(thread.threadId) sessionJobDb.cancelPendingMessageSendJobs(thread.threadId)
threadDb.deleteConversation(thread.threadId) storage.deleteConversation(thread.threadId)
return ResultOf.Success(Unit) return ResultOf.Success(Unit)
} }
override suspend fun clearAllMessageRequests(): ResultOf<Unit> { override suspend fun clearAllMessageRequests(block: Boolean): ResultOf<Unit> {
threadDb.readerFor(threadDb.unapprovedConversationList).use { reader -> threadDb.readerFor(threadDb.unapprovedConversationList).use { reader ->
while (reader.next != null) { while (reader.next != null) {
deleteMessageRequest(reader.current) deleteMessageRequest(reader.current)
val recipient = reader.current.recipient
if (block) {
setBlocked(recipient, true)
}
} }
} }
return ResultOf.Success(Unit) return ResultOf.Success(Unit)
} }
override suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf<Unit> = suspendCoroutine { continuation -> override suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf<Unit> = suspendCoroutine { continuation ->
recipientDb.setApproved(recipient, true) storage.setRecipientApproved(recipient, true)
val message = MessageRequestResponse(true) val message = MessageRequestResponse(true)
MessageSender.send(message, Destination.from(recipient.address)) MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = recipient.isLocalNumber)
.success { .success {
threadDb.setHasSent(threadId, true) threadDb.setHasSent(threadId, true)
continuation.resume(ResultOf.Success(Unit)) continuation.resume(ResultOf.Success(Unit))
@ -283,7 +292,7 @@ class DefaultConversationRepository @Inject constructor(
override fun declineMessageRequest(threadId: Long) { override fun declineMessageRequest(threadId: Long) {
sessionJobDb.cancelPendingMessageSendJobs(threadId) sessionJobDb.cancelPendingMessageSendJobs(threadId)
threadDb.deleteConversation(threadId) storage.deleteConversation(threadId)
} }
override fun hasReceived(threadId: Long): Boolean { override fun hasReceived(threadId: Long): Boolean {

View File

@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.service;
import android.content.Context; import android.content.Context;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.session.libsession.database.StorageProtocol;
import org.session.libsession.messaging.MessagingModuleConfiguration;
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate; import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate;
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.OutgoingExpirationUpdateMessage;
@ -15,6 +17,7 @@ import org.session.libsignal.messages.SignalServiceGroup;
import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.guava.Optional; import org.session.libsignal.utilities.guava.Optional;
import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
@ -35,12 +38,14 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM
private final SmsDatabase smsDatabase; private final SmsDatabase smsDatabase;
private final MmsDatabase mmsDatabase; private final MmsDatabase mmsDatabase;
private final MmsSmsDatabase mmsSmsDatabase;
private final Context context; private final Context context;
public ExpiringMessageManager(Context context) { public ExpiringMessageManager(Context context) {
this.context = context.getApplicationContext(); this.context = context.getApplicationContext();
this.smsDatabase = DatabaseComponent.get(context).smsDatabase(); this.smsDatabase = DatabaseComponent.get(context).smsDatabase();
this.mmsDatabase = DatabaseComponent.get(context).mmsDatabase(); this.mmsDatabase = DatabaseComponent.get(context).mmsDatabase();
this.mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
executor.execute(new LoadTask()); executor.execute(new LoadTask());
executor.execute(new ProcessTask()); executor.execute(new ProcessTask());
@ -79,12 +84,11 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM
} }
if (message.getId() != null) { if (message.getId() != null) {
DatabaseComponent.get(context).smsDatabase().deleteMessage(message.getId()); smsDatabase.deleteMessage(message.getId());
} }
} }
private void insertIncomingExpirationTimerMessage(ExpirationTimerUpdate message) { private void insertIncomingExpirationTimerMessage(ExpirationTimerUpdate message) {
MmsDatabase database = DatabaseComponent.get(context).mmsDatabase();
String senderPublicKey = message.getSender(); String senderPublicKey = message.getSender();
Long sentTimestamp = message.getSentTimestamp(); Long sentTimestamp = message.getSentTimestamp();
@ -106,6 +110,10 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM
Address groupAddress = Address.fromSerialized(groupID); Address groupAddress = Address.fromSerialized(groupID);
recipient = Recipient.from(context, groupAddress, false); recipient = Recipient.from(context, groupAddress, false);
} }
Long threadId = MessagingModuleConfiguration.getShared().getStorage().getThreadId(recipient);
if (threadId == null) {
return;
}
IncomingMediaMessage mediaMessage = new IncomingMediaMessage(address, sentTimestamp, -1, IncomingMediaMessage mediaMessage = new IncomingMediaMessage(address, sentTimestamp, -1,
duration * 1000L, true, duration * 1000L, true,
@ -120,10 +128,10 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM
Optional.absent(), Optional.absent(),
Optional.absent()); Optional.absent());
//insert the timer update message //insert the timer update message
database.insertSecureDecryptedMessageInbox(mediaMessage, -1, true, true); mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, threadId, true);
//set the timer to the conversation //set the timer to the conversation
DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration); MessagingModuleConfiguration.getShared().getStorage().setExpirationTimer(recipient.getAddress().serialize(), duration);
} catch (IOException | MmsException ioe) { } catch (IOException | MmsException ioe) {
Log.e("Loki", "Failed to insert expiration update message."); Log.e("Loki", "Failed to insert expiration update message.");
@ -131,28 +139,30 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM
} }
private void insertOutgoingExpirationTimerMessage(ExpirationTimerUpdate message) { private void insertOutgoingExpirationTimerMessage(ExpirationTimerUpdate message) {
MmsDatabase database = DatabaseComponent.get(context).mmsDatabase();
Long sentTimestamp = message.getSentTimestamp(); Long sentTimestamp = message.getSentTimestamp();
String groupId = message.getGroupPublicKey(); String groupId = message.getGroupPublicKey();
int duration = message.getDuration(); int duration = message.getDuration();
Address address = Address.fromSerialized((message.getSyncTarget() != null && !message.getSyncTarget().isEmpty()) ? message.getSyncTarget() : message.getRecipient()); Address address;
Recipient recipient = Recipient.from(context, address, false);
try { try {
OutgoingExpirationUpdateMessage timerUpdateMessage = new OutgoingExpirationUpdateMessage(recipient, sentTimestamp, duration * 1000L, groupId);
database.insertSecureDecryptedMessageOutbox(timerUpdateMessage, -1, sentTimestamp, true);
if (groupId != null) { if (groupId != null) {
// we need the group ID as recipient for setExpireMessages below address = Address.fromSerialized(GroupUtil.doubleEncodeGroupID(groupId));
recipient = Recipient.from(context, Address.fromSerialized(GroupUtil.doubleEncodeGroupID(groupId)), false); } else {
address = Address.fromSerialized((message.getSyncTarget() != null && !message.getSyncTarget().isEmpty()) ? message.getSyncTarget() : message.getRecipient());
} }
//set the timer to the conversation
DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration);
Recipient recipient = Recipient.from(context, address, false);
StorageProtocol storage = MessagingModuleConfiguration.getShared().getStorage();
message.setThreadID(storage.getOrCreateThreadIdFor(address));
OutgoingExpirationUpdateMessage timerUpdateMessage = new OutgoingExpirationUpdateMessage(recipient, sentTimestamp, duration * 1000L, groupId);
mmsDatabase.insertSecureDecryptedMessageOutbox(timerUpdateMessage, message.getThreadID(), sentTimestamp, true);
//set the timer to the conversation
MessagingModuleConfiguration.getShared().getStorage().setExpirationTimer(recipient.getAddress().serialize(), duration);
} catch (MmsException | IOException ioe) { } catch (MmsException | IOException ioe) {
Log.e("Loki", "Failed to insert expiration update message."); Log.e("Loki", "Failed to insert expiration update message.", ioe);
} }
} }
@ -163,7 +173,7 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM
@Override @Override
public void startAnyExpiration(long timestamp, @NotNull String author) { public void startAnyExpiration(long timestamp, @NotNull String author) {
MessageRecord messageRecord = DatabaseComponent.get(context).mmsSmsDatabase().getMessageFor(timestamp, author); MessageRecord messageRecord = mmsSmsDatabase.getMessageFor(timestamp, author);
if (messageRecord != null) { if (messageRecord != null) {
boolean mms = messageRecord.isMms(); boolean mms = messageRecord.isMms();
Recipient recipient = messageRecord.getRecipient(); Recipient recipient = messageRecord.getRecipient();

View File

@ -1,16 +1,23 @@
package org.thoughtcrime.securesms.sskenvironment package org.thoughtcrime.securesms.sskenvironment
import android.content.Context import android.content.Context
import network.loki.messenger.libsession_util.util.UserPic
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
class ProfileManager : SSKEnvironment.ProfileManagerProtocol { class ProfileManager(private val context: Context, private val configFactory: ConfigFactory) : SSKEnvironment.ProfileManagerProtocol {
override fun setNickname(context: Context, recipient: Recipient, nickname: String?) { override fun setNickname(context: Context, recipient: Recipient, nickname: String?) {
if (recipient.isLocalNumber) return
val sessionID = recipient.address.serialize() val sessionID = recipient.address.serialize()
val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase()
var contact = contactDatabase.getContactWithSessionID(sessionID) var contact = contactDatabase.getContactWithSessionID(sessionID)
@ -20,10 +27,12 @@ class ProfileManager : SSKEnvironment.ProfileManagerProtocol {
contact.nickname = nickname contact.nickname = nickname
contactDatabase.setContact(contact) contactDatabase.setContact(contact)
} }
contactUpdatedInternal(contact)
} }
override fun setName(context: Context, recipient: Recipient, name: String) { override fun setName(context: Context, recipient: Recipient, name: String?) {
// New API // New API
if (recipient.isLocalNumber) return
val sessionID = recipient.address.serialize() val sessionID = recipient.address.serialize()
val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase()
var contact = contactDatabase.getContactWithSessionID(sessionID) var contact = contactDatabase.getContactWithSessionID(sessionID)
@ -37,40 +46,69 @@ class ProfileManager : SSKEnvironment.ProfileManagerProtocol {
val database = DatabaseComponent.get(context).recipientDatabase() val database = DatabaseComponent.get(context).recipientDatabase()
database.setProfileName(recipient, name) database.setProfileName(recipient, name)
recipient.notifyListeners() recipient.notifyListeners()
contactUpdatedInternal(contact)
} }
override fun setProfilePictureURL(context: Context, recipient: Recipient, profilePictureURL: String) { override fun setProfilePicture(
val job = RetrieveProfileAvatarJob(profilePictureURL, recipient.address) context: Context,
JobQueue.shared.add(job) recipient: Recipient,
profilePictureURL: String?,
profileKey: ByteArray?
) {
val hasPendingDownload = DatabaseComponent
.get(context)
.sessionJobDatabase()
.getAllJobs(RetrieveProfileAvatarJob.KEY).any {
(it.value as? RetrieveProfileAvatarJob)?.recipientAddress == recipient.address
}
val resolved = recipient.resolve()
DatabaseComponent.get(context).storage().setProfilePicture(
recipient = resolved,
newProfileKey = profileKey,
newProfilePicture = profilePictureURL
)
val sessionID = recipient.address.serialize() val sessionID = recipient.address.serialize()
val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase()
var contact = contactDatabase.getContactWithSessionID(sessionID) var contact = contactDatabase.getContactWithSessionID(sessionID)
if (contact == null) contact = Contact(sessionID) if (contact == null) contact = Contact(sessionID)
contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address) contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address)
if (contact.profilePictureURL != profilePictureURL) { if (!contact.profilePictureEncryptionKey.contentEquals(profileKey) || contact.profilePictureURL != profilePictureURL) {
contact.profilePictureEncryptionKey = profileKey
contact.profilePictureURL = profilePictureURL contact.profilePictureURL = profilePictureURL
contactDatabase.setContact(contact) contactDatabase.setContact(contact)
} }
contactUpdatedInternal(contact)
if (!hasPendingDownload) {
val job = RetrieveProfileAvatarJob(profilePictureURL, recipient.address)
JobQueue.shared.add(job)
} }
override fun setProfileKey(context: Context, recipient: Recipient, profileKey: ByteArray) {
// New API
val sessionID = recipient.address.serialize()
val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase()
var contact = contactDatabase.getContactWithSessionID(sessionID)
if (contact == null) contact = Contact(sessionID)
contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address)
if (!contact.profilePictureEncryptionKey.contentEquals(profileKey)) {
contact.profilePictureEncryptionKey = profileKey
contactDatabase.setContact(contact)
}
// Old API
val database = DatabaseComponent.get(context).recipientDatabase()
database.setProfileKey(recipient, profileKey)
} }
override fun setUnidentifiedAccessMode(context: Context, recipient: Recipient, unidentifiedAccessMode: Recipient.UnidentifiedAccessMode) { override fun setUnidentifiedAccessMode(context: Context, recipient: Recipient, unidentifiedAccessMode: Recipient.UnidentifiedAccessMode) {
val database = DatabaseComponent.get(context).recipientDatabase() val database = DatabaseComponent.get(context).recipientDatabase()
database.setUnidentifiedAccessMode(recipient, unidentifiedAccessMode) database.setUnidentifiedAccessMode(recipient, unidentifiedAccessMode)
} }
override fun contactUpdatedInternal(contact: Contact): String? {
val contactConfig = configFactory.contacts ?: return null
if (contact.sessionID == TextSecurePreferences.getLocalNumber(context)) return null
val sessionId = SessionId(contact.sessionID)
if (sessionId.prefix != IdPrefix.STANDARD) return null // only internally store standard session IDs
contactConfig.upsertContact(contact.sessionID) {
this.name = contact.name.orEmpty()
this.nickname = contact.nickname.orEmpty()
val url = contact.profilePictureURL
val key = contact.profilePictureEncryptionKey
if (!url.isNullOrEmpty() && key != null && key.size == 32) {
this.profilePicture = UserPic(url, key)
} else if (url.isNullOrEmpty() && key == null) {
this.profilePicture = UserPic.DEFAULT
}
}
if (contactConfig.needsPush()) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
return contactConfig.get(contact.sessionID)?.hashCode()?.toString()
}
} }

View File

@ -1,12 +1,10 @@
package org.thoughtcrime.securesms.util package org.thoughtcrime.securesms.util
import android.app.Notification import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.os.Build
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
@ -32,15 +30,7 @@ class CallNotificationBuilder {
@JvmStatic @JvmStatic
fun areNotificationsEnabled(context: Context): Boolean { fun areNotificationsEnabled(context: Context): Boolean {
val notificationManager = NotificationManagerCompat.from(context) val notificationManager = NotificationManagerCompat.from(context)
return when { return notificationManager.areNotificationsEnabled()
!notificationManager.areNotificationsEnabled() -> false
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> {
notificationManager.notificationChannels.firstOrNull { channel ->
channel.importance == NotificationManager.IMPORTANCE_NONE
} == null
}
else -> true
}
} }
@JvmStatic @JvmStatic

View File

@ -1,18 +1,66 @@
package org.thoughtcrime.securesms.util package org.thoughtcrime.securesms.util
import android.content.Context import android.content.Context
import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.Contacts
import network.loki.messenger.libsession_util.ConversationVolatileConfig
import network.loki.messenger.libsession_util.UserGroupsConfig
import network.loki.messenger.libsession_util.UserProfile
import network.loki.messenger.libsession_util.util.BaseCommunityInfo
import network.loki.messenger.libsession_util.util.Contact
import network.loki.messenger.libsession_util.util.ExpiryMode
import network.loki.messenger.libsession_util.util.GroupInfo
import network.loki.messenger.libsession_util.util.UserPic
import nl.komponents.kovenant.Promise import nl.komponents.kovenant.Promise
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.ConfigurationSyncJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.ConfigurationMessage
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.GroupUtil
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.WindowDebouncer
import org.session.libsignal.crypto.ecc.DjbECPublicKey
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import java.util.Timer
object ConfigurationMessageUtilities { object ConfigurationMessageUtilities {
private val debouncer = WindowDebouncer(3000, Timer())
private fun scheduleConfigSync(userPublicKey: String) {
debouncer.publish {
// don't schedule job if we already have one
val storage = MessagingModuleConfiguration.shared.storage
val ourDestination = Destination.Contact(userPublicKey)
val currentStorageJob = storage.getConfigSyncJob(ourDestination)
if (currentStorageJob != null) {
(currentStorageJob as ConfigurationSyncJob).shouldRunAgain.set(true)
return@publish
}
val newConfigSync = ConfigurationSyncJob(ourDestination)
JobQueue.shared.add(newConfigSync)
}
}
@JvmStatic @JvmStatic
fun syncConfigurationIfNeeded(context: Context) { fun syncConfigurationIfNeeded(context: Context) {
// add if check here to schedule new config job process and return early
val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return
val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context)
val currentTime = SnodeAPI.nowWithOffset
if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) {
scheduleConfigSync(userPublicKey)
return
}
val lastSyncTime = TextSecurePreferences.getLastConfigurationSyncTime(context) val lastSyncTime = TextSecurePreferences.getLastConfigurationSyncTime(context)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (now - lastSyncTime < 7 * 24 * 60 * 60 * 1000) return if (now - lastSyncTime < 7 * 24 * 60 * 60 * 1000) return
@ -35,7 +83,16 @@ object ConfigurationMessageUtilities {
} }
fun forceSyncConfigurationNowIfNeeded(context: Context): Promise<Unit, Exception> { fun forceSyncConfigurationNowIfNeeded(context: Context): Promise<Unit, Exception> {
val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofSuccess(Unit) // add if check here to schedule new config job process and return early
val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofFail(NullPointerException("User Public Key is null"))
val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context)
val currentTime = SnodeAPI.nowWithOffset
if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) {
// schedule job if none exist
// don't schedule job if we already have one
scheduleConfigSync(userPublicKey)
return Promise.ofSuccess(Unit)
}
val contacts = ContactUtilities.getAllContacts(context).filter { recipient -> val contacts = ContactUtilities.getAllContacts(context).filter { recipient ->
!recipient.isGroupRecipient && !recipient.name.isNullOrEmpty() && !recipient.isLocalNumber && recipient.address.serialize().isNotEmpty() !recipient.isGroupRecipient && !recipient.name.isNullOrEmpty() && !recipient.isLocalNumber && recipient.address.serialize().isNotEmpty()
}.map { recipient -> }.map { recipient ->
@ -50,9 +107,179 @@ object ConfigurationMessageUtilities {
) )
} }
val configurationMessage = ConfigurationMessage.getCurrent(contacts) ?: return Promise.ofSuccess(Unit) val configurationMessage = ConfigurationMessage.getCurrent(contacts) ?: return Promise.ofSuccess(Unit)
val promise = MessageSender.send(configurationMessage, Destination.from(Address.fromSerialized(userPublicKey))) val promise = MessageSender.send(configurationMessage, Destination.from(Address.fromSerialized(userPublicKey)), isSyncMessage = true)
TextSecurePreferences.setLastConfigurationSyncTime(context, System.currentTimeMillis()) TextSecurePreferences.setLastConfigurationSyncTime(context, System.currentTimeMillis())
return promise return promise
} }
private fun maybeUserSecretKey() = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes
fun generateUserProfileConfigDump(): ByteArray? {
val storage = MessagingModuleConfiguration.shared.storage
val ownPublicKey = storage.getUserPublicKey() ?: return null
val config = ConfigurationMessage.getCurrent(listOf()) ?: return null
val secretKey = maybeUserSecretKey() ?: return null
val profile = UserProfile.newInstance(secretKey)
profile.setName(config.displayName)
val picUrl = config.profilePicture
val picKey = config.profileKey
if (!picUrl.isNullOrEmpty() && picKey.isNotEmpty()) {
profile.setPic(UserPic(picUrl, picKey))
}
val ownThreadId = storage.getThreadId(Address.fromSerialized(ownPublicKey))
profile.setNtsPriority(
if (ownThreadId != null)
if (storage.isPinned(ownThreadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
else ConfigBase.PRIORITY_HIDDEN
)
val dump = profile.dump()
profile.free()
return dump
}
fun generateContactConfigDump(): ByteArray? {
val secretKey = maybeUserSecretKey() ?: return null
val storage = MessagingModuleConfiguration.shared.storage
val localUserKey = storage.getUserPublicKey() ?: return null
val contactsWithSettings = storage.getAllContacts().filter { recipient ->
recipient.sessionID != localUserKey && recipient.sessionID.startsWith(IdPrefix.STANDARD.value)
&& storage.getThreadId(recipient.sessionID) != null
}.map { contact ->
val address = Address.fromSerialized(contact.sessionID)
val thread = storage.getThreadId(address)
val isPinned = if (thread != null) {
storage.isPinned(thread)
} else false
Triple(contact, storage.getRecipientSettings(address)!!, isPinned)
}
val contactConfig = Contacts.newInstance(secretKey)
for ((contact, settings, isPinned) in contactsWithSettings) {
val url = contact.profilePictureURL
val key = contact.profilePictureEncryptionKey
val userPic = if (url.isNullOrEmpty() || key?.isNotEmpty() != true) {
null
} else {
UserPic(url, key)
}
val contactInfo = Contact(
id = contact.sessionID,
name = contact.name.orEmpty(),
nickname = contact.nickname.orEmpty(),
blocked = settings.isBlocked,
approved = settings.isApproved,
approvedMe = settings.hasApprovedMe(),
profilePicture = userPic ?: UserPic.DEFAULT,
priority = if (isPinned) 1 else 0,
expiryMode = if (settings.expireMessages == 0) ExpiryMode.NONE else ExpiryMode.AfterRead(settings.expireMessages.toLong())
)
contactConfig.set(contactInfo)
}
val dump = contactConfig.dump()
contactConfig.free()
if (dump.isEmpty()) return null
return dump
}
fun generateConversationVolatileDump(context: Context): ByteArray? {
val secretKey = maybeUserSecretKey() ?: return null
val storage = MessagingModuleConfiguration.shared.storage
val convoConfig = ConversationVolatileConfig.newInstance(secretKey)
val threadDb = DatabaseComponent.get(context).threadDatabase()
threadDb.approvedConversationList.use { cursor ->
val reader = threadDb.readerFor(cursor)
var current = reader.next
while (current != null) {
val recipient = current.recipient
val contact = when {
recipient.isOpenGroupRecipient -> {
val openGroup = storage.getOpenGroup(current.threadId) ?: continue
val (base, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: continue
convoConfig.getOrConstructCommunity(base, room, pubKey)
}
recipient.isClosedGroupRecipient -> {
val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize())
convoConfig.getOrConstructLegacyGroup(groupPublicKey)
}
recipient.isContactRecipient -> {
if (recipient.isLocalNumber) null // this is handled by the user profile NTS data
else if (recipient.isOpenGroupInboxRecipient) null // specifically exclude
else if (!recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) null
else convoConfig.getOrConstructOneToOne(recipient.address.serialize())
}
else -> null
}
if (contact == null) {
current = reader.next
continue
}
contact.lastRead = current.lastSeen
contact.unread = false
convoConfig.set(contact)
current = reader.next
}
}
val dump = convoConfig.dump()
convoConfig.free()
if (dump.isEmpty()) return null
return dump
}
fun generateUserGroupDump(context: Context): ByteArray? {
val secretKey = maybeUserSecretKey() ?: return null
val storage = MessagingModuleConfiguration.shared.storage
val groupConfig = UserGroupsConfig.newInstance(secretKey)
val allOpenGroups = storage.getAllOpenGroups().values.mapNotNull { openGroup ->
val (baseUrl, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return@mapNotNull null
val pubKeyHex = Hex.toStringCondensed(pubKey)
val baseInfo = BaseCommunityInfo(baseUrl, room, pubKeyHex)
val threadId = storage.getThreadId(openGroup) ?: return@mapNotNull null
val isPinned = storage.isPinned(threadId)
GroupInfo.CommunityGroupInfo(baseInfo, if (isPinned) 1 else 0)
}
val allLgc = storage.getAllGroups(includeInactive = false).filter {
it.isClosedGroup && it.isActive && it.members.size > 1
}.mapNotNull { group ->
val groupAddress = Address.fromSerialized(group.encodedId)
val groupPublicKey = GroupUtil.doubleDecodeGroupID(groupAddress.serialize()).toHexString()
val recipient = storage.getRecipientSettings(groupAddress) ?: return@mapNotNull null
val encryptionKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: return@mapNotNull null
val threadId = storage.getThreadId(group.encodedId)
val isPinned = threadId?.let { storage.isPinned(threadId) } ?: false
val admins = group.admins.map { it.serialize() to true }.toMap()
val members = group.members.filterNot { it.serialize() !in admins.keys }.map { it.serialize() to false }.toMap()
GroupInfo.LegacyGroupInfo(
sessionId = groupPublicKey,
name = group.title,
members = admins + members,
priority = if (isPinned) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE,
encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = encryptionKeyPair.privateKey.serialize(),
disappearingTimer = recipient.expireMessages.toLong(),
joinedAt = (group.formationTimestamp / 1000L)
)
}
(allOpenGroups + allLgc).forEach { groupInfo ->
groupConfig.set(groupInfo)
}
val dump = groupConfig.dump()
groupConfig.free()
if (dump.isEmpty()) return null
return dump
}
@JvmField
val DELETE_INACTIVE_GROUPS: String = """
DELETE FROM ${GroupDatabase.TABLE_NAME} WHERE ${GroupDatabase.GROUP_ID} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%');
DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.ADDRESS} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%');
""".trimIndent()
@JvmField
val DELETE_INACTIVE_ONE_TO_ONES: String = """
DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_INBOX_PREFIX}%';
""".trimIndent()
} }

View File

@ -67,7 +67,8 @@ public class DateUtils extends android.text.format.DateUtils {
} }
public static String getDisplayFormattedTimeSpanString(final Context c, final Locale locale, final long timestamp) { public static String getDisplayFormattedTimeSpanString(final Context c, final Locale locale, final long timestamp) {
if (isWithin(timestamp, 1, TimeUnit.MINUTES)) { // If the timestamp is invalid (ie. 0) then assume we're waiting on data and just use the 'Now' copy
if (timestamp == 0 || isWithin(timestamp, 1, TimeUnit.MINUTES)) {
return c.getString(R.string.DateUtils_just_now); return c.getString(R.string.DateUtils_just_now);
} else if (isToday(timestamp)) { } else if (isToday(timestamp)) {
return getFormattedDateTime(timestamp, getHourFormat(c), locale); return getFormattedDateTime(timestamp, getHourFormat(c), locale);

View File

@ -7,6 +7,7 @@ import android.graphics.Canvas
import android.graphics.Paint import android.graphics.Paint
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.RelativeLayout import android.widget.RelativeLayout
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
@ -55,16 +56,21 @@ object GlowViewUtilities {
animation.start() animation.start()
} }
fun animateShadowColorChange(view: GlowView, @ColorInt startColor: Int, @ColorInt endColor: Int) { fun animateShadowColorChange(
view: GlowView,
@ColorInt startColor: Int,
@ColorInt endColor: Int,
duration: Long = 250
) {
val animation = ValueAnimator.ofObject(ArgbEvaluator(), startColor, endColor) val animation = ValueAnimator.ofObject(ArgbEvaluator(), startColor, endColor)
animation.duration = 250 animation.duration = duration
animation.interpolator = AccelerateDecelerateInterpolator()
animation.addUpdateListener { animator -> animation.addUpdateListener { animator ->
val color = animator.animatedValue as Int val color = animator.animatedValue as Int
view.sessionShadowColor = color view.sessionShadowColor = color
} }
animation.start() animation.start()
} }
} }
class PNModeView : LinearLayout, GlowView { class PNModeView : LinearLayout, GlowView {
@ -223,3 +229,59 @@ class InputBarButtonImageViewContainer : RelativeLayout, GlowView {
} }
// endregion // endregion
} }
class MessageBubbleView : androidx.constraintlayout.widget.ConstraintLayout, GlowView {
@ColorInt override var mainColor: Int = 0
set(newValue) { field = newValue; paint.color = newValue }
@ColorInt override var sessionShadowColor: Int = 0
set(newValue) {
field = newValue
shadowPaint.setShadowLayer(toPx(10, resources).toFloat(), 0.0f, 0.0f, newValue)
if (numShadowRenders == 0) {
numShadowRenders = 1
}
invalidate()
}
var cornerRadius: Float = 0f
var numShadowRenders: Int = 0
private val paint: Paint by lazy {
val result = Paint()
result.style = Paint.Style.FILL
result.isAntiAlias = true
result
}
private val shadowPaint: Paint by lazy {
val result = Paint()
result.style = Paint.Style.FILL
result.isAntiAlias = true
result
}
// region Lifecycle
constructor(context: Context) : super(context) { }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { }
init {
setWillNotDraw(false)
}
// endregion
// region Updating
override fun onDraw(c: Canvas) {
val w = width.toFloat()
val h = height.toFloat()
(0 until numShadowRenders).forEach {
c.drawRoundRect(0f, 0f, w, h, cornerRadius, cornerRadius, shadowPaint)
}
c.drawRoundRect(0f, 0f, w, h, cornerRadius, cornerRadius, paint)
super.onDraw(c)
}
// endregion
}

View File

@ -7,8 +7,6 @@ import org.session.libsession.messaging.messages.signal.IncomingTextMessage
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.utilities.Address 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
@ -21,7 +19,6 @@ import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.GroupManager import org.thoughtcrime.securesms.groups.GroupManager
import java.security.SecureRandom import java.security.SecureRandom
import java.util.*
import kotlin.random.asKotlinRandom import kotlin.random.asKotlinRandom
object MockDataGenerator { object MockDataGenerator {
@ -139,7 +136,6 @@ object MockDataGenerator {
false false
), ),
(timestampNow - (index * 5000)), (timestampNow - (index * 5000)),
false,
false false
) )
} }
@ -235,8 +231,9 @@ object MockDataGenerator {
// Add the group to the user's set of public keys to poll for and store the key pair // Add the group to the user's set of public keys to poll for and store the key pair
val encryptionKeyPair = Curve.generateKeyPair() val encryptionKeyPair = Curve.generateKeyPair()
storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, randomGroupPublicKey) storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, randomGroupPublicKey, System.currentTimeMillis())
storage.setExpirationTimer(groupId, 0) storage.setExpirationTimer(groupId, 0)
storage.createInitialConfigGroup(randomGroupPublicKey, groupName, GroupUtil.createConfigMemberMap(members, setOf(adminUserId)), System.currentTimeMillis(), encryptionKeyPair)
// Add the group created message // Add the group created message
if (userSessionId == adminUserId) { if (userSessionId == adminUserId) {
@ -269,7 +266,6 @@ object MockDataGenerator {
false false
), ),
(timestampNow - (index * 5000)), (timestampNow - (index * 5000)),
false,
false false
) )
} }
@ -395,7 +391,6 @@ object MockDataGenerator {
false false
), ),
(timestampNow - (index * 5000)), (timestampNow - (index * 5000)),
false,
false false
) )
} else { } else {

View File

@ -49,11 +49,11 @@ object SessionMetaProtocol {
@JvmStatic @JvmStatic
fun shouldSendReadReceipt(recipient: Recipient): Boolean { fun shouldSendReadReceipt(recipient: Recipient): Boolean {
return !recipient.isGroupRecipient && recipient.isApproved return !recipient.isGroupRecipient && recipient.isApproved && !recipient.isBlocked
} }
@JvmStatic @JvmStatic
fun shouldSendTypingIndicator(recipient: Recipient): Boolean { fun shouldSendTypingIndicator(recipient: Recipient): Boolean {
return !recipient.isGroupRecipient && recipient.isApproved return !recipient.isGroupRecipient && recipient.isApproved && !recipient.isBlocked
} }
} }

View File

@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.util
import network.loki.messenger.libsession_util.ConversationVolatileConfig
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.utilities.GroupUtil
import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.database.model.ThreadRecord
fun ConversationVolatileConfig.getConversationUnread(thread: ThreadRecord): Boolean {
val recipient = thread.recipient
if (recipient.isContactRecipient
&& recipient.isOpenGroupInboxRecipient
&& recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) {
return getOneToOne(recipient.address.serialize())?.unread == true
} else if (recipient.isClosedGroupRecipient) {
return getLegacyClosedGroup(GroupUtil.doubleDecodeGroupId(recipient.address.toGroupString()))?.unread == true
} else if (recipient.isOpenGroupRecipient) {
val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(thread.threadId) ?: return false
return getCommunity(openGroup.server, openGroup.room)?.unread == true
}
return false
}

View File

@ -58,7 +58,7 @@ fun View.fadeIn(duration: Long = 150) {
fun View.fadeOut(duration: Long = 150) { fun View.fadeOut(duration: Long = 150) {
animate().setDuration(duration).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { animate().setDuration(duration).alpha(0.0f).setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) { override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation) super.onAnimationEnd(animation)
visibility = View.GONE visibility = View.GONE
} }

View File

@ -303,7 +303,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
sdpMLineIndexes = sdpMLineIndexes, sdpMLineIndexes = sdpMLineIndexes,
sdpMids = sdpMids, sdpMids = sdpMids,
currentCallId currentCallId
), currentRecipient.address) ), currentRecipient.address, isSyncMessage = currentRecipient.isLocalNumber)
} }
} }
} }
@ -437,7 +437,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
pendingIncomingIceUpdates.clear() pendingIncomingIceUpdates.clear()
val answerMessage = CallMessage.answer(answer.description, callId) val answerMessage = CallMessage.answer(answer.description, callId)
Log.i("Loki", "Posting new answer") Log.i("Loki", "Posting new answer")
MessageSender.sendNonDurably(answerMessage, recipient.address) MessageSender.sendNonDurably(answerMessage, recipient.address, isSyncMessage = recipient.isLocalNumber)
} else { } else {
Promise.ofFail(Exception("Couldn't reconnect from current state")) Promise.ofFail(Exception("Couldn't reconnect from current state"))
} }
@ -481,11 +481,11 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
connection.setLocalDescription(answer) connection.setLocalDescription(answer)
val answerMessage = CallMessage.answer(answer.description, callId) val answerMessage = CallMessage.answer(answer.description, callId)
val userAddress = storage.getUserPublicKey() ?: return Promise.ofFail(NullPointerException("No user public key")) val userAddress = storage.getUserPublicKey() ?: return Promise.ofFail(NullPointerException("No user public key"))
MessageSender.sendNonDurably(answerMessage, Address.fromSerialized(userAddress)) MessageSender.sendNonDurably(answerMessage, Address.fromSerialized(userAddress), isSyncMessage = true)
val sendAnswerMessage = MessageSender.sendNonDurably(CallMessage.answer( val sendAnswerMessage = MessageSender.sendNonDurably(CallMessage.answer(
answer.description, answer.description,
callId callId
), recipient.address) ), recipient.address, isSyncMessage = recipient.isLocalNumber)
insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_INCOMING, false) insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_INCOMING, false)
@ -535,13 +535,13 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
Log.d("Loki", "Sending pre-offer") Log.d("Loki", "Sending pre-offer")
return MessageSender.sendNonDurably(CallMessage.preOffer( return MessageSender.sendNonDurably(CallMessage.preOffer(
callId callId
), recipient.address).bind { ), recipient.address, isSyncMessage = recipient.isLocalNumber).bind {
Log.d("Loki", "Sent pre-offer") Log.d("Loki", "Sent pre-offer")
Log.d("Loki", "Sending offer") Log.d("Loki", "Sending offer")
MessageSender.sendNonDurably(CallMessage.offer( MessageSender.sendNonDurably(CallMessage.offer(
offer.description, offer.description,
callId callId
), recipient.address).success { ), recipient.address, isSyncMessage = recipient.isLocalNumber).success {
Log.d("Loki", "Sent offer") Log.d("Loki", "Sent offer")
}.fail { }.fail {
Log.e("Loki", "Failed to send offer", it) Log.e("Loki", "Failed to send offer", it)
@ -555,8 +555,8 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
val recipient = recipient ?: return val recipient = recipient ?: return
val userAddress = storage.getUserPublicKey() ?: return val userAddress = storage.getUserPublicKey() ?: return
stateProcessor.processEvent(Event.DeclineCall) { stateProcessor.processEvent(Event.DeclineCall) {
MessageSender.sendNonDurably(CallMessage.endCall(callId), Address.fromSerialized(userAddress)) MessageSender.sendNonDurably(CallMessage.endCall(callId), Address.fromSerialized(userAddress), isSyncMessage = true)
MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address) MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address, isSyncMessage = recipient.isLocalNumber)
insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_MISSED) insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_MISSED)
} }
} }
@ -575,7 +575,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
val buffer = DataChannel.Buffer(ByteBuffer.wrap(HANGUP_JSON.toString().encodeToByteArray()), false) val buffer = DataChannel.Buffer(ByteBuffer.wrap(HANGUP_JSON.toString().encodeToByteArray()), false)
channel.send(buffer) channel.send(buffer)
} }
MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address) MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address, isSyncMessage = recipient.isLocalNumber)
} }
} }
@ -726,7 +726,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va
}) })
connection.setLocalDescription(offer) connection.setLocalDescription(offer)
MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId), recipient.address) MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId), recipient.address, isSyncMessage = recipient.isLocalNumber)
} }
} }

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android"> <selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="false" android:color="@color/grey"/> <item android:state_enabled="false" android:color="@color/gray50"/>
<item android:color="?prominentButtonColor"/> <item android:color="?prominentButtonColor"/>
</selector> </selector>

View File

@ -216,6 +216,19 @@
</RelativeLayout> </RelativeLayout>
<TextView
android:padding="@dimen/medium_spacing"
android:textSize="@dimen/small_font_size"
android:textColor="?android:textColorSecondary"
android:textAlignment="center"
android:id="@+id/placeholderText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/blockedBanner"
android:elevation="8dp"
tools:text="@string/activity_conversation_empty_state_default"
/>
<LinearLayout <LinearLayout
android:id="@+id/messageRequestBar" android:id="@+id/messageRequestBar"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@ -27,8 +27,9 @@
android:layout_marginLeft="20dp" android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"> android:layout_marginRight="20dp">
<include layout="@layout/view_profile_picture" <include
android:id="@+id/profileButton" android:id="@+id/profileButton"
layout="@layout/view_profile_picture"
android:layout_width="@dimen/small_profile_picture_size" android:layout_width="@dimen/small_profile_picture_size"
android:layout_height="@dimen/small_profile_picture_size" android:layout_height="@dimen/small_profile_picture_size"
android:layout_alignParentLeft="true" android:layout_alignParentLeft="true"
@ -38,10 +39,10 @@
<org.thoughtcrime.securesms.home.PathStatusView <org.thoughtcrime.securesms.home.PathStatusView
android:id="@+id/pathStatusView" android:id="@+id/pathStatusView"
android:layout_alignBottom="@+id/profileButton"
android:layout_alignEnd="@+id/profileButton"
android:layout_width="@dimen/path_status_view_size" android:layout_width="@dimen/path_status_view_size"
android:layout_height="@dimen/path_status_view_size"/> android:layout_height="@dimen/path_status_view_size"
android:layout_alignEnd="@+id/profileButton"
android:layout_alignBottom="@+id/profileButton" />
<ImageView <ImageView
android:id="@+id/sessionHeaderImage" android:id="@+id/sessionHeaderImage"
@ -75,16 +76,17 @@
</RelativeLayout> </RelativeLayout>
<RelativeLayout <RelativeLayout
android:visibility="gone"
android:id="@+id/search_toolbar" android:id="@+id/search_toolbar"
android:layout_marginHorizontal="@dimen/medium_spacing"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?actionBarSize"> android:layout_height="?actionBarSize"
android:layout_marginHorizontal="@dimen/medium_spacing"
android:visibility="gone">
<org.thoughtcrime.securesms.home.search.GlobalSearchInputLayout <org.thoughtcrime.securesms.home.search.GlobalSearchInputLayout
android:layout_centerVertical="true"
android:id="@+id/globalSearchInputLayout" android:id="@+id/globalSearchInputLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"/> android:layout_height="wrap_content"
android:layout_centerVertical="true" />
</RelativeLayout> </RelativeLayout>
<View <View
@ -94,19 +96,48 @@
android:elevation="1dp" /> android:elevation="1dp" />
<org.thoughtcrime.securesms.onboarding.SeedReminderView <org.thoughtcrime.securesms.onboarding.SeedReminderView
tools:visibility="gone"
android:id="@+id/seedReminderView" android:id="@+id/seedReminderView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />
<FrameLayout
tools:visibility="visible"
android:visibility="gone"
android:id="@+id/configOutdatedView"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_gravity="center"
android:textColor="?message_sent_text_color"
android:background="?colorAccent"
android:textSize="9sp"
android:paddingVertical="4dp"
android:paddingHorizontal="64dp"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/activity_home_outdated_client_config"
/>
<ImageView
android:layout_margin="@dimen/small_spacing"
android:layout_gravity="center_vertical|right"
android:layout_width="12dp"
android:layout_height="12dp"
android:scaleType="centerInside"
android:src="@drawable/ic_x"
app:tint="@color/black" />
</FrameLayout>
</LinearLayout> </LinearLayout>
</androidx.appcompat.widget.Toolbar> </androidx.appcompat.widget.Toolbar>
<RelativeLayout <RelativeLayout
android:focusable="false"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:clipChildren="false"> android:clipChildren="false"
android:focusable="false">
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView" android:id="@+id/recyclerView"
@ -119,14 +150,14 @@
tools:listitem="@layout/view_conversation" /> tools:listitem="@layout/view_conversation" />
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:visibility="gone"
android:scrollbars="vertical"
android:id="@+id/globalSearchRecycler" android:id="@+id/globalSearchRecycler"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
android:visibility="gone"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/view_global_search_result"
tools:itemCount="6" tools:itemCount="6"
android:layout_height="match_parent"/> tools:listitem="@layout/view_global_search_result" />
<LinearLayout <LinearLayout
android:id="@+id/emptyStateContainer" android:id="@+id/emptyStateContainer"

View File

@ -52,6 +52,7 @@
android:text="Spiderman" /> android:text="Spiderman" />
<ImageView <ImageView
android:id="@+id/nameEditIcon"
android:layout_width="20dp" android:layout_width="20dp"
android:layout_height="22dp" android:layout_height="22dp"
android:contentDescription="@string/AccessibilityId_edit_user_nickname" android:contentDescription="@string/AccessibilityId_edit_user_nickname"

View File

@ -16,6 +16,7 @@
android:layout_height="@dimen/small_profile_picture_size" android:layout_height="@dimen/small_profile_picture_size"
android:layout_alignParentLeft="true" android:layout_alignParentLeft="true"
android:layout_alignParentTop="true" android:layout_alignParentTop="true"
android:scaleType="centerCrop"
android:background="@drawable/profile_picture_view_small_background" /> android:background="@drawable/profile_picture_view_small_background" />
<ImageView <ImageView
@ -24,11 +25,13 @@
android:layout_height="@dimen/small_profile_picture_size" android:layout_height="@dimen/small_profile_picture_size"
android:layout_alignParentRight="true" android:layout_alignParentRight="true"
android:layout_alignParentBottom="true" android:layout_alignParentBottom="true"
android:scaleType="centerCrop"
android:background="@drawable/profile_picture_view_small_background" /> android:background="@drawable/profile_picture_view_small_background" />
</RelativeLayout> </RelativeLayout>
<ImageView <ImageView
android:scaleType="centerCrop"
android:id="@+id/singleModeImageView" android:id="@+id/singleModeImageView"
android:layout_width="@dimen/medium_profile_picture_size" android:layout_width="@dimen/medium_profile_picture_size"
android:layout_height="@dimen/medium_profile_picture_size" android:layout_height="@dimen/medium_profile_picture_size"
@ -36,6 +39,7 @@
<ImageView <ImageView
android:id="@+id/largeSingleModeImageView" android:id="@+id/largeSingleModeImageView"
android:scaleType="centerCrop"
android:layout_width="@dimen/large_profile_picture_size" android:layout_width="@dimen/large_profile_picture_size"
android:layout_height="@dimen/large_profile_picture_size" android:layout_height="@dimen/large_profile_picture_size"
android:background="@drawable/profile_picture_view_large_background" /> android:background="@drawable/profile_picture_view_large_background" />

View File

@ -8,6 +8,46 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical"> android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/unreadMarkerContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/small_spacing"
android:visibility="gone"
tools:visibility="visible">
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginStart="@dimen/medium_spacing"
android:layout_marginEnd="@dimen/small_spacing"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/unreadMarker"
android:background="?android:colorAccent" />
<TextView
android:id="@+id/unreadMarker"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:text="@string/unread_marker"
android:gravity="center"
android:textColor="?android:colorAccent"
android:textSize="@dimen/small_font_size"
android:textStyle="bold" />
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginStart="@dimen/small_spacing"
android:layout_marginEnd="@dimen/medium_spacing"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/unreadMarker"
app:layout_constraintEnd_toEndOf="parent"
android:background="?android:colorAccent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView <TextView
android:id="@+id/dateBreakTextView" android:id="@+id/dateBreakTextView"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@ -7,7 +7,7 @@
android:id="@+id/mainContainerConstraint" android:id="@+id/mainContainerConstraint"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.constraintlayout.widget.ConstraintLayout <org.thoughtcrime.securesms.util.MessageBubbleView
android:id="@+id/contentParent" android:id="@+id/contentParent"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -111,7 +111,7 @@
android:id="@+id/bodyTextView" android:id="@+id/bodyTextView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content"/> android:layout_height="wrap_content"/>
</androidx.constraintlayout.widget.ConstraintLayout> </org.thoughtcrime.securesms.util.MessageBubbleView>
<include layout="@layout/album_thumbnail_view" <include layout="@layout/album_thumbnail_view"
android:visibility="gone" android:visibility="gone"

View File

@ -1020,4 +1020,12 @@
<string name="delivery_status_failed">Failed to send</string> <string name="delivery_status_failed">Failed to send</string>
<string name="giphy_permission_title">Search GIFs?</string> <string name="giphy_permission_title">Search GIFs?</string>
<string name="giphy_permission_message">Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.</string> <string name="giphy_permission_message">Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.</string>
<string name="activity_home_outdated_client_config">Some of your devices are using outdated versions. Syncing may be unreliable until they are updated.</string>
<string name="activity_conversation_empty_state_read_only">There are no messages in <b>%s</b>.</string>
<string name="activity_conversation_empty_state_note_to_self">You have no messages in Note to Self.</string>
<string name="activity_conversation_empty_state_default">You have no messages from <b>%s</b>.\nSend a message to start the conversation!</string>
<string name="unread_marker">Unread Messages</string>
</resources> </resources>

View File

@ -642,4 +642,84 @@
<item name="message_selected">@color/ocean_light_5</item> <item name="message_selected">@color/ocean_light_5</item>
</style> </style>
<!-- For testing / XML rendering -->
<style name="Theme.Session.DayNight.NoActionBar.Test" parent="Base.Theme.Session">
<item name="dividerVertical">?android:textColorTertiary</item>
<item name="dividerHorizontal">?dividerVertical</item>
<item name="colorAccent">@color/classic_accent</item>
<item name="sessionLogoTint">@color/classic_dark_6</item>
<item name="colorPrimary">@color/classic_dark_0</item>
<item name="colorPrimaryDark">@color/classic_dark_0</item>
<item name="colorControlNormal">?android:textColorPrimary</item>
<item name="colorControlActivated">?colorAccent</item>
<item name="android:colorControlHighlight">?colorAccent</item>
<item name="android:textColorPrimary">@color/classic_dark_6</item>
<item name="android:textColorSecondary">?android:textColorPrimary</item>
<item name="android:textColorTertiary">@color/classic_dark_5</item>
<item name="android:textColor">?android:textColorPrimary</item>
<item name="android:textColorHint">@color/gray27</item>
<item name="android:windowBackground">?colorPrimary</item>
<item name="android:navigationBarColor">@color/compose_view_background</item>
<item name="dialog_background_color">@color/classic_dark_1</item>
<item name="bottomSheetDialogTheme">@style/Classic.Dark.BottomSheet</item>
<item name="actionMenuTextColor">?android:textColorPrimary</item>
<item name="popupTheme">?actionBarPopupTheme</item>
<item name="colorCellBackground">@color/classic_dark_1</item>
<item name="colorSettingsBackground">@color/classic_dark_1</item>
<item name="colorDividerBackground">@color/classic_dark_3</item>
<item name="actionBarPopupTheme">@style/Dark.Popup</item>
<item name="actionBarWidgetTheme">@null</item>
<item name="actionBarTheme">@style/ThemeOverlay.AppCompat.Dark.ActionBar</item>
<item name="actionBarStyle">@style/Widget.Session.ActionBar</item>
<item name="prominentButtonColor">?colorAccent</item>
<item name="elementBorderColor">@color/classic_dark_3</item>
<!-- Home screen -->
<item name="searchBackgroundColor">#1B1B1B</item>
<item name="searchIconColor">#E5E5E8</item>
<item name="searchHintColor">@color/classic_dark_5</item>
<item name="searchTextColor">?android:textColorPrimary</item>
<item name="searchHighlightTint">?colorAccent</item>
<item name="home_gradient_start">#00000000</item>
<item name="home_gradient_end">@color/classic_dark_1</item>
<item name="conversation_pinned_background_color">?colorCellBackground</item>
<item name="conversation_unread_background_color">@color/classic_dark_2</item>
<item name="conversation_pinned_icon_color">?android:textColorSecondary</item>
<item name="unreadIndicatorBackgroundColor">@color/classic_dark_3</item>
<item name="unreadIndicatorTextColor">@color/classic_dark_6</item>
<!-- New conversation button -->
<item name="conversation_color_non_main">@color/classic_dark_2</item>
<item name="conversation_shadow_non_main">@color/transparent_black_30</item>
<item name="conversation_shadow_main">?colorAccent</item>
<item name="conversation_menu_background_color">@color/classic_dark_1</item>
<item name="conversation_menu_cell_color">?conversation_menu_background_color</item>
<item name="conversation_menu_border_color">@color/classic_dark_3</item>
<item name="conversationMenuSearchBackgroundColor">@color/classic_dark_0</item>
<!-- Conversation -->
<item name="message_received_background_color">@color/classic_dark_3</item>
<item name="message_received_text_color">@color/classic_dark_6</item>
<item name="message_sent_background_color">?colorAccent</item>
<item name="message_sent_text_color">@color/classic_dark_0</item>
<item name="message_status_color">@color/classic_dark_5</item>
<item name="input_bar_background">@color/classic_dark_1</item>
<item name="input_bar_text_hint">@color/classic_dark_5</item>
<item name="input_bar_text_user">@color/classic_dark_6</item>
<item name="input_bar_border">@color/classic_dark_3</item>
<item name="input_bar_button_background">@color/classic_dark_2</item>
<item name="input_bar_button_text_color">@color/classic_dark_6</item>
<item name="input_bar_button_background_opaque">@color/classic_dark_2</item>
<item name="input_bar_button_background_opaque_border">@color/classic_dark_3</item>
<item name="input_bar_lock_view_background">@color/classic_dark_2</item>
<item name="input_bar_lock_view_border">@color/classic_dark_3</item>
<item name="mention_candidates_view_background">@color/classic_dark_2</item>
<item name="scroll_to_bottom_button_background">@color/classic_dark_1</item>
<item name="scroll_to_bottom_button_border">@color/classic_dark_3</item>
<item name="conversation_unread_count_indicator_background">@color/classic_dark_4</item>
<item name="message_selected">@color/classic_dark_2</item>
</style>
</resources> </resources>

View File

@ -12,7 +12,6 @@
android:title="@string/preferences_app_protection__screen_lock" android:title="@string/preferences_app_protection__screen_lock"
android:summary="@string/preferences_app_protection__lock_signal_access_with_android_screen_lock_or_fingerprint" /> android:summary="@string/preferences_app_protection__lock_signal_access_with_android_screen_lock_or_fingerprint" />
<!-- TODO: check figure out what is needed for this -->
<org.thoughtcrime.securesms.components.SwitchPreferenceCompat <org.thoughtcrime.securesms.components.SwitchPreferenceCompat
android:defaultValue="@bool/screen_security_default" android:defaultValue="@bool/screen_security_default"
android:key="pref_screen_security" android:key="pref_screen_security"

View File

@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.first
import org.hamcrest.CoreMatchers.endsWith import org.hamcrest.CoreMatchers.endsWith
import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.MatcherAssert.assertThat
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.mockito.Mockito.anyLong import org.mockito.Mockito.anyLong
@ -31,7 +30,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
private lateinit var recipient: Recipient private lateinit var recipient: Recipient
private val viewModel: ConversationViewModel by lazy { private val viewModel: ConversationViewModel by lazy {
ConversationViewModel(threadId, edKeyPair, repository, storage) ConversationViewModel(threadId, edKeyPair, mock(), repository, storage)
} }
@Before @Before

View File

@ -26,9 +26,9 @@ class MessageRequestsViewModelTest : BaseViewModelTest() {
@Test @Test
fun `should clear all message requests`() = runBlockingTest { fun `should clear all message requests`() = runBlockingTest {
viewModel.clearAllMessageRequests() viewModel.clearAllMessageRequests(block = false)
verify(repository).clearAllMessageRequests() verify(repository).clearAllMessageRequests(block = false)
} }
} }

View File

@ -9,7 +9,7 @@ import junit.framework.TestCase;
import org.junit.Ignore; import org.junit.Ignore;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.mockito.runners.MockitoJUnitRunner; import org.mockito.junit.MockitoJUnitRunner;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsession.utilities.recipients.RecipientExporter; import org.session.libsession.utilities.recipients.RecipientExporter;

View File

@ -51,7 +51,7 @@ allprojects {
project.ext { project.ext {
androidMinimumSdkVersion = 23 androidMinimumSdkVersion = 23
androidTargetSdkVersion = 31 androidTargetSdkVersion = 33
androidCompileSdkVersion = 32 androidCompileSdkVersion = 33
} }
} }

View File

@ -1,26 +1,38 @@
android.useAndroidX=true ## For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
#
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx1024m -XX:MaxPermSize=256m
# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
#
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
#Mon Jun 26 09:56:43 AEST 2023
android.enableJetifier=true android.enableJetifier=true
org.gradle.jvmargs=-Xmx8g android.useAndroidX=true
appcompatVersion=1.6.1
gradlePluginVersion=7.3.1 coreVersion=1.8.0
googleServicesVersion=4.3.12
kotlinVersion=1.6.21
coroutinesVersion=1.6.4 coroutinesVersion=1.6.4
kotlinxJsonVersion=1.3.3 curve25519Version=0.6.0
lifecycleVersion=2.5.1
daggerVersion=2.40.1 daggerVersion=2.40.1
glideVersion=4.11.0 glideVersion=4.11.0
kovenantVersion=3.3.0 googleServicesVersion=4.3.12
curve25519Version=0.6.0 gradlePluginVersion=7.3.1
protobufVersion=2.5.0
okhttpVersion=3.12.1
jacksonDatabindVersion=2.9.8 jacksonDatabindVersion=2.9.8
appcompatVersion=1.5.1
materialVersion=1.7.0
preferenceVersion=1.2.0
coreVersion=1.8.0
junitVersion=4.13.2 junitVersion=4.13.2
mockitoKotlinVersion=4.0.0 kotlinVersion=1.6.21
testCoreVersion=1.4.0 kotlinxJsonVersion=1.3.3
kovenantVersion=3.3.0
lifecycleVersion=2.5.1
materialVersion=1.8.0
mockitoKotlinVersion=4.1.0
okhttpVersion=3.12.1
org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"
org.gradle.unsafe.configuration-cache=true
pagingVersion=3.0.0 pagingVersion=3.0.0
preferenceVersion=1.2.0
protobufVersion=2.5.0
testCoreVersion=1.5.0

2
libsession-util/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/build
/.cxx/

View File

@ -0,0 +1,47 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'network.loki.messenger.libsession_util'
compileSdkVersion androidCompileSdkVersion
defaultConfig {
minSdkVersion androidMinimumSdkVersion
targetSdkVersion androidCompileSdkVersion
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild {
cmake {
}
}
}
buildTypes {
release {
minifyEnabled false
}
}
externalNativeBuild {
cmake {
path "src/main/cpp/CMakeLists.txt"
version "3.22.1"
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
testImplementation 'junit:junit:4.13.2'
implementation(project(":libsignal"))
implementation "com.google.protobuf:protobuf-java:$protobufVersion"
androidTestImplementation 'androidx.test.ext:junit:1.1.4'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
}

@ -0,0 +1 @@
Subproject commit 7eb87028355bfc89950102c52d5b2927a25b2e22

View File

@ -0,0 +1,584 @@
package network.loki.messenger.libsession_util
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import network.loki.messenger.libsession_util.util.*
import org.hamcrest.CoreMatchers.not
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.Log
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class InstrumentedTests {
val seed =
Hex.fromStringCondensed("0123456789abcdef0123456789abcdef00000000000000000000000000000000")
private val keyPair: KeyPair
get() {
return Sodium.ed25519KeyPair(seed)
}
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("network.loki.messenger.libsession_util.test", appContext.packageName)
}
@Test
fun jni_test_sodium_kp_ed_curve() {
val kp = keyPair
val curvePkBytes = Sodium.ed25519PkToCurve25519(kp.pubKey)
val edPk = kp.pubKey
val curvePk = curvePkBytes
assertArrayEquals(Hex.fromStringCondensed("4cb76fdc6d32278e3f83dbf608360ecc6b65727934b85d2fb86862ff98c46ab7"), edPk)
assertArrayEquals(Hex.fromStringCondensed("d2ad010eeb72d72e561d9de7bd7b6989af77dcabffa03a5111a6c859ae5c3a72"), curvePk)
assertArrayEquals(kp.secretKey.take(32).toByteArray(), seed)
}
@Test
fun testDirtyEmptyString() {
val contacts = Contacts.newInstance(keyPair.secretKey)
val definitelyRealId = "050000000000000000000000000000000000000000000000000000000000000000"
val contact = contacts.getOrConstruct(definitelyRealId)
contacts.set(contact)
assertTrue(contacts.dirty())
contacts.set(contact.copy(name = "test"))
assertTrue(contacts.dirty())
val push = contacts.push()
contacts.confirmPushed(push.seqNo, "abc123")
contacts.dump()
contacts.set(contact.copy(name = "test2"))
contacts.set(contact.copy(name = "test"))
assertTrue(contacts.dirty())
}
@Test
fun jni_contacts() {
val contacts = Contacts.newInstance(keyPair.secretKey)
val definitelyRealId = "050000000000000000000000000000000000000000000000000000000000000000"
assertNull(contacts.get(definitelyRealId))
// Should be an uninitialized contact apart from ID
val c = contacts.getOrConstruct(definitelyRealId)
assertEquals(definitelyRealId, c.id)
assertTrue(c.name.isEmpty())
assertTrue(c.nickname.isEmpty())
assertFalse(c.approved)
assertFalse(c.approvedMe)
assertFalse(c.blocked)
assertEquals(UserPic.DEFAULT, c.profilePicture)
assertFalse(contacts.needsPush())
assertFalse(contacts.needsDump())
assertEquals(0, contacts.push().seqNo)
c.name = "Joe"
c.nickname = "Joey"
c.approved = true
c.approvedMe = true
contacts.set(c)
val cSaved = contacts.get(definitelyRealId)!!
assertEquals("Joe", cSaved.name)
assertEquals("Joey", cSaved.nickname)
assertTrue(cSaved.approved)
assertTrue(cSaved.approvedMe)
assertFalse(cSaved.blocked)
assertEquals(UserPic.DEFAULT, cSaved.profilePicture)
val push1 = contacts.push()
assertEquals(1, push1.seqNo)
contacts.confirmPushed(push1.seqNo, "fakehash1")
assertFalse(contacts.needsPush())
assertTrue(contacts.needsDump())
val contacts2 = Contacts.newInstance(keyPair.secretKey, contacts.dump())
assertFalse(contacts.needsDump())
assertFalse(contacts2.needsPush())
assertFalse(contacts2.needsDump())
val anotherId = "051111111111111111111111111111111111111111111111111111111111111111"
val c2 = contacts2.getOrConstruct(anotherId)
contacts2.set(c2)
val push2 = contacts2.push()
assertEquals(2, push2.seqNo)
contacts2.confirmPushed(push2.seqNo, "fakehash2")
assertFalse(contacts2.needsPush())
contacts.merge("fakehash2" to push2.config)
assertFalse(contacts.needsPush())
assertEquals(push2.seqNo, contacts.push().seqNo)
val contactList = contacts.all().toList()
assertEquals(definitelyRealId, contactList[0].id)
assertEquals(anotherId, contactList[1].id)
assertEquals("Joey", contactList[0].nickname)
assertEquals("", contactList[1].nickname)
contacts.erase(definitelyRealId)
val thirdId ="052222222222222222222222222222222222222222222222222222222222222222"
val third = Contact(
id = thirdId,
nickname = "Nickname 3",
approved = true,
blocked = true,
profilePicture = UserPic("http://example.com/huge.bmp", "qwertyuio01234567890123456789012".encodeToByteArray()),
expiryMode = ExpiryMode.NONE
)
contacts2.set(third)
assertTrue(contacts.needsPush())
assertTrue(contacts2.needsPush())
val toPush = contacts.push()
val toPush2 = contacts2.push()
assertEquals(toPush.seqNo, toPush2.seqNo)
assertThat(toPush2.config, not(equals(toPush.config)))
contacts.confirmPushed(toPush.seqNo, "fakehash3a")
contacts2.confirmPushed(toPush2.seqNo, "fakehash3b")
contacts.merge("fakehash3b" to toPush2.config)
contacts2.merge("fakehash3a" to toPush.config)
assertTrue(contacts.needsPush())
assertTrue(contacts2.needsPush())
val mergePush = contacts.push()
val mergePush2 = contacts2.push()
assertEquals(mergePush.seqNo, mergePush2.seqNo)
assertArrayEquals(mergePush.config, mergePush2.config)
assertTrue(mergePush.obsoleteHashes.containsAll(listOf("fakehash3b", "fakehash3a")))
assertTrue(mergePush2.obsoleteHashes.containsAll(listOf("fakehash3b", "fakehash3a")))
}
@Test
fun jni_accessible() {
val userProfile = UserProfile.newInstance(keyPair.secretKey)
assertNotNull(userProfile)
userProfile.free()
}
@Test
fun jni_user_profile_c_api() {
val edSk = keyPair.secretKey
val userProfile = UserProfile.newInstance(edSk)
// these should be false as empty config
assertFalse(userProfile.needsPush())
assertFalse(userProfile.needsDump())
// Since it's empty there shouldn't be a name
assertNull(userProfile.getName())
// Don't need to push yet so this is just for testing
val (_, seqNo) = userProfile.push() // disregarding encrypted
assertEquals("UserProfile", userProfile.encryptionDomain())
assertEquals(0, seqNo)
// This should also be unset:
assertEquals(UserPic.DEFAULT, userProfile.getPic())
// Now let's go set a profile name and picture:
// not sending keylen like c api so cutting off the NOTSECRET in key for testing purposes
userProfile.setName("Kallie")
val newUserPic = UserPic("http://example.org/omg-pic-123.bmp", "secret78901234567890123456789012".encodeToByteArray())
userProfile.setPic(newUserPic)
userProfile.setNtsPriority(9)
// Retrieve them just to make sure they set properly:
assertEquals("Kallie", userProfile.getName())
val pic = userProfile.getPic()
assertEquals("http://example.org/omg-pic-123.bmp", pic.url)
assertEquals("secret78901234567890123456789012", pic.key.decodeToString())
// Since we've made changes, we should need to push new config to the swarm, *and* should need
// to dump the updated state:
assertTrue(userProfile.needsPush())
assertTrue(userProfile.needsDump())
val (newToPush, newSeqNo) = userProfile.push()
val expHash0 =
Hex.fromStringCondensed("ea173b57beca8af18c3519a7bbf69c3e7a05d1c049fa9558341d8ebb48b0c965")
val expectedPush1Decrypted = ("d" +
"1:#"+ "i1e" +
"1:&"+ "d"+
"1:+"+ "i9e"+
"1:n"+ "6:Kallie"+
"1:p"+ "34:http://example.org/omg-pic-123.bmp"+
"1:q"+ "32:secret78901234567890123456789012"+
"e"+
"1:<"+ "l"+
"l"+ "i0e"+ "32:").encodeToByteArray() + expHash0 + ("de"+ "e"+
"e"+
"1:="+ "d"+
"1:+" +"0:"+
"1:n" +"0:"+
"1:p" +"0:"+
"1:q" +"0:"+
"e"+
"e").encodeToByteArray()
assertEquals(1, newSeqNo)
// We haven't dumped, so still need to dump:
assertTrue(userProfile.needsDump())
// We did call push but we haven't confirmed it as stored yet, so this will still return true:
assertTrue(userProfile.needsPush())
val dump = userProfile.dump()
// (in a real client we'd now store this to disk)
assertFalse(userProfile.needsDump())
val expectedDump = ("d" +
"1:!"+ "i2e" +
"1:$").encodeToByteArray() + expectedPush1Decrypted.size.toString().encodeToByteArray() +
":".encodeToByteArray() + expectedPush1Decrypted +
"1:(0:1:)le".encodeToByteArray()+
"e".encodeToByteArray()
assertArrayEquals(expectedDump, dump)
userProfile.confirmPushed(newSeqNo, "fakehash1")
val newConf = UserProfile.newInstance(edSk)
val accepted = newConf.merge("fakehash1" to newToPush)
assertEquals(1, accepted)
assertTrue(newConf.needsDump())
assertFalse(newConf.needsPush())
val _ignore = newConf.dump()
assertFalse(newConf.needsDump())
userProfile.setName("Raz")
newConf.setName("Nibbler")
newConf.setPic(UserPic("http://new.example.com/pic", "qwertyuio01234567890123456789012".encodeToByteArray()))
val conf = userProfile.push()
val conf2 = newConf.push()
userProfile.confirmPushed(conf.seqNo, "fakehash2")
newConf.confirmPushed(conf2.seqNo, "fakehash3")
userProfile.dump()
assertFalse(conf.config.contentEquals(conf2.config))
newConf.merge("fakehash2" to conf.config)
userProfile.merge("fakehash3" to conf2.config)
assertTrue(newConf.needsPush())
assertTrue(userProfile.needsPush())
val newSeq1 = userProfile.push()
assertEquals(3, newSeq1.seqNo)
userProfile.confirmPushed(newSeq1.seqNo, "fakehash4")
// assume newConf push gets rejected as it was last to write and clear previous config by hash on oxenss
newConf.merge("fakehash4" to newSeq1.config)
val newSeqMerge = newConf.push()
newConf.confirmPushed(newSeqMerge.seqNo, "fakehash5")
assertEquals("Raz", newConf.getName())
assertEquals(3, newSeqMerge.seqNo)
// userProfile device polls and merges
userProfile.merge("fakehash5" to newSeqMerge.config)
val userConfigMerge = userProfile.push()
assertEquals(3, userConfigMerge.seqNo)
assertEquals("Raz", newConf.getName())
assertEquals("Raz", userProfile.getName())
userProfile.free()
newConf.free()
}
@Test
fun merge_resolves_conflicts() {
val kp = keyPair
val a = UserProfile.newInstance(kp.secretKey)
val b = UserProfile.newInstance(kp.secretKey)
a.setName("A")
val (aPush, aSeq) = a.push()
a.confirmPushed(aSeq, "hashfroma")
b.setName("B")
// polls and sees invalid state, has to merge
b.merge("hashfroma" to aPush)
val (bPush, bSeq) = b.push()
b.confirmPushed(bSeq, "hashfromb")
assertEquals("B", b.getName())
assertEquals(1, aSeq)
assertEquals(2, bSeq)
a.merge("hashfromb" to bPush)
assertEquals(2, a.push().seqNo)
}
@Test
fun jni_setting_getting() {
val userProfile = UserProfile.newInstance(keyPair.secretKey)
val newName = "test"
println("Name being set via JNI call: $newName")
userProfile.setName(newName)
val nameFromNative = userProfile.getName()
assertEquals(newName, nameFromNative)
println("Name received by JNI call: $nameFromNative")
assertTrue(userProfile.dirty())
userProfile.free()
}
@Test
fun jni_remove_all_test() {
val convos = ConversationVolatileConfig.newInstance(keyPair.secretKey)
assertEquals(0 /* number removed */, convos.eraseAll { true /* 'erase' every item */ })
val definitelyRealId = "050000000000000000000000000000000000000000000000000000000000000000"
val definitelyRealConvo = Conversation.OneToOne(definitelyRealId, System.currentTimeMillis(), false)
convos.set(definitelyRealConvo)
val anotherDefinitelyReadId = "051111111111111111111111111111111111111111111111111111111111111111"
val anotherDefinitelyRealConvo = Conversation.OneToOne(anotherDefinitelyReadId, System.currentTimeMillis(), false)
convos.set(anotherDefinitelyRealConvo)
assertEquals(2, convos.sizeOneToOnes())
val numErased = convos.eraseAll { convo ->
convo is Conversation.OneToOne && convo.sessionId == definitelyRealId
}
assertEquals(1, numErased)
assertEquals(1, convos.sizeOneToOnes())
}
@Test
fun test_open_group_urls() {
val (base1, room1, pk1) = BaseCommunityInfo.parseFullUrl(
"https://example.com/" +
"someroom?public_key=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
)!!
val (base2, room2, pk2) = BaseCommunityInfo.parseFullUrl(
"HTTPS://EXAMPLE.COM/" +
"someroom?public_key=0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"
)!!
val (base3, room3, pk3) = BaseCommunityInfo.parseFullUrl(
"HTTPS://EXAMPLE.COM/r/" +
"someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF"
)!!
val (base4, room4, pk4) = BaseCommunityInfo.parseFullUrl(
"http://example.com/r/" +
"someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF"
)!!
val (base5, room5, pk5) = BaseCommunityInfo.parseFullUrl(
"HTTPS://EXAMPLE.com:443/r/" +
"someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF"
)!!
val (base6, room6, pk6) = BaseCommunityInfo.parseFullUrl(
"HTTP://EXAMPLE.com:80/r/" +
"someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF"
)!!
val (base7, room7, pk7) = BaseCommunityInfo.parseFullUrl(
"http://example.com:80/r/" +
"someroom?public_key=ASNFZ4mrze8BI0VniavN7wEjRWeJq83vASNFZ4mrze8"
)!!
val (base8, room8, pk8) = BaseCommunityInfo.parseFullUrl(
"http://example.com:80/r/" +
"someroom?public_key=yrtwk3hjixg66yjdeiuauk6p7hy1gtm8tgih55abrpnsxnpm3zzo"
)!!
assertEquals("https://example.com", base1)
assertEquals("http://example.com", base4)
assertEquals(base1, base2)
assertEquals(base1, base3)
assertNotEquals(base1, base4)
assertEquals(base1, base5)
assertEquals(base4, base6)
assertEquals(base4, base7)
assertEquals(base4, base8)
assertEquals("someroom", room1)
assertEquals("someroom", room2)
assertEquals("someroom", room3)
assertEquals("someroom", room4)
assertEquals("someroom", room5)
assertEquals("someroom", room6)
assertEquals("someroom", room7)
assertEquals("someroom", room8)
assertEquals(Hex.toStringCondensed(pk1), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
assertEquals(Hex.toStringCondensed(pk2), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
assertEquals(Hex.toStringCondensed(pk3), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
assertEquals(Hex.toStringCondensed(pk4), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
assertEquals(Hex.toStringCondensed(pk5), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
assertEquals(Hex.toStringCondensed(pk6), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
assertEquals(Hex.toStringCondensed(pk7), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
assertEquals(Hex.toStringCondensed(pk8), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
}
@Test
fun test_conversations() {
val convos = ConversationVolatileConfig.newInstance(keyPair.secretKey)
val definitelyRealId = "055000000000000000000000000000000000000000000000000000000000000000"
assertNull(convos.getOneToOne(definitelyRealId))
assertTrue(convos.empty())
assertEquals(0, convos.size())
val c = convos.getOrConstructOneToOne(definitelyRealId)
assertEquals(definitelyRealId, c.sessionId)
assertEquals(0, c.lastRead)
assertFalse(convos.needsPush())
assertFalse(convos.needsDump())
assertEquals(0, convos.push().seqNo)
val nowMs = System.currentTimeMillis()
c.lastRead = nowMs
convos.set(c)
assertNull(convos.getLegacyClosedGroup(definitelyRealId))
assertNotNull(convos.getOneToOne(definitelyRealId))
assertEquals(nowMs, convos.getOneToOne(definitelyRealId)?.lastRead)
assertTrue(convos.needsPush())
assertTrue(convos.needsDump())
val openGroupPubKey = Hex.fromStringCondensed("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
val og = convos.getOrConstructCommunity("http://Example.ORG:5678", "SudokuRoom", openGroupPubKey)
val ogCommunity = og.baseCommunityInfo
assertEquals("http://example.org:5678", ogCommunity.baseUrl) // Note: lower-case
assertEquals("sudokuroom", ogCommunity.room) // Note: lower-case
assertEquals(64, ogCommunity.pubKeyHex.length)
assertEquals("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", ogCommunity.pubKeyHex)
og.unread = true
convos.set(og)
val (_, seqNo) = convos.push()
assertEquals(1, seqNo)
convos.confirmPushed(seqNo, "fakehash1")
assertTrue(convos.needsDump())
assertFalse(convos.needsPush())
val convos2 = ConversationVolatileConfig.newInstance(keyPair.secretKey, convos.dump())
assertFalse(convos.needsPush())
assertFalse(convos.needsDump())
assertEquals(1, convos.push().seqNo)
assertFalse(convos.needsDump())
val x1 = convos2.getOneToOne(definitelyRealId)!!
assertEquals(nowMs, x1.lastRead)
assertEquals(definitelyRealId, x1.sessionId)
assertEquals(false, x1.unread)
val x2 = convos2.getCommunity("http://EXAMPLE.org:5678", "sudokuRoom")!!
val x2Info = x2.baseCommunityInfo
assertEquals("http://example.org:5678", x2Info.baseUrl)
assertEquals("sudokuroom", x2Info.room)
assertEquals(x2Info.pubKeyHex, Hex.toStringCondensed(openGroupPubKey))
assertTrue(x2.unread)
val anotherId = "051111111111111111111111111111111111111111111111111111111111111111"
val c2 = convos.getOrConstructOneToOne(anotherId)
c2.unread = true
convos2.set(c2)
val c3 = convos.getOrConstructLegacyGroup(
"05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
)
c3.lastRead = nowMs - 50
convos2.set(c3)
assertTrue(convos2.needsPush())
val (toPush2, seqNo2) = convos2.push()
assertEquals(2, seqNo2)
convos2.confirmPushed(seqNo2, "fakehash2")
convos.merge("fakehash2" to toPush2)
assertFalse(convos.needsPush())
assertEquals(seqNo2, convos.push().seqNo)
val seen = mutableListOf<String>()
for ((ind, conv) in listOf(convos, convos2).withIndex()) {
Log.e("Test","Testing seen from convo #$ind")
seen.clear()
assertEquals(4, conv.size())
assertEquals(2, conv.sizeOneToOnes())
assertEquals(1, conv.sizeCommunities())
assertEquals(1, conv.sizeLegacyClosedGroups())
assertFalse(conv.empty())
val allConvos = conv.all()
for (convo in allConvos) {
when (convo) {
is Conversation.OneToOne -> seen.add("1-to-1: ${convo.sessionId}")
is Conversation.Community -> seen.add("og: ${convo.baseCommunityInfo.baseUrl}/r/${convo.baseCommunityInfo.room}")
is Conversation.LegacyGroup -> seen.add("cl: ${convo.groupId}")
}
}
assertTrue(seen.contains("1-to-1: 051111111111111111111111111111111111111111111111111111111111111111"))
assertTrue(seen.contains("1-to-1: 055000000000000000000000000000000000000000000000000000000000000000"))
assertTrue(seen.contains("og: http://example.org:5678/r/sudokuroom"))
assertTrue(seen.contains("cl: 05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"))
assertTrue(seen.size == 4) // for some reason iterative checks aren't working in test cases
}
assertFalse(convos.needsPush())
convos.eraseOneToOne("052000000000000000000000000000000000000000000000000000000000000000")
assertFalse(convos.needsPush())
convos.eraseOneToOne("055000000000000000000000000000000000000000000000000000000000000000")
assertTrue(convos.needsPush())
assertEquals(1, convos.allOneToOnes().size)
assertEquals("051111111111111111111111111111111111111111111111111111111111111111",
convos.allOneToOnes().map(Conversation.OneToOne::sessionId).first()
)
assertEquals(1, convos.allCommunities().size)
assertEquals("http://example.org:5678",
convos.allCommunities().map { it.baseCommunityInfo.baseUrl }.first()
)
assertEquals(1, convos.allLegacyClosedGroups().size)
assertEquals("05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
convos.allLegacyClosedGroups().map(Conversation.LegacyGroup::groupId).first()
)
}
}

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