diff --git a/.drone.yml b/.drone.yml
new file mode 100644
index 00000000000..e7f705722ff
--- /dev/null
+++ b/.drone.yml
@@ -0,0 +1,10 @@
+---
+kind: pipeline
+type: docker
+name: default
+
+steps:
+- name: test
+ image: mingc/android-build-box:1.24.0
+ commands:
+ - bash ./gradlew test
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index be928b39335..bf9ef62a972 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
project.properties
.project
.settings
+.kotlin
bin/
gen/
.idea/
diff --git a/app/build.gradle b/app/build.gradle
index 37673102a9a..52c06e8298f 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,16 +1,18 @@
plugins {
+ id 'com.android.application'
+ id 'org.jetbrains.kotlin.android'
+ id 'org.jetbrains.kotlin.plugin.serialization'
+ id 'org.jetbrains.kotlin.plugin.compose'
id 'com.google.devtools.ksp'
id 'com.google.dagger.hilt.android'
+ id 'kotlin-parcelize'
+ id 'kotlinx-serialization'
}
-apply plugin: 'com.android.application'
-apply plugin: 'kotlin-android'
apply plugin: 'witness'
-apply plugin: 'kotlin-parcelize'
-apply plugin: 'kotlinx-serialization'
-configurations.forEach {
- it.exclude module: "commons-logging"
+configurations.configureEach {
+ exclude module: "commons-logging"
}
def canonicalVersionCode = 384
@@ -40,12 +42,12 @@ android {
useLibrary 'org.apache.http.legacy'
compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = '1.8'
+ jvmTarget = '17'
}
packagingOptions {
@@ -54,6 +56,7 @@ android {
}
}
+
splits {
abi {
enable !project.hasProperty('huawei') // huawei builds do not need the split variants
@@ -64,7 +67,8 @@ android {
}
buildFeatures {
- compose true
+ viewBinding true
+ buildConfig true
}
composeOptions {
@@ -198,11 +202,11 @@ android {
}
}
- buildFeatures {
- viewBinding true
- }
-
def huaweiEnabled = project.properties['huawei'] != null
+ lint {
+ abortOnError true
+ baseline file('lint-baseline.xml')
+ }
applicationVariants.configureEach { variant ->
if (variant.flavorName == 'huawei') {
@@ -261,6 +265,7 @@ dependencies {
ksp("androidx.hilt:hilt-compiler:$jetpackHiltVersion")
ksp("com.google.dagger:hilt-compiler:$daggerHiltVersion")
ksp("com.github.bumptech.glide:ksp:$glideVersion")
+ implementation("androidx.hilt:hilt-navigation-compose:$androidxHiltVersion")
implementation("com.google.dagger:hilt-android:$daggerHiltVersion")
implementation "androidx.appcompat:appcompat:$appcompatVersion"
@@ -340,7 +345,6 @@ dependencies {
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation "com.squareup.phrase:phrase:$phraseVersion"
implementation 'app.cash.copper:copper-flow:1.0.0'
- implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion"
@@ -363,7 +367,6 @@ dependencies {
androidTestImplementation('com.adevinta.android:barista:4.2.0') {
exclude group: 'org.jetbrains.kotlin'
}
-
// AndroidJUnitRunner and JUnit Rules
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
@@ -382,6 +385,8 @@ dependencies {
androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1'
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
+ androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.5.3"
+ debugImplementation "androidx.compose.ui:ui-test-manifest:1.5.3"
androidTestUtil 'androidx.test:orchestrator:1.4.2'
testImplementation 'org.robolectric:robolectric:4.12.2'
@@ -405,6 +410,11 @@ dependencies {
androidTestImplementation "androidx.compose.ui:ui-test-junit4-android"
debugImplementation "androidx.compose.ui:ui-test-manifest"
+ // Navigation
+ implementation "androidx.navigation:navigation-fragment-ktx:$navVersion"
+ implementation "androidx.navigation:navigation-ui-ktx:$navVersion"
+ implementation "androidx.navigation:navigation-compose:$navVersion"
+
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha"
implementation "com.google.accompanist:accompanist-permissions:0.36.0"
implementation "com.google.accompanist:accompanist-drawablepainter:0.33.1-alpha"
diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml
index 023120e1a8e..be464ad75a9 100644
--- a/app/src/androidTest/AndroidManifest.xml
+++ b/app/src/androidTest/AndroidManifest.xml
@@ -3,5 +3,8 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/androidTest/java/network/loki/messenger/CreateGroupTests.kt b/app/src/androidTest/java/network/loki/messenger/CreateGroupTests.kt
new file mode 100644
index 00000000000..c4b81d8148b
--- /dev/null
+++ b/app/src/androidTest/java/network/loki/messenger/CreateGroupTests.kt
@@ -0,0 +1,143 @@
+package network.loki.messenger
+
+import androidx.compose.ui.test.hasContentDescriptionExactly
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import org.hamcrest.CoreMatchers.*
+import org.hamcrest.MatcherAssert.*
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.thoughtcrime.securesms.ApplicationContext
+import org.thoughtcrime.securesms.groups.compose.CreateGroup
+import org.thoughtcrime.securesms.groups.ViewState
+import org.thoughtcrime.securesms.ui.theme.PreviewTheme
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class CreateGroupTests {
+
+ @get:Rule
+ val composeTest = createComposeRule()
+
+ @Test
+ fun testNavigateToCreateGroup() {
+ val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
+ // Accessibility IDs
+ val nameDesc = application.getString(R.string.AccessibilityId_closed_group_edit_group_name)
+ val buttonDesc = application.getString(R.string.AccessibilityId_create_closed_group_create_button)
+
+ var backPressed = false
+ var closePressed = false
+
+ composeTest.setContent {
+ PreviewTheme {
+ CreateGroup(
+ viewState = ViewState.DEFAULT,
+ onBack = { backPressed = true },
+ onClose = { closePressed = true },
+ onContactItemClicked = {},
+ updateState = {}
+ )
+ }
+ }
+
+ with(composeTest) {
+ onNode(hasContentDescriptionExactly(nameDesc)).performTextInput("Name")
+ onNode(hasContentDescriptionExactly(buttonDesc)).performClick()
+ }
+
+ assertThat(backPressed, equalTo(false))
+ assertThat(closePressed, equalTo(false))
+
+ }
+
+ @Test
+ fun testFailToCreate() {
+ val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
+ // Accessibility IDs
+ val nameDesc = application.getString(R.string.AccessibilityId_closed_group_edit_group_name)
+ val buttonDesc = application.getString(R.string.AccessibilityId_create_closed_group_create_button)
+
+ var backPressed = false
+ var closePressed = false
+
+ composeTest.setContent {
+ PreviewTheme {
+ CreateGroup(
+ viewState = ViewState.DEFAULT,
+ onBack = { backPressed = true },
+ onClose = { closePressed = true },
+ updateState = {},
+ onContactItemClicked = {}
+ )
+ }
+ }
+ with(composeTest) {
+ onNode(hasContentDescriptionExactly(nameDesc)).performTextInput("")
+ onNode(hasContentDescriptionExactly(buttonDesc)).performClick()
+ }
+
+ assertThat(backPressed, equalTo(false))
+ assertThat(closePressed, equalTo(false))
+ }
+
+ @Test
+ fun testBackButton() {
+ val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
+ // Accessibility IDs
+ val backDesc = application.getString(R.string.new_conversation_dialog_back_button_content_description)
+
+ var backPressed = false
+
+ composeTest.setContent {
+ PreviewTheme {
+ CreateGroup(
+ viewState = ViewState.DEFAULT,
+ onBack = { backPressed = true },
+ onClose = {},
+ onContactItemClicked = {},
+ updateState = {}
+ )
+ }
+ }
+
+ with (composeTest) {
+ onNode(hasContentDescriptionExactly(backDesc)).performClick()
+ }
+
+ assertThat(backPressed, equalTo(true))
+ }
+
+ @Test
+ fun testCloseButton() {
+ val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
+ // Accessibility IDs
+ val closeDesc = application.getString(R.string.new_conversation_dialog_close_button_content_description)
+ var closePressed = false
+
+ composeTest.setContent {
+ PreviewTheme {
+ CreateGroup(
+ viewState = ViewState.DEFAULT,
+ onBack = { },
+ onClose = { closePressed = true },
+ onContactItemClicked = {},
+ updateState = {}
+ )
+ }
+ }
+
+ with (composeTest) {
+ onNode(hasContentDescriptionExactly(closeDesc)).performClick()
+ }
+
+ assertThat(closePressed, equalTo(true))
+ }
+
+
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/network/loki/messenger/EditGroupTests.kt b/app/src/androidTest/java/network/loki/messenger/EditGroupTests.kt
new file mode 100644
index 00000000000..e0799b0a3a0
--- /dev/null
+++ b/app/src/androidTest/java/network/loki/messenger/EditGroupTests.kt
@@ -0,0 +1,257 @@
+package network.loki.messenger
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.hasContentDescriptionExactly
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import org.hamcrest.CoreMatchers.equalTo
+import org.hamcrest.MatcherAssert.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.thoughtcrime.securesms.ApplicationContext
+import org.thoughtcrime.securesms.groups.compose.EditGroup
+import org.thoughtcrime.securesms.groups.EditGroupViewState
+import org.thoughtcrime.securesms.groups.MemberState
+import org.thoughtcrime.securesms.groups.MemberViewModel
+import org.thoughtcrime.securesms.ui.theme.PreviewTheme
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class EditGroupTests {
+
+ @get:Rule
+ val composeTest = createComposeRule()
+
+ val oneMember = MemberViewModel(
+ "Test User",
+ "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
+ MemberState.InviteSent,
+ false
+ )
+ val twoMember = MemberViewModel(
+ "Test User 2",
+ "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235",
+ MemberState.InviteFailed,
+ false
+ )
+ val threeMember = MemberViewModel(
+ "Test User 3",
+ "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236",
+ MemberState.Member,
+ false
+ )
+
+ val fourMember = MemberViewModel(
+ "Test User 4",
+ "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1237",
+ MemberState.Admin,
+ false
+ )
+
+ @Test
+ fun testDisplaysNameAndDesc() {
+ val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
+ // Accessibility IDs
+ val nameDesc = application.getString(R.string.AccessibilityId_group_name)
+ val descriptionDesc = application.getString(R.string.AccessibilityId_group_description)
+
+ with (composeTest) {
+ val state = EditGroupViewState(
+ "TestGroup",
+ "TestDesc",
+ emptyList(),
+ false
+ )
+
+ setContent {
+ PreviewTheme {
+ EditGroup(
+ onBackClick = {},
+ onAddMemberClick = {},
+ onResendInviteClick = {},
+ onPromoteClick = {},
+ onRemoveClick = {},
+ onEditName = {},
+ onMemberSelected = {},
+ viewState = state
+ )
+ }
+ }
+ onNode(hasContentDescriptionExactly(nameDesc)).assertTextEquals("TestGroup")
+ onNode(hasContentDescriptionExactly(descriptionDesc)).assertTextEquals("TestDesc")
+ }
+ }
+
+ @Test
+ fun testDisplaysReinviteProperly() {
+ val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
+
+ // Accessibility IDs
+ val reinviteDesc = application.getString(R.string.AccessibilityId_reinvite_member)
+ val promoteDesc = application.getString(R.string.AccessibilityId_promote_member)
+
+ var reinvited = false
+
+ with (composeTest) {
+
+ val state = EditGroupViewState(
+ "TestGroup",
+ "TestDesc",
+ listOf(
+ twoMember
+ ),
+ // reinvite only shows for admin users
+ true
+ )
+
+ setContent {
+ PreviewTheme {
+ EditGroup(
+ onBackClick = {},
+ onAddMemberClick = {},
+ onResendInviteClick = { reinvited = true },
+ onPromoteClick = {},
+ onRemoveClick = {},
+ onEditName = {},
+ onMemberSelected = {},
+ viewState = state
+ )
+ }
+ }
+ onNodeWithContentDescription(reinviteDesc).assertIsDisplayed().performClick()
+ onNodeWithContentDescription(promoteDesc).assertDoesNotExist()
+ assertThat(reinvited, equalTo(true))
+ }
+ }
+
+ @Test
+ fun testDisplaysRegularMemberProperly() {
+ val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
+
+ // Accessibility IDs
+ val reinviteDesc = application.getString(R.string.AccessibilityId_reinvite_member)
+ val promoteDesc = application.getString(R.string.AccessibilityId_promote_member)
+
+ var promoted = false
+
+ with (composeTest) {
+
+ val state = EditGroupViewState(
+ "TestGroup",
+ "TestDesc",
+ listOf(
+ threeMember
+ ),
+ // reinvite only shows for admin users
+ true
+ )
+
+ setContent {
+ PreviewTheme {
+ EditGroup(
+ onBackClick = {},
+ onAddMemberClick = {},
+ onResendInviteClick = {},
+ onPromoteClick = { promoted = true },
+ onRemoveClick = {},
+ onEditName = {},
+ onMemberSelected = {},
+ viewState = state
+ )
+ }
+ }
+ onNodeWithContentDescription(reinviteDesc).assertDoesNotExist()
+ onNodeWithContentDescription(promoteDesc).assertIsDisplayed().performClick()
+ assertThat(promoted, equalTo(true))
+ }
+ }
+
+ @Test
+ fun testDisplaysAdminProperly() {
+ val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
+
+ // Accessibility IDs
+ val reinviteDesc = application.getString(R.string.AccessibilityId_reinvite_member)
+ val promoteDesc = application.getString(R.string.AccessibilityId_promote_member)
+
+ with (composeTest) {
+
+ val state = EditGroupViewState(
+ "TestGroup",
+ "TestDesc",
+ listOf(
+ fourMember
+ ),
+ // reinvite only shows for admin users
+ true
+ )
+
+ setContent {
+ PreviewTheme {
+ EditGroup(
+ onBackClick = {},
+ onAddMemberClick = {},
+ onResendInviteClick = {},
+ onPromoteClick = {},
+ onRemoveClick = {},
+ onEditName = {},
+ onMemberSelected = {},
+ viewState = state
+ )
+ }
+ }
+ onNodeWithContentDescription(reinviteDesc).assertDoesNotExist()
+ onNodeWithContentDescription(promoteDesc).assertDoesNotExist()
+ }
+ }
+
+ @Test
+ fun testDisplaysPendingInviteProperly() {
+ val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
+
+ // Accessibility IDs
+ val reinviteDesc = application.getString(R.string.AccessibilityId_reinvite_member)
+ val promoteDesc = application.getString(R.string.AccessibilityId_promote_member)
+ val stateDesc = application.getString(R.string.AccessibilityId_member_state)
+ val memberDesc = application.getString(R.string.AccessibilityId_contact)
+
+ with (composeTest) {
+
+ val state = EditGroupViewState(
+ "TestGroup",
+ "TestDesc",
+ listOf(
+ oneMember
+ ),
+ // reinvite only shows for admin users
+ true
+ )
+
+ setContent {
+ PreviewTheme {
+ EditGroup(
+ onBackClick = {},
+ onAddMemberClick = {},
+ onResendInviteClick = {},
+ onPromoteClick = {},
+ onRemoveClick = {},
+ onEditName = {},
+ onMemberSelected = {},
+ viewState = state
+ )
+ }
+ }
+ onNodeWithContentDescription(reinviteDesc).assertDoesNotExist()
+ onNodeWithContentDescription(promoteDesc).assertDoesNotExist()
+ onNodeWithContentDescription(stateDesc, useUnmergedTree = true).assertTextEquals("InviteSent")
+ onNodeWithContentDescription(memberDesc, useUnmergedTree = true).assertTextEquals("Test User")
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt
index 43b347ba42b..ddb999ab56d 100644
--- a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt
+++ b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt
@@ -1,12 +1,11 @@
package network.loki.messenger
-import android.Manifest
import android.app.Instrumentation
import android.view.View
+import android.content.ClipboardManager
+import android.content.Context
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack
-import androidx.test.espresso.UiController
-import androidx.test.espresso.ViewAction
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed
@@ -16,14 +15,15 @@ import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.LargeTest
+import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
+import network.loki.messenger.util.sendMessage
+import network.loki.messenger.util.waitFor
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import com.adevinta.android.barista.interaction.PermissionGranter
import com.bumptech.glide.Glide
import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable
-import org.hamcrest.Matcher
import org.hamcrest.Matchers.allOf
import org.hamcrest.Matchers.not
import org.junit.After
@@ -42,7 +42,7 @@ import org.thoughtcrime.securesms.home.HomeActivity
*/
@RunWith(AndroidJUnit4::class)
-@LargeTest
+@SmallTest
class HomeActivityTests {
@get:Rule
@@ -108,6 +108,7 @@ class HomeActivityTests {
onView(withId(R.id.newConversationButton)).perform(ViewActions.click())
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
// new chat
+ Thread.sleep(500)
onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard())
onView(withId(R.id.copyButton)).perform(ViewActions.click())
val context = InstrumentationRegistry.getInstrumentation().targetContext
@@ -147,11 +148,13 @@ class HomeActivityTests {
setupLoggedInState()
goToMyChat()
TextSecurePreferences.setLinkPreviewsEnabled(context, true)
- sendMessage("howdy")
- sendMessage("test")
- // tests url rewriter doesn't crash
- sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest")
- sendMessage("https://www.ámazon.com")
+ with (activityMonitor.waitForActivity() as ConversationActivityV2) {
+ sendMessage("howdy")
+ sendMessage("test")
+ // tests url rewriter doesn't crash
+ sendMessage("https://www.getsession.org?random_query_parameter=testtesttesttesttesttesttesttest&other_query_parameter=testtesttesttesttesttesttesttest")
+ sendMessage("https://www.ámazon.com")
+ }
}
@Test
@@ -161,7 +164,9 @@ class HomeActivityTests {
TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true)
// given the link url text
val url = "https://www.ámazon.com"
- sendMessage(url, LinkPreview(url, "amazon", Optional.absent()))
+ with (activityMonitor.waitForActivity() as ConversationActivityV2) {
+ sendMessage(url, LinkPreview(url, "amazon", Optional.absent()))
+ }
// when the URL span is clicked
onView(withSubstring(url)).perform(ViewActions.click())
@@ -175,21 +180,4 @@ class HomeActivityTests {
onView(withText(dialogPromptText)).check(matches(isDisplayed()))
}*/
-
- /**
- * Perform action of waiting for a specific time.
- */
- fun waitFor(millis: Long): ViewAction {
- return object : ViewAction {
- override fun getConstraints(): Matcher? {
- return isRoot()
- }
-
- override fun getDescription(): String = "Wait for $millis milliseconds."
-
- override fun perform(uiController: UiController, view: View?) {
- uiController.loopMainThreadForAtLeast(millis)
- }
- }
- }
}
\ No newline at end of file
diff --git a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt
index 54470569e19..39e9526aecd 100644
--- a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt
+++ b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt
@@ -11,6 +11,10 @@ import network.loki.messenger.libsession_util.ConversationVolatileConfig
import network.loki.messenger.libsession_util.util.Contact
import network.loki.messenger.libsession_util.util.Conversation
import network.loki.messenger.libsession_util.util.ExpiryMode
+import network.loki.messenger.util.applySpiedStorage
+import network.loki.messenger.util.maybeGetUserInfo
+import network.loki.messenger.util.randomSeedBytes
+import network.loki.messenger.util.randomSessionId
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.CoreMatchers.instanceOf
import org.hamcrest.MatcherAssert.assertThat
@@ -31,32 +35,15 @@ 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 randomAccountId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey
-
private var fakeHashI = 0
private val nextFakeHash: String
get() = "fakehash${fakeHashI++}"
- private fun maybeGetUserInfo(): Pair? {
- 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): ByteArray {
val (key,_) = maybeGetUserInfo()!!
val contacts = Contacts.newInstance(key)
@@ -98,11 +85,10 @@ class LibSessionTests {
@Test
fun migration_one_to_ones() {
- val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
- val storageSpy = spy(app.storage)
- app.storage = storageSpy
+ val applicationContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
+ val storage = applicationContext.applySpiedStorage()
- val newContactId = randomAccountId()
+ val newContactId = randomSessionId()
val singleContact = Contact(
id = newContactId,
approved = true,
@@ -111,10 +97,10 @@ class LibSessionTests {
val newContactMerge = buildContactMessage(listOf(singleContact))
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
fakePollNewConfig(contacts, newContactMerge)
- verify(storageSpy).addLibSessionContacts(argThat {
+ verify(storage).addLibSessionContacts(argThat {
first().let { it.id == newContactId && it.approved } && size == 1
}, any())
- verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true))
+ verify(storage).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true))
}
@Test
@@ -123,7 +109,7 @@ class LibSessionTests {
val storageSpy = spy(app.storage)
app.storage = storageSpy
- val randomRecipient = randomAccountId()
+ val randomRecipient = randomSessionId()
val newContact = Contact(
id = randomRecipient,
approved = true,
@@ -158,7 +144,7 @@ class LibSessionTests {
app.storage = storageSpy
// Initial state
- val randomRecipient = randomAccountId()
+ val randomRecipient = randomSessionId()
val currentContact = Contact(
id = randomRecipient,
approved = true,
diff --git a/app/src/androidTest/java/network/loki/messenger/util/LoginAutomation.kt b/app/src/androidTest/java/network/loki/messenger/util/LoginAutomation.kt
new file mode 100644
index 00000000000..e7a3ce107cf
--- /dev/null
+++ b/app/src/androidTest/java/network/loki/messenger/util/LoginAutomation.kt
@@ -0,0 +1,82 @@
+package network.loki.messenger.util
+
+import android.Manifest
+import android.view.View
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.UiController
+import androidx.test.espresso.ViewAction
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.platform.app.InstrumentationRegistry
+import com.adevinta.android.barista.interaction.PermissionGranter
+import network.loki.messenger.R
+import org.hamcrest.Matcher
+import org.hamcrest.Matchers
+import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
+import org.session.libsession.utilities.TextSecurePreferences
+import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
+import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBar
+import org.thoughtcrime.securesms.mms.GlideApp
+
+fun setupLoggedInState(hasViewedSeed: Boolean = false) {
+ // landing activity
+ onView(ViewMatchers.withId(R.id.registerButton)).perform(ViewActions.click())
+ // session ID - register activity
+ onView(ViewMatchers.withId(R.id.registerButton)).perform(ViewActions.click())
+ // display name selection
+ onView(ViewMatchers.withId(R.id.displayNameEditText))
+ .perform(ViewActions.typeText("test-user123"))
+ onView(ViewMatchers.withId(R.id.registerButton)).perform(ViewActions.click())
+ // PN select
+ if (hasViewedSeed) {
+ // has viewed seed is set to false after register activity
+ TextSecurePreferences.setHasViewedSeed(
+ InstrumentationRegistry.getInstrumentation().targetContext,
+ true
+ )
+ }
+ onView(ViewMatchers.withId(R.id.backgroundPollingOptionView))
+ .perform(ViewActions.click())
+ onView(ViewMatchers.withId(R.id.registerButton)).perform(ViewActions.click())
+ // allow notification permission
+ PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS)
+}
+
+fun ConversationActivityV2.sendMessage(messageToSend: String, linkPreview: LinkPreview? = null) {
+ // assume in chat activity
+ onView(
+ Matchers.allOf(
+ ViewMatchers.isDescendantOfA(ViewMatchers.withId(R.id.inputBar)),
+ ViewMatchers.withId(R.id.inputBarEditText)
+ )
+ ).perform(ViewActions.replaceText(messageToSend))
+ if (linkPreview != null) {
+ val glide = GlideApp.with(this)
+ this.findViewById(R.id.inputBar).updateLinkPreviewDraft(glide, linkPreview)
+ }
+ onView(
+ Matchers.allOf(
+ ViewMatchers.isDescendantOfA(ViewMatchers.withId(R.id.inputBar)),
+ InputBarButtonDrawableMatcher.inputButtonWithDrawable(R.drawable.ic_arrow_up)
+ )
+ ).perform(ViewActions.click())
+ // TODO: text can flaky on cursor reload, figure out a better way to wait for the UI to settle with new data
+ onView(ViewMatchers.isRoot()).perform(waitFor(500))
+}
+
+/**
+ * Perform action of waiting for a specific time.
+ */
+fun waitFor(millis: Long): ViewAction {
+ return object : ViewAction {
+ override fun getConstraints(): Matcher? {
+ return ViewMatchers.isRoot()
+ }
+
+ override fun getDescription(): String = "Wait for $millis milliseconds."
+
+ override fun perform(uiController: UiController, view: View?) {
+ uiController.loopMainThreadForAtLeast(millis)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/network/loki/messenger/util/StorageUtility.kt b/app/src/androidTest/java/network/loki/messenger/util/StorageUtility.kt
new file mode 100644
index 00000000000..7b02efad68c
--- /dev/null
+++ b/app/src/androidTest/java/network/loki/messenger/util/StorageUtility.kt
@@ -0,0 +1,31 @@
+package network.loki.messenger.util
+
+import androidx.test.platform.app.InstrumentationRegistry
+import org.mockito.kotlin.spy
+import org.session.libsignal.utilities.hexEncodedPublicKey
+import org.thoughtcrime.securesms.ApplicationContext
+import org.thoughtcrime.securesms.crypto.KeyPairUtilities
+import org.thoughtcrime.securesms.database.Storage
+import kotlin.random.Random
+
+fun maybeGetUserInfo(): Pair? {
+ 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
+}
+
+fun ApplicationContext.applySpiedStorage(): Storage {
+ val storageSpy = spy(storage)!!
+ storage = storageSpy
+ return storageSpy
+}
+
+fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() }
+fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray())
+fun randomSessionId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey
diff --git a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushModule.kt b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushModule.kt
index 26a484df16f..9527e3462b1 100644
--- a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushModule.kt
+++ b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushModule.kt
@@ -4,6 +4,7 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
+import org.session.libsession.messaging.notifications.TokenFetcher
@Module
@InstallIn(SingletonComponent::class)
diff --git a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt
index 0a5c14fd421..a246acbac9c 100644
--- a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt
+++ b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt
@@ -4,18 +4,16 @@ import android.os.Bundle
import com.huawei.hms.push.HmsMessageService
import com.huawei.hms.push.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
-import org.json.JSONException
-import org.session.libsession.utilities.TextSecurePreferences
+import org.session.libsession.messaging.notifications.TokenFetcher
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Log
-import java.lang.Exception
import javax.inject.Inject
private val TAG = HuaweiPushService::class.java.simpleName
@AndroidEntryPoint
class HuaweiPushService: HmsMessageService() {
- @Inject lateinit var pushRegistry: PushRegistry
+ @Inject lateinit var tokenFetcher: TokenFetcher
@Inject lateinit var pushReceiver: PushReceiver
override fun onMessageReceived(message: RemoteMessage?) {
@@ -25,16 +23,15 @@ class HuaweiPushService: HmsMessageService() {
}
override fun onNewToken(token: String?) {
- pushRegistry.register(token)
+ if (token != null) {
+ tokenFetcher.onNewToken(token)
+ }
}
override fun onNewToken(token: String?, bundle: Bundle?) {
Log.d(TAG, "New HCM token: $token.")
- pushRegistry.register(token)
- }
-
- override fun onDeletedMessages() {
- Log.d(TAG, "onDeletedMessages")
- pushRegistry.refresh(false)
+ if (token != null) {
+ tokenFetcher.onNewToken(token)
+ }
}
}
diff --git a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt
index f0ce294596a..2c32cb5a294 100644
--- a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt
+++ b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt
@@ -2,14 +2,13 @@ package org.thoughtcrime.securesms.notifications
import android.content.Context
import com.huawei.hms.aaid.HmsInstanceId
-import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-import org.session.libsignal.utilities.Log
+import org.session.libsession.messaging.notifications.TokenFetcher
import javax.inject.Inject
import javax.inject.Singleton
@@ -19,13 +18,20 @@ private const val TOKEN_SCOPE = "HCM"
@Singleton
class HuaweiTokenFetcher @Inject constructor(
@ApplicationContext private val context: Context,
- private val pushRegistry: Lazy,
): TokenFetcher {
- override suspend fun fetch(): String? = HmsInstanceId.getInstance(context).run {
- // https://developer.huawei.com/consumer/en/doc/development/HMS-Guides/push-basic-capability#h2-1576218800370
- // getToken may return an empty string, if so HuaweiPushService#onNewToken will be called.
- withContext(Dispatchers.IO) {
- getToken(APP_ID, TOKEN_SCOPE)
+ override val token = MutableStateFlow(null)
+
+ override fun onNewToken(token: String) {
+ this.token.value = token
+ }
+
+ init {
+ GlobalScope.launch {
+ val instanceId = HmsInstanceId.getInstance(context)
+ withContext(Dispatchers.Default) {
+ instanceId.getToken(APP_ID, TOKEN_SCOPE)
+ }
}
+
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index eac4ef33001..eeead1c3cef 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -154,7 +154,13 @@
android:label="@string/conversationsBlockedContacts"
/>
+
+
-
messageNotifierLazy;
+ @Inject LokiAPIDatabase apiDB;
+ @Inject EmojiSearchDatabase emojiSearchDb;
private volatile boolean isAppVisible;
@@ -162,14 +180,21 @@ public static ApplicationContext getInstance(Context context) {
return (ApplicationContext) context.getApplicationContext();
}
+ @Deprecated(message = "Use proper DI to inject this component")
public TextSecurePreferences getPrefs() {
return EntryPoints.get(getApplicationContext(), AppComponent.class).getPrefs();
}
+ @Deprecated(message = "Use proper DI to inject this component")
public DatabaseComponent getDatabaseComponent() {
return EntryPoints.get(getApplicationContext(), DatabaseComponent.class);
}
+ @Deprecated(message = "Use proper DI to inject this component")
+ public MessageNotifier getMessageNotifier() {
+ return messageNotifierLazy.get();
+ }
+
public Handler getConversationListNotificationHandler() {
if (this.conversationListHandlerThread == null) {
conversationListHandlerThread = new HandlerThread("ConversationListHandler");
@@ -193,12 +218,12 @@ public PersistentLogger getPersistentLogger() {
}
@Override
- public void notifyUpdates(@NonNull ConfigBase forConfigObject, long messageTimestamp) {
- // forward to the config factory / storage ig
- if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) {
- textSecurePreferences.setConfigurationMessageSynced(true);
+ public void toast(@StringRes int stringRes, int toastLength, @NonNull Map parameters) {
+ Phrase builder = Phrase.from(this, stringRes);
+ for (Map.Entry entry : parameters.entrySet()) {
+ builder.put(entry.getKey(), entry.getValue());
}
- storage.notifyConfigUpdates(forConfigObject, messageTimestamp);
+ Toast.makeText(getApplicationContext(), builder.format(), toastLength).show();
}
@Override
@@ -214,9 +239,12 @@ public void onCreate() {
storage,
device,
messageDataProvider,
- ()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this),
configFactory,
- lastSentTimestampCache
+ lastSentTimestampCache,
+ this,
+ tokenFetcher,
+ groupManagerV2,
+ snodeClock
);
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
Log.i(TAG, "onCreate()");
@@ -227,18 +255,11 @@ public void onCreate() {
NotificationChannels.create(this);
ProcessLifecycleOwner.get().getLifecycle().addObserver(this);
AppContext.INSTANCE.configureKovenant();
- messageNotifier = new OptimizedMessageNotifier(new DefaultMessageNotifier());
broadcaster = new Broadcaster(this);
- LokiAPIDatabase apiDB = getDatabaseComponent().lokiAPIDatabase();
boolean useTestNet = textSecurePreferences.getEnvironment() == Environment.TEST_NET;
SnodeModule.Companion.configure(apiDB, broadcaster, useTestNet);
- initializeExpiringMessageManager();
- initializeTypingStatusRepository();
- initializeTypingStatusSender();
- initializeReadReceiptManager();
- initializeProfileManager();
initializePeriodicTasks();
- SSKEnvironment.Companion.configure(getTypingStatusRepository(), getReadReceiptManager(), getProfileManager(), messageNotifier, getExpiringMessageManager());
+ SSKEnvironment.Companion.configure(typingStatusRepository, readReceiptManager, profileManager, getMessageNotifier(), expiringMessageManager);
initializeWebRtc();
initializeBlobProvider();
resubmitProfilePictureIfNeeded();
@@ -248,6 +269,12 @@ public void onCreate() {
NetworkConstraint networkConstraint = new NetworkConstraint.Factory(this).create();
HTTP.INSTANCE.setConnectedToNetwork(networkConstraint::isMet);
+ snodeClock.start();
+ pushRegistrationHandler.run();
+ configUploader.start();
+ configToDatabaseSync.start();
+ removeGroupMemberHandler.start();
+
// add our shortcut debug menu if we are not in a release build
if (BuildConfig.BUILD_TYPE != "release") {
// add the config settings shortcut
@@ -285,6 +312,7 @@ public void onStart(@NonNull LifecycleOwner owner) {
startPollingIfNeeded();
OpenGroupManager.INSTANCE.startPolling();
+ return Unit.INSTANCE;
});
// fetch last version data
@@ -296,11 +324,12 @@ public void onStop(@NonNull LifecycleOwner owner) {
isAppVisible = false;
Log.i(TAG, "App is no longer visible.");
KeyCachingService.onAppBackgrounded(this);
- messageNotifier.setVisibleThread(-1);
+ getMessageNotifier().setVisibleThread(-1);
if (poller != null) {
poller.stopIfNeeded();
}
- ClosedGroupPollerV2.getShared().stopAll();
+ pollerFactory.stopAll();
+ LegacyClosedGroupPollerV2.getShared().stopAll();
versionDataFetcher.stopTimedVersionCheck();
}
@@ -308,29 +337,36 @@ public void onStop(@NonNull LifecycleOwner owner) {
public void onTerminate() {
stopKovenant(); // Loki
OpenGroupManager.INSTANCE.stopPolling();
+ pollerFactory.stopAll();
versionDataFetcher.stopTimedVersionCheck();
super.onTerminate();
}
+ @Deprecated(message = "Use proper DI to inject this component")
public ExpiringMessageManager getExpiringMessageManager() {
return expiringMessageManager;
}
+ @Deprecated(message = "Use proper DI to inject this component")
public TypingStatusRepository getTypingStatusRepository() {
return typingStatusRepository;
}
+ @Deprecated(message = "Use proper DI to inject this component")
public TypingStatusSender getTypingStatusSender() {
return typingStatusSender;
}
+ @Deprecated(message = "Use proper DI to inject this component")
+ public TextSecurePreferences getTextSecurePreferences() {
+ return textSecurePreferences;
+ }
+
+ @Deprecated(message = "Use proper DI to inject this component")
public ReadReceiptManager getReadReceiptManager() {
return readReceiptManager;
}
- public ProfileManager getProfileManager() {
- return profileManager;
- }
public boolean isAppVisible() {
return isAppVisible;
@@ -374,26 +410,6 @@ private void initializeCrashHandling() {
Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionLogger(originalHandler));
}
- private void initializeExpiringMessageManager() {
- this.expiringMessageManager = new ExpiringMessageManager(this);
- }
-
- private void initializeTypingStatusRepository() {
- this.typingStatusRepository = new TypingStatusRepository();
- }
-
- private void initializeReadReceiptManager() {
- this.readReceiptManager = new ReadReceiptManager();
- }
-
- private void initializeProfileManager() {
- this.profileManager = new ProfileManager(this, configFactory);
- }
-
- private void initializeTypingStatusSender() {
- this.typingStatusSender = new TypingStatusSender(this);
- }
-
private void initializePeriodicTasks() {
BackgroundPollWorker.schedulePeriodic(this);
}
@@ -414,13 +430,9 @@ private void initializeBlobProvider() {
private static class ProviderInitializationException extends RuntimeException { }
private void setUpPollingIfNeeded() {
- String userPublicKey = TextSecurePreferences.getLocalNumber(this);
+ String userPublicKey = textSecurePreferences.getLocalNumber();
if (userPublicKey == null) return;
- if (poller != null) {
- poller.setUserPublicKey(userPublicKey);
- return;
- }
- poller = new Poller(configFactory, new Timer());
+ poller = new Poller(configFactory, storage, lokiAPIDatabase);
}
public void startPollingIfNeeded() {
@@ -428,7 +440,8 @@ public void startPollingIfNeeded() {
if (poller != null) {
poller.startIfNeeded();
}
- ClosedGroupPollerV2.getShared().start();
+ pollerFactory.startAll();
+ LegacyClosedGroupPollerV2.getShared().start();
}
public void retrieveUserProfile() {
@@ -444,7 +457,6 @@ private void resubmitProfilePictureIfNeeded() {
private void loadEmojiSearchIndexIfNeeded() {
Executors.newSingleThreadExecutor().execute(() -> {
- EmojiSearchDatabase emojiSearchDb = getDatabaseComponent().emojiSearchDatabase();
if (emojiSearchDb.query("face", 1).isEmpty()) {
try (InputStream inputStream = getAssets().open("emoji/emoji_search_index.json")) {
List searchIndex = Arrays.asList(JsonUtil.fromJson(inputStream, EmojiSearchData[].class));
@@ -470,7 +482,7 @@ public boolean clearAllData() {
Log.d("Loki", "Failed to delete database.");
return false;
}
- configFactory.keyPairChanged();
+ configFactory.clearAll();
return true;
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/DatabaseUpgradeActivity.java b/app/src/main/java/org/thoughtcrime/securesms/DatabaseUpgradeActivity.java
index 31d146c45f9..8682579b2c3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/DatabaseUpgradeActivity.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/DatabaseUpgradeActivity.java
@@ -49,7 +49,7 @@ private void updateNotifications(final Context context) {
new AsyncTask() {
@Override
protected Void doInBackground(Void... params) {
- ApplicationContext.getInstance(context).messageNotifier.updateNotification(context);
+ ApplicationContext.getInstance(context).getMessageNotifier().updateNotification(context);
return null;
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java
index 00be42e299a..c714aa0eeaf 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java
@@ -406,6 +406,7 @@ private void forward() {
@SuppressWarnings("CodeBlock2Expr")
@SuppressLint("InlinedApi")
private void saveToDisk() {
+ Log.w("ACL", "Asked to save to disk!");
MediaItem mediaItem = getCurrentMediaItem();
if (mediaItem == null) return;
@@ -439,7 +440,7 @@ private String getPermanentlyDeniedStorageText(){
}
private void sendMediaSavedNotificationIfNeeded() {
- if (conversationRecipient.isGroupRecipient()) return;
+ if (conversationRecipient.isGroupOrCommunityRecipient()) return;
DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset()));
MessageSender.send(message, conversationRecipient.getAddress());
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt
index 3e9fb7bcfb1..e948f9da3c6 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt
@@ -151,11 +151,11 @@ class SessionDialogBuilder(val context: Context) {
fun dangerButton(
@StringRes text: Int,
- @StringRes contentDescription: Int = text,
+ @StringRes contentDescriptionRes: Int = text,
listener: () -> Unit = {}
) = button(
text,
- contentDescription,
+ contentDescriptionRes,
R.style.Widget_Session_Button_Dialog_DangerText,
) { listener() }
diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt
index 06e344a2392..6d1ff33d6a2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt
@@ -7,7 +7,6 @@ import org.greenrobot.eventbus.EventBus
import org.session.libsession.database.MessageDataProvider
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.MarkAsDeletedMessage
-import org.session.libsession.messaging.messages.control.UnsendRequest
import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState
@@ -249,6 +248,51 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
}
}
+ override fun markMessagesAsDeleted(
+ threadId: Long,
+ serverHashes: List,
+ displayedMessage: String
+ ) {
+ val sendersForHashes = DatabaseComponent.get(context).lokiMessageDatabase()
+ .getSendersForHashes(threadId, serverHashes.toSet())
+
+ val smsMessages = sendersForHashes.asSequence()
+ .filter { it.isSms }
+ .map { msg -> MarkAsDeletedMessage(messageId = msg.messageId, isOutgoing = msg.isOutgoing) }
+ .toList()
+
+ val mmsMessages = sendersForHashes.asSequence()
+ .filter { !it.isSms }
+ .map { msg -> MarkAsDeletedMessage(messageId = msg.messageId, isOutgoing = msg.isOutgoing) }
+ .toList()
+
+ markMessagesAsDeleted(smsMessages, isSms = true, displayedMessage)
+ markMessagesAsDeleted(mmsMessages, isSms = false, displayedMessage)
+ }
+
+ override fun markUserMessagesAsDeleted(
+ threadId: Long,
+ until: Long,
+ sender: String,
+ displayedMessage: String
+ ) {
+ val mmsMessages = mutableListOf()
+ val smsMessages = mutableListOf()
+
+ DatabaseComponent.get(context).mmsSmsDatabase().getUserMessages(threadId, sender)
+ .filter { it.timestamp <= until }
+ .forEach { record ->
+ if (record.isMms) {
+ mmsMessages.add(MarkAsDeletedMessage(record.id, record.isOutgoing))
+ } else {
+ smsMessages.add(MarkAsDeletedMessage(record.id, record.isOutgoing))
+ }
+ }
+
+ markMessagesAsDeleted(smsMessages, isSms = true, displayedMessage)
+ markMessagesAsDeleted(mmsMessages, isSms = false, displayedMessage)
+ }
+
override fun getServerHashForMessage(messageID: Long, mms: Boolean): String? =
DatabaseComponent.get(context).lokiMessageDatabase().getMessageServerHash(messageID, mms)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt
index 70834a02ded..18bf937cad4 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt
@@ -53,19 +53,19 @@ class ProfilePictureView @JvmOverloads constructor(
fun update(recipient: Recipient) {
this.recipient = recipient
- recipient.run { update(address, isClosedGroupRecipient, isOpenGroupInboxRecipient) }
+ recipient.run { update(address, isLegacyGroupRecipient, isCommunityInboxRecipient) }
}
fun update(
address: Address,
- isClosedGroupRecipient: Boolean = false,
- isOpenGroupInboxRecipient: Boolean = false
+ isLegacyGroupRecipient: Boolean = false,
+ isCommunityInboxRecipient: Boolean = false
) {
fun getUserDisplayName(publicKey: String): String = prefs.takeIf { userPublicKey == publicKey }?.getProfileName()
?: DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)?.displayName(Contact.ContactContext.REGULAR)
?: publicKey
- if (isClosedGroupRecipient) {
+ if (isLegacyGroupRecipient) {
val members = DatabaseComponent.get(context).groupDatabase()
.getGroupMemberAddresses(address.toGroupString(), true)
.sorted()
@@ -83,7 +83,7 @@ class ProfilePictureView @JvmOverloads constructor(
additionalPublicKey = apk
additionalDisplayName = getUserDisplayName(apk)
}
- } else if(isOpenGroupInboxRecipient) {
+ } else if(isCommunityInboxRecipient) {
val publicKey = GroupUtil.getDecodedOpenGroupInboxAccountId(address.serialize())
this.publicKey = publicKey
displayName = getUserDisplayName(publicKey)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java
index c2922fbfc01..d4c1ea98bb1 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java
@@ -3,32 +3,35 @@
import android.annotation.SuppressLint;
import android.content.Context;
-import androidx.annotation.NonNull;
import org.session.libsession.messaging.messages.control.TypingIndicator;
import org.session.libsession.messaging.sending_receiving.MessageSender;
import org.session.libsession.utilities.Util;
import org.session.libsession.utilities.recipients.Recipient;
import org.thoughtcrime.securesms.database.ThreadDatabase;
-import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.util.SessionMetaProtocol;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
@SuppressLint("UseSparseArrays")
+@Singleton
public class TypingStatusSender {
private static final long REFRESH_TYPING_TIMEOUT = TimeUnit.SECONDS.toMillis(10);
private static final long PAUSE_TYPING_TIMEOUT = TimeUnit.SECONDS.toMillis(3);
- private final Context context;
private final Map selfTypingTimers;
+ private final ThreadDatabase threadDatabase;
- public TypingStatusSender(@NonNull Context context) {
- this.context = context;
- this.selfTypingTimers = new HashMap<>();
+ @Inject
+ public TypingStatusSender(ThreadDatabase threadDatabase) {
+ this.threadDatabase = threadDatabase;
+ this.selfTypingTimers = new HashMap<>();
}
public synchronized void onTypingStarted(long threadId) {
@@ -77,7 +80,6 @@ private synchronized void onTypingStopped(long threadId, boolean notify) {
}
private void sendTyping(long threadId, boolean typingStarted) {
- ThreadDatabase threadDatabase = DatabaseComponent.get(context).threadDatabase();
Recipient recipient = threadDatabase.getRecipientForThreadId(threadId);
if (recipient == null) { return; }
if (!SessionMetaProtocol.shouldSendTypingIndicator(recipient)) { return; }
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt
index 7b92e505c6b..2e7c027b1e0 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt
@@ -8,7 +8,6 @@ import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.view.isGone
-import androidx.core.widget.ImageViewCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.CoroutineScope
diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt
new file mode 100644
index 00000000000..3b5ba393f9c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt
@@ -0,0 +1,408 @@
+package org.thoughtcrime.securesms.configs
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.supervisorScope
+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.ReadableGroupInfoConfig
+import network.loki.messenger.libsession_util.ReadableUserGroupsConfig
+import network.loki.messenger.libsession_util.ReadableUserProfile
+import network.loki.messenger.libsession_util.util.BaseCommunityInfo
+import network.loki.messenger.libsession_util.util.Contact
+import network.loki.messenger.libsession_util.util.Conversation
+import network.loki.messenger.libsession_util.util.ExpiryMode
+import network.loki.messenger.libsession_util.util.GroupInfo
+import network.loki.messenger.libsession_util.util.UserPic
+import network.loki.messenger.libsession_util.util.afterSend
+import org.session.libsession.database.StorageProtocol
+import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
+import org.session.libsession.messaging.jobs.JobQueue
+import org.session.libsession.messaging.messages.ExpirationConfiguration
+import org.session.libsession.messaging.open_groups.OpenGroup
+import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
+import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2
+import org.session.libsession.snode.SnodeClock
+import org.session.libsession.utilities.Address.Companion.fromSerialized
+import org.session.libsession.utilities.ConfigFactoryProtocol
+import org.session.libsession.utilities.ConfigUpdateNotification
+import org.session.libsession.utilities.GroupUtil
+import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol.Companion.NAME_PADDED_LENGTH
+import org.session.libsession.utilities.TextSecurePreferences
+import org.session.libsession.utilities.UserConfigType
+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.utilities.AccountId
+import org.session.libsignal.utilities.Log
+import org.thoughtcrime.securesms.database.MmsDatabase
+import org.thoughtcrime.securesms.database.RecipientDatabase
+import org.thoughtcrime.securesms.database.ThreadDatabase
+import org.thoughtcrime.securesms.dependencies.PollerFactory
+import org.thoughtcrime.securesms.groups.ClosedGroupManager
+import org.thoughtcrime.securesms.groups.OpenGroupManager
+import org.thoughtcrime.securesms.sskenvironment.ProfileManager
+import javax.inject.Inject
+
+private const val TAG = "ConfigToDatabaseSync"
+
+/**
+ * This class is responsible for syncing config system's data into the database.
+ *
+ * It does so by listening to the [ConfigFactoryProtocol.configUpdateNotifications] and updating the database accordingly.
+ *
+ * @see ConfigUploader For upload config system data into swarm automagically.
+ */
+class ConfigToDatabaseSync @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val configFactory: ConfigFactoryProtocol,
+ private val storage: StorageProtocol,
+ private val threadDatabase: ThreadDatabase,
+ private val recipientDatabase: RecipientDatabase,
+ private val mmsDatabase: MmsDatabase,
+ private val pollerFactory: PollerFactory,
+ private val clock: SnodeClock,
+ private val profileManager: ProfileManager,
+ private val preferences: TextSecurePreferences,
+) {
+ private var job: Job? = null
+
+ fun start() {
+ require(job == null) { "Already started" }
+
+ @Suppress("OPT_IN_USAGE")
+ job = GlobalScope.launch {
+ supervisorScope {
+ val job1 = async {
+ configFactory.configUpdateNotifications
+ .filterIsInstance()
+ .debounce(800L)
+ .collect { config ->
+ try {
+ Log.i(TAG, "Start syncing user configs")
+ syncUserConfigs(config.configType, config.timestamp)
+ Log.i(TAG, "Finished syncing user configs")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error syncing user configs", e)
+ }
+ }
+ }
+
+ val job2 = async {
+ configFactory.configUpdateNotifications
+ .filterIsInstance()
+ .collect {
+ syncGroupConfigs(it.groupId)
+ }
+ }
+
+ job1.await()
+ job2.await()
+ }
+ }
+ }
+
+ private fun syncGroupConfigs(groupId: AccountId) {
+ val info = configFactory.withGroupConfigs(groupId) {
+ UpdateGroupInfo(it.groupInfo)
+ }
+
+ updateGroup(info)
+ }
+
+ private fun syncUserConfigs(userConfigType: UserConfigType, updateTimestamp: Long) {
+ val configUpdate = configFactory.withUserConfigs { configs ->
+ when (userConfigType) {
+ UserConfigType.USER_PROFILE -> UpdateUserInfo(configs.userProfile)
+ UserConfigType.USER_GROUPS -> UpdateUserGroupsInfo(configs.userGroups)
+ UserConfigType.CONTACTS -> UpdateContacts(configs.contacts.all())
+ UserConfigType.CONVO_INFO_VOLATILE -> UpdateConvoVolatile(configs.convoInfoVolatile.all())
+ }
+ }
+
+ when (configUpdate) {
+ is UpdateUserInfo -> updateUser(configUpdate, updateTimestamp)
+ is UpdateUserGroupsInfo -> updateUserGroups(configUpdate, updateTimestamp)
+ is UpdateContacts -> updateContacts(configUpdate, updateTimestamp)
+ is UpdateConvoVolatile -> updateConvoVolatile(configUpdate)
+ else -> error("Unknown config update type: $configUpdate")
+ }
+ }
+
+ private data class UpdateUserInfo(
+ val name: String?,
+ val userPic: UserPic,
+ val ntsPriority: Long,
+ val ntsExpiry: ExpiryMode
+ ) {
+ constructor(profile: ReadableUserProfile) : this(
+ name = profile.getName(),
+ userPic = profile.getPic(),
+ ntsPriority = profile.getNtsPriority(),
+ ntsExpiry = profile.getNtsExpiry()
+ )
+ }
+
+ private fun updateUser(userProfile: UpdateUserInfo, messageTimestamp: Long) {
+ val userPublicKey = storage.getUserPublicKey() ?: return
+ // would love to get rid of recipient and context from this
+ val recipient = Recipient.from(context, fromSerialized(userPublicKey), false)
+
+ // Update profile name
+ userProfile.name?.takeUnless { it.isEmpty() }?.truncate(NAME_PADDED_LENGTH)?.let {
+ preferences.setProfileName(it)
+ profileManager.setName(context, recipient, it)
+ }
+
+ // Update profile picture
+ if (userProfile.userPic == UserPic.DEFAULT) {
+ storage.clearUserPic(clearConfig = false)
+ } else if (userProfile.userPic.key.isNotEmpty() && userProfile.userPic.url.isNotEmpty()
+ && preferences.getProfilePictureURL() != userProfile.userPic.url
+ ) {
+ storage.setUserProfilePicture(userProfile.userPic.url, userProfile.userPic.key)
+ }
+
+ if (userProfile.ntsPriority == PRIORITY_HIDDEN) {
+ // delete nts thread if needed
+ val ourThread = storage.getThreadId(recipient) ?: return
+ storage.deleteConversation(ourThread)
+ } else {
+ // create note to self thread if needed (?)
+ val address = recipient.address
+ val ourThread = storage.getThreadId(address) ?: storage.getOrCreateThreadIdFor(address).also {
+ storage.setThreadDate(it, 0)
+ }
+ threadDatabase.setHasSent(ourThread, true)
+ storage.setPinned(ourThread, userProfile.ntsPriority > 0)
+ }
+
+ // Set or reset the shared library to use latest expiration config
+ storage.getThreadId(recipient)?.let {
+ storage.setExpirationConfiguration(
+ storage.getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp } ?:
+ ExpirationConfiguration(it, userProfile.ntsExpiry, messageTimestamp)
+ )
+ }
+ }
+
+ private data class UpdateGroupInfo(
+ val id: AccountId,
+ val name: String?,
+ val destroyed: Boolean,
+ val deleteBefore: Long?,
+ val deleteAttachmentsBefore: Long?
+ ) {
+ constructor(groupInfoConfig: ReadableGroupInfoConfig) : this(
+ id = groupInfoConfig.id(),
+ name = groupInfoConfig.getName(),
+ destroyed = groupInfoConfig.isDestroyed(),
+ deleteBefore = groupInfoConfig.getDeleteBefore(),
+ deleteAttachmentsBefore = groupInfoConfig.getDeleteAttachmentsBefore()
+ )
+ }
+
+ private fun updateGroup(groupInfoConfig: UpdateGroupInfo) {
+ val threadId = storage.getThreadId(fromSerialized(groupInfoConfig.id.hexString)) ?: return
+ val recipient = storage.getRecipientForThread(threadId) ?: return
+ recipientDatabase.setProfileName(recipient, groupInfoConfig.name)
+ profileManager.setName(context, recipient, groupInfoConfig.name ?: "")
+
+ if (groupInfoConfig.destroyed) {
+ storage.clearMessages(threadId)
+ } else {
+ groupInfoConfig.deleteBefore?.let { removeBefore ->
+ storage.trimThreadBefore(threadId, removeBefore)
+ }
+ groupInfoConfig.deleteAttachmentsBefore?.let { removeAttachmentsBefore ->
+ mmsDatabase.deleteMessagesInThreadBeforeDate(threadId, removeAttachmentsBefore, onlyMedia = true)
+ }
+ }
+ }
+
+ private data class UpdateContacts(val contacts: List)
+
+ private fun updateContacts(contacts: UpdateContacts, messageTimestamp: Long) {
+ storage.addLibSessionContacts(contacts.contacts, messageTimestamp)
+ }
+
+ private data class UpdateUserGroupsInfo(
+ val communityInfo: List,
+ val legacyGroupInfo: List,
+ val closedGroupInfo: List
+ ) {
+ constructor(userGroups: ReadableUserGroupsConfig) : this(
+ communityInfo = userGroups.allCommunityInfo(),
+ legacyGroupInfo = userGroups.allLegacyGroupInfo(),
+ closedGroupInfo = userGroups.allClosedGroupInfo()
+ )
+ }
+
+ private fun updateUserGroups(userGroups: UpdateUserGroupsInfo, messageTimestamp: Long) {
+ val localUserPublicKey = storage.getUserPublicKey() ?: return Log.w(
+ TAG,
+ "No user public key when trying to update user groups from config"
+ )
+ val allOpenGroups = storage.getAllOpenGroups()
+ val toDeleteCommunities = allOpenGroups.filter {
+ Conversation.Community(BaseCommunityInfo(it.value.server, it.value.room, it.value.publicKey), 0, false).baseCommunityInfo.fullUrl() !in userGroups.communityInfo.map { it.community.fullUrl() }
+ }
+
+ val existingCommunities: Map = allOpenGroups.filterKeys { it !in toDeleteCommunities.keys }
+ val toAddCommunities = userGroups.communityInfo.filter { it.community.fullUrl() !in existingCommunities.map { it.value.joinURL } }
+ val existingJoinUrls = existingCommunities.values.map { it.joinURL }
+
+ val existingLegacyClosedGroups = storage.getAllGroups(includeInactive = true).filter { it.isLegacyGroup }
+ val lgcIds = userGroups.legacyGroupInfo.map { it.accountId }
+ val toDeleteLegacyClosedGroups = existingLegacyClosedGroups.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)
+ }
+
+ toDeleteLegacyClosedGroups.forEach { deleteGroup ->
+ val threadId = storage.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 (!storage.hasBackgroundGroupAddJob(joinUrl)) {
+ JobQueue.shared.add(BackgroundGroupAddJob(joinUrl))
+ }
+ }
+
+ for (groupInfo in userGroups.communityInfo) {
+ val groupBaseCommunity = groupInfo.community
+ if (groupBaseCommunity.fullUrl() in existingJoinUrls) {
+ // add it
+ val (threadId, _) = existingCommunities.entries.first { (_, v) -> v.joinURL == groupInfo.community.fullUrl() }
+ threadDatabase.setPinned(threadId, groupInfo.priority == PRIORITY_PINNED)
+ }
+ }
+
+ val existingClosedGroupThreads: Map = threadDatabase.readerFor(threadDatabase.conversationList).use { reader ->
+ buildMap(reader.count) {
+ var current = reader.next
+ while (current != null) {
+ if (current.recipient?.isGroupV2Recipient == true) {
+ put(AccountId(current.recipient.address.serialize()), current.threadId)
+ }
+
+ current = reader.next
+ }
+ }
+ }
+
+ val groupThreadsToKeep = hashMapOf()
+
+ for (closedGroup in userGroups.closedGroupInfo) {
+ val recipient = Recipient.from(context, fromSerialized(closedGroup.groupAccountId.hexString), false)
+ storage.setRecipientApprovedMe(recipient, true)
+ storage.setRecipientApproved(recipient, !closedGroup.invited)
+ val threadId = storage.getOrCreateThreadIdFor(recipient.address)
+ groupThreadsToKeep[closedGroup.groupAccountId] = threadId
+
+ storage.setPinned(threadId, closedGroup.priority == PRIORITY_PINNED)
+ if (!closedGroup.invited) {
+ pollerFactory.pollerFor(closedGroup.groupAccountId)?.start()
+ }
+ }
+
+ val toRemove = existingClosedGroupThreads - groupThreadsToKeep.keys
+ Log.d(TAG, "Removing ${toRemove.size} closed groups")
+ toRemove.forEach { (groupId, threadId) ->
+ pollerFactory.pollerFor(groupId)?.stop()
+ storage.removeClosedGroupThread(threadId)
+ }
+
+ for (group in userGroups.legacyGroupInfo) {
+ val groupId = GroupUtil.doubleEncodeGroupID(group.accountId)
+ val existingGroup = existingLegacyClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.accountId }
+ val existingThread = existingGroup?.let { storage.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(TAG, "Existing group had no thread to hide")
+ } else {
+ Log.d(TAG, "Setting existing group pinned status to ${group.priority}")
+ threadDatabase.setPinned(existingThread, group.priority == PRIORITY_PINNED)
+ }
+ } else {
+ val members = group.members.keys.map { fromSerialized(it) }
+ val admins = group.members.filter { it.value /*admin = true*/ }.keys.map { fromSerialized(it) }
+ val title = group.name
+ val formationTimestamp = (group.joinedAt * 1000L)
+ storage.createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp)
+ storage.setProfileSharing(fromSerialized(groupId), true)
+ // Add the group to the user's set of public keys to poll for
+ storage.addClosedGroupPublicKey(group.accountId)
+ // Store the encryption key pair
+ val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey))
+ storage.addClosedGroupEncryptionKeyPair(keyPair, group.accountId, clock.currentTimeMills())
+ // Notify the PN server
+ PushRegistryV1.subscribeGroup(group.accountId, publicKey = localUserPublicKey)
+ // Notify the user
+ val threadID = storage.getOrCreateThreadIdFor(fromSerialized(groupId))
+ threadDatabase.setDate(threadID, formationTimestamp)
+
+ // Note: Commenting out this line prevents the timestamp of room creation being added to a new closed group,
+ // which in turn allows us to show the `groupNoMessages` control message text.
+ //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
+ LegacyClosedGroupPollerV2.shared.startPolling(group.accountId)
+ }
+ storage.getThreadId(fromSerialized(groupId))?.let {
+ storage.setExpirationConfiguration(
+ storage.getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp }
+ ?: ExpirationConfiguration(it, afterSend(group.disappearingTimer), messageTimestamp)
+ )
+ }
+ }
+ }
+
+ private data class UpdateConvoVolatile(val convos: List)
+
+ private fun updateConvoVolatile(convos: UpdateConvoVolatile) {
+ val extracted = convos.convos.filterNotNull()
+ for (conversation in extracted) {
+ val threadId = when (conversation) {
+ is Conversation.OneToOne -> storage.getThreadIdFor(conversation.accountId, null, null, createThread = false)
+ is Conversation.LegacyGroup -> storage.getThreadIdFor("", conversation.groupId,null, createThread = false)
+ is Conversation.Community -> storage.getThreadIdFor("",null, "${conversation.baseCommunityInfo.baseUrl.removeSuffix("/")}.${conversation.baseCommunityInfo.room}", createThread = false)
+ is Conversation.ClosedGroup -> storage.getThreadIdFor(conversation.accountId, null, null, createThread = false) // New groups will be managed bia libsession
+ }
+ if (threadId != null) {
+ if (conversation.lastRead > storage.getLastSeen(threadId)) {
+ storage.markConversationAsRead(threadId, conversation.lastRead, force = true)
+ storage.updateThread(threadId, false)
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Truncate a string to a specified number of bytes
+ *
+ * This could split multi-byte characters/emojis.
+ */
+private fun String.truncate(sizeInBytes: Int): String =
+ toByteArray().takeIf { it.size > sizeInBytes }?.take(sizeInBytes)?.toByteArray()?.let(::String) ?: this
diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt
new file mode 100644
index 00000000000..8e8651539e0
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt
@@ -0,0 +1,277 @@
+package org.thoughtcrime.securesms.configs
+
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.supervisorScope
+import network.loki.messenger.libsession_util.util.ConfigPush
+import org.session.libsession.database.StorageProtocol
+import org.session.libsession.database.userAuth
+import org.session.libsession.snode.OwnedSwarmAuth
+import org.session.libsession.snode.SnodeAPI
+import org.session.libsession.snode.SnodeClock
+import org.session.libsession.snode.SnodeMessage
+import org.session.libsession.snode.SwarmAuth
+import org.session.libsession.snode.model.StoreMessageResponse
+import org.session.libsession.snode.utilities.await
+import org.session.libsession.utilities.ConfigFactoryProtocol
+import org.session.libsession.utilities.ConfigPushResult
+import org.session.libsession.utilities.ConfigUpdateNotification
+import org.session.libsession.utilities.UserConfigType
+import org.session.libsession.utilities.getGroup
+import org.session.libsignal.utilities.AccountId
+import org.session.libsignal.utilities.Base64
+import org.session.libsignal.utilities.Log
+import org.session.libsignal.utilities.Namespace
+import org.session.libsignal.utilities.Snode
+import org.session.libsignal.utilities.retryWithUniformInterval
+import javax.inject.Inject
+
+private const val TAG = "ConfigUploader"
+
+/**
+ * This class is responsible for sending the local config changes to the swarm.
+ *
+ * Note: This class is listening ONLY to the config system changes. If you change any local database
+ * data, this class will not be aware of it. You'll need to update the config system
+ * for this class to pick up these changes.
+ *
+ * @see ConfigToDatabaseSync For syncing the config changes to the local database.
+ *
+ * It does so by listening for changes in the config factory.
+ */
+class ConfigUploader @Inject constructor(
+ private val configFactory: ConfigFactoryProtocol,
+ private val storageProtocol: StorageProtocol,
+ private val clock: SnodeClock,
+) {
+ private var job: Job? = null
+
+ @OptIn(DelicateCoroutinesApi::class, FlowPreview::class)
+ fun start() {
+ require(job == null) { "Already started" }
+
+ job = GlobalScope.launch {
+ supervisorScope {
+ val job1 = launch {
+ configFactory.configUpdateNotifications
+ .filterIsInstance()
+ .debounce(1000L)
+ .collect {
+ try {
+ retryWithUniformInterval {
+ pushUserConfigChangesIfNeeded()
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to push user configs", e)
+ }
+ }
+ }
+
+ val job2 = launch {
+ configFactory.configUpdateNotifications
+ .filterIsInstance()
+ .debounce(1000L)
+ .collect { changes ->
+ try {
+ retryWithUniformInterval {
+ pushGroupConfigsChangesIfNeeded(changes.groupId)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to push group configs", e)
+ }
+ }
+ }
+
+ job1.join()
+ job2.join()
+ }
+ }
+ }
+
+ private suspend fun pushGroupConfigsChangesIfNeeded(groupId: AccountId) = coroutineScope {
+ // Only admin can push group configs
+ val adminKey = configFactory.getGroup(groupId)?.adminKey
+ if (adminKey == null) {
+ Log.i(TAG, "Skipping group config push without admin key")
+ return@coroutineScope
+ }
+
+ // Gather data to push
+ val (membersPush, infoPush, keysPush) = configFactory.withMutableGroupConfigs(groupId) { configs ->
+ val membersPush = if (configs.groupMembers.needsPush()) {
+ configs.groupMembers.push()
+ } else {
+ null
+ }
+
+ val infoPush = if (configs.groupInfo.needsPush()) {
+ configs.groupInfo.push()
+ } else {
+ null
+ }
+
+ Triple(membersPush, infoPush, configs.groupKeys.pendingConfig())
+ }
+
+ // Nothing to push?
+ if (membersPush == null && infoPush == null && keysPush == null) {
+ return@coroutineScope
+ }
+
+ Log.d(TAG, "Pushing group configs")
+
+ val snode = SnodeAPI.getSingleTargetSnode(groupId.hexString).await()
+ val auth = OwnedSwarmAuth.ofClosedGroup(groupId, adminKey)
+
+ // Spawn the config pushing concurrently
+ val membersConfigHashTask = membersPush?.let {
+ async {
+ membersPush to pushConfig(
+ auth,
+ snode,
+ membersPush,
+ Namespace.CLOSED_GROUP_MEMBERS()
+ )
+ }
+ }
+
+ val infoConfigHashTask = infoPush?.let {
+ async {
+ infoPush to pushConfig(auth, snode, infoPush, Namespace.CLOSED_GROUP_INFO())
+ }
+ }
+
+ // Keys push is different: it doesn't have the delete call so we don't call pushConfig
+ val keysPushResult = keysPush?.let {
+ SnodeAPI.sendBatchRequest(
+ snode = snode,
+ publicKey = auth.accountId.hexString,
+ request = SnodeAPI.buildAuthenticatedStoreBatchInfo(
+ Namespace.ENCRYPTION_KEYS(),
+ SnodeMessage(
+ auth.accountId.hexString,
+ Base64.encodeBytes(keysPush),
+ SnodeMessage.CONFIG_TTL,
+ clock.currentTimeMills(),
+ ),
+ auth
+ ),
+ responseType = StoreMessageResponse::class.java
+ ).toConfigPushResult()
+ }
+
+ // Wait for all other config push to come back
+ val memberPushResult = membersConfigHashTask?.await()
+ val infoPushResult = infoConfigHashTask?.await()
+
+ configFactory.confirmGroupConfigsPushed(
+ groupId,
+ memberPushResult,
+ infoPushResult,
+ keysPushResult
+ )
+
+ Log.i(
+ TAG,
+ "Pushed group configs, " +
+ "info = ${infoPush != null}, " +
+ "members = ${membersPush != null}, " +
+ "keys = ${keysPush != null}"
+ )
+ }
+
+ private suspend fun pushConfig(
+ auth: SwarmAuth,
+ snode: Snode,
+ push: ConfigPush,
+ namespace: Int
+ ): ConfigPushResult {
+ val response = SnodeAPI.sendBatchRequest(
+ snode = snode,
+ publicKey = auth.accountId.hexString,
+ request = SnodeAPI.buildAuthenticatedStoreBatchInfo(
+ namespace,
+ SnodeMessage(
+ auth.accountId.hexString,
+ Base64.encodeBytes(push.config),
+ SnodeMessage.CONFIG_TTL,
+ clock.currentTimeMills(),
+ ),
+ auth,
+ ),
+ responseType = StoreMessageResponse::class.java
+ )
+
+ if (push.obsoleteHashes.isNotEmpty()) {
+ SnodeAPI.sendBatchRequest(
+ snode = snode,
+ publicKey = auth.accountId.hexString,
+ request = SnodeAPI.buildAuthenticatedDeleteBatchInfo(auth, push.obsoleteHashes)
+ )
+ }
+
+ return response.toConfigPushResult()
+ }
+
+ private suspend fun pushUserConfigChangesIfNeeded() = coroutineScope {
+ val userAuth = requireNotNull(storageProtocol.userAuth) {
+ "Current user not available"
+ }
+
+ // Gather all the user configs that need to be pushed
+ val pushes = configFactory.withMutableUserConfigs { configs ->
+ UserConfigType.entries
+ .mapNotNull { type ->
+ val config = configs.getConfig(type)
+ if (!config.needsPush()) {
+ return@mapNotNull null
+ }
+
+ type to config.push()
+ }
+ }
+
+ if (pushes.isEmpty()) {
+ return@coroutineScope
+ }
+
+ Log.d(TAG, "Pushing ${pushes.size} user configs")
+
+ val snode = SnodeAPI.getSingleTargetSnode(userAuth.accountId.hexString).await()
+
+ val pushTasks = pushes.map { (configType, configPush) ->
+ async {
+ (configType to configPush) to pushConfig(
+ userAuth,
+ snode,
+ configPush,
+ configType.namespace
+ )
+ }
+ }
+
+ val pushResults =
+ pushTasks.awaitAll().associate { it.first.first to (it.first.second to it.second) }
+
+ Log.d(TAG, "Pushed ${pushResults.size} user configs")
+
+ configFactory.confirmUserConfigsPushed(
+ contacts = pushResults[UserConfigType.CONTACTS],
+ userGroups = pushResults[UserConfigType.USER_GROUPS],
+ convoInfoVolatile = pushResults[UserConfigType.CONVO_INFO_VOLATILE],
+ userProfile = pushResults[UserConfigType.USER_PROFILE]
+ )
+ }
+
+ private fun StoreMessageResponse.toConfigPushResult(): ConfigPushResult {
+ return ConfigPushResult(hash, timestamp)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt
index 0db2ec89622..7bfb8dd54c3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt
@@ -33,7 +33,7 @@ class ContactSelectionListLoader(context: Context, val mode: Int, val filter: St
}
val list = mutableListOf()
if (isFlagSet(DisplayMode.FLAG_CLOSED_GROUPS)) {
- list.addAll(getClosedGroups(contacts))
+ list.addAll(getGroups(contacts))
}
if (isFlagSet(DisplayMode.FLAG_OPEN_GROUPS)) {
list.addAll(getCommunities(contacts))
@@ -46,14 +46,12 @@ class ContactSelectionListLoader(context: Context, val mode: Int, val filter: St
private fun getContacts(contacts: List): List {
return getItems(contacts, context.getString(R.string.contactContacts)) {
- !it.isGroupRecipient
+ !it.isGroupOrCommunityRecipient
}
}
- private fun getClosedGroups(contacts: List): List {
- return getItems(contacts, context.getString(R.string.conversationsGroups)) {
- it.address.isClosedGroup
- }
+ private fun getGroups(contacts: List): List {
+ return getItems(contacts, context.getString(R.string.conversationsGroups)) { it.address.isGroup }
}
private fun getCommunities(contacts: List): List {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsLoader.kt
index b1706affeae..5b9c5ad9d1e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsLoader.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsLoader.kt
@@ -9,7 +9,7 @@ class SelectContactsLoader(context: Context, private val usersToExclude: Set {
val contacts = ContactUtilities.getAllContacts(context)
return contacts.filter {
- !it.isGroupRecipient && !usersToExclude.contains(it.address.toString()) && it.hasApprovedMe()
+ !it.isGroupOrCommunityRecipient && !usersToExclude.contains(it.address.toString()) && it.hasApprovedMe()
}.map {
it.address.toString()
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt
index 77f579e14d9..8cfd58f0979 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt
@@ -56,7 +56,7 @@ class UserView : LinearLayout {
val address = user.address.serialize()
binding.profilePictureView.update(user)
binding.actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24)
- binding.nameTextView.text = if (user.isGroupRecipient) user.name else getUserDisplayName(address)
+ binding.nameTextView.text = if (user.isGroupOrCommunityRecipient) user.name else getUserDisplayName(address)
when (actionIndicator) {
ActionIndicator.None -> {
binding.actionIndicatorImageView.visibility = View.GONE
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt
index 2a88aac463e..93ec9d0d5c4 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt
@@ -25,6 +25,7 @@ import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.LokiAPIDatabase
import org.thoughtcrime.securesms.ui.getSubbedString
+import org.thoughtcrime.securesms.database.Storage
import javax.inject.Inject
@AndroidEntryPoint
@@ -37,6 +38,7 @@ class ConversationActionBarView @JvmOverloads constructor(
@Inject lateinit var lokiApiDb: LokiAPIDatabase
@Inject lateinit var groupDb: GroupDatabase
+ @Inject lateinit var storage: Storage
var delegate: ConversationActionBarDelegate? = null
@@ -46,6 +48,9 @@ class ConversationActionBarView @JvmOverloads constructor(
}
}
+ val profilePictureView
+ get() = binding.profilePictureView
+
init {
var previousState: Int
var currentState = 0
@@ -75,7 +80,7 @@ class ConversationActionBarView @JvmOverloads constructor(
) {
this.delegate = delegate
binding.profilePictureView.layoutParams = resources.getDimensionPixelSize(
- if (recipient.isClosedGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size
+ if (recipient.isGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size
).let { LayoutParams(it, it) }
update(recipient, openGroup, config)
}
@@ -128,12 +133,16 @@ class ConversationActionBarView @JvmOverloads constructor(
)
}
- if (recipient.isGroupRecipient) {
+ if (recipient.isGroupOrCommunityRecipient) {
val title = if (recipient.isCommunityRecipient) {
val userCount = openGroup?.let { lokiApiDb.getUserCount(it.room, it.server) } ?: 0
resources.getQuantityString(R.plurals.membersActive, userCount, userCount)
} else {
- val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size
+ val userCount = if (recipient.isGroupV2Recipient) {
+ storage.getMembers(recipient.address.serialize()).size
+ } else { // legacy closed groups
+ groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size
+ }
resources.getQuantityString(R.plurals.members, userCount, userCount)
}
settings += ConversationSetting(title, ConversationSettingType.MEMBER_COUNT)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt
index e086c959245..0988094b55a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt
@@ -4,11 +4,12 @@ import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.ExpiryMode
-import org.session.libsession.messaging.MessagingModuleConfiguration
+import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.snode.SnodeAPI
+import org.session.libsession.snode.SnodeClock
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.ExpirationUtil
import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol
@@ -19,19 +20,18 @@ import org.session.libsession.utilities.getExpirationTypeDisplayValue
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.getSubbedCharSequence
-import org.thoughtcrime.securesms.ui.getSubbedString
-import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
class DisappearingMessages @Inject constructor(
- @ApplicationContext private val context: Context,
private val textSecurePreferences: TextSecurePreferences,
private val messageExpirationManager: MessageExpirationManagerProtocol,
+ private val storage: StorageProtocol,
+ private val clock: SnodeClock,
) {
fun set(threadId: Long, address: Address, mode: ExpiryMode, isGroup: Boolean) {
- val expiryChangeTimestampMs = SnodeAPI.nowWithOffset
- MessagingModuleConfiguration.shared.storage.setExpirationConfiguration(ExpirationConfiguration(threadId, mode, expiryChangeTimestampMs))
+ val expiryChangeTimestampMs = clock.currentTimeMills()
+ storage.setExpirationConfiguration(ExpirationConfiguration(threadId, mode, expiryChangeTimestampMs))
val message = ExpirationTimerUpdate(isGroup = isGroup).apply {
expiryMode = mode
@@ -43,7 +43,6 @@ class DisappearingMessages @Inject constructor(
messageExpirationManager.insertExpirationTimerMessage(message)
MessageSender.send(message, address)
- ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
fun showFollowSettingDialog(context: Context, message: MessageRecord) = context.showSessionDialog {
@@ -58,9 +57,9 @@ class DisappearingMessages @Inject constructor(
dangerButton(
text = if (message.expiresIn == 0L) R.string.confirm else R.string.set,
- contentDescription = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_setButton
+ contentDescriptionRes = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_setButton
) {
- set(message.threadId, message.recipient.address, message.expiryMode, message.recipient.isClosedGroupRecipient)
+ set(message.threadId, message.recipient.address, message.expiryMode, message.recipient.isGroupRecipient)
}
cancelButton()
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt
index 915ff669711..6cdf8678bb7 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt
@@ -19,7 +19,6 @@ import kotlinx.coroutines.launch
import network.loki.messenger.BuildConfig
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.session.libsession.messaging.messages.ExpirationConfiguration
-import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.ExpiryCallbacks
import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.UiState
@@ -32,7 +31,6 @@ class DisappearingMessagesViewModel(
private val threadId: Long,
private val application: Application,
private val textSecurePreferences: TextSecurePreferences,
- private val messageExpirationManager: MessageExpirationManagerProtocol,
private val disappearingMessages: DisappearingMessages,
private val threadDb: ThreadDatabase,
private val groupDb: GroupDatabase,
@@ -59,16 +57,27 @@ class DisappearingMessagesViewModel(
init {
viewModelScope.launch {
val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode ?: ExpiryMode.NONE
- val recipient = threadDb.getRecipientForThreadId(threadId)
- val groupRecord = recipient?.takeIf { it.isClosedGroupRecipient }
- ?.run { groupDb.getGroup(address.toGroupString()).orNull() }
+ val recipient = threadDb.getRecipientForThreadId(threadId)?: return@launch
+
+ val isAdmin = when {
+ recipient.isGroupV2Recipient -> {
+ // Handle the new closed group functionality
+ storage.getMembers(recipient.address.serialize()).any { it.sessionId == textSecurePreferences.getLocalNumber() && it.admin }
+ }
+ recipient.isLegacyGroupRecipient -> {
+ val groupRecord = groupDb.getGroup(recipient.address.toGroupString()).orNull()
+ // Handle as legacy group
+ groupRecord?.admins?.any{ it.serialize() == textSecurePreferences.getLocalNumber() } == true
+ }
+ else -> !recipient.isGroupOrCommunityRecipient
+ }
_state.update {
it.copy(
- address = recipient?.address,
- isGroup = groupRecord != null,
- isNoteToSelf = recipient?.address?.serialize() == textSecurePreferences.getLocalNumber(),
- isSelfAdmin = groupRecord == null || groupRecord.admins.any{ it.serialize() == textSecurePreferences.getLocalNumber() },
+ address = recipient.address,
+ isGroup = recipient.isGroupRecipient,
+ isNoteToSelf = recipient.address.serialize() == textSecurePreferences.getLocalNumber(),
+ isSelfAdmin = isAdmin,
expiryMode = expiryMode,
persistedMode = expiryMode
)
@@ -102,7 +111,6 @@ class DisappearingMessagesViewModel(
@Assisted private val threadId: Long,
private val application: Application,
private val textSecurePreferences: TextSecurePreferences,
- private val messageExpirationManager: MessageExpirationManagerProtocol,
private val disappearingMessages: DisappearingMessages,
private val threadDb: ThreadDatabase,
private val groupDb: GroupDatabase,
@@ -113,7 +121,6 @@ class DisappearingMessagesViewModel(
threadId,
application,
textSecurePreferences,
- messageExpirationManager,
disappearingMessages,
threadDb,
groupDb,
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt
index b066a96cc7e..15025a411af 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt
@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.conversation.disappearingmessages.ui
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -12,19 +11,19 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.ExpiryMode
+import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox
import org.thoughtcrime.securesms.ui.Callbacks
import org.thoughtcrime.securesms.ui.NoOpCallbacks
import org.thoughtcrime.securesms.ui.OptionsCard
import org.thoughtcrime.securesms.ui.RadioOption
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
-import org.thoughtcrime.securesms.ui.components.SlimOutlineButton
import org.thoughtcrime.securesms.ui.contentDescription
-import org.thoughtcrime.securesms.ui.fadingEdges
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.LocalType
@@ -38,15 +37,12 @@ fun DisappearingMessages(
modifier: Modifier = Modifier,
callbacks: ExpiryCallbacks = NoOpCallbacks
) {
- val scrollState = rememberScrollState()
-
Column(modifier = modifier.padding(horizontal = LocalDimensions.current.spacing)) {
- Box(modifier = Modifier.weight(1f)) {
+ BottomFadingEdgeBox(modifier = Modifier.weight(1f)) { bottomContentPadding ->
Column(
modifier = Modifier
- .padding(vertical = LocalDimensions.current.spacing)
- .verticalScroll(scrollState)
- .fadingEdges(scrollState),
+ .verticalScroll(rememberScrollState())
+ .padding(vertical = LocalDimensions.current.spacing),
) {
state.cards.forEachIndexed { index, option ->
OptionsCard(option, callbacks)
@@ -69,6 +65,8 @@ fun DisappearingMessages(
.fillMaxWidth()
.padding(top = LocalDimensions.current.xsSpacing)
)
+
+ Spacer(modifier = Modifier.height(bottomContentPadding))
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt
index 8dffb1fd9b7..f01eeaea2d5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt
@@ -66,7 +66,7 @@ class StartConversationFragment : BottomSheetDialogFragment(), StartConversation
}
override fun onCreateGroupSelected() {
- replaceFragment(CreateGroupFragment().also { it.delegate = this })
+ replaceFragment(CreateGroupFragment())
}
override fun onJoinCommunitySelected() {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt
index bc298c5bd38..bae328c78ee 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/invitefriend/InviteFriend.kt
@@ -23,6 +23,7 @@ import network.loki.messenger.R
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon
import org.thoughtcrime.securesms.ui.components.BackAppBar
+import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.thoughtcrime.securesms.ui.components.SlimOutlineButton
import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton
import org.thoughtcrime.securesms.ui.components.border
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt
index 0a40c6ee391..e825d412802 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.conversation.start.newmessage
import android.graphics.Rect
import android.os.Build
import android.view.ViewTreeObserver
-import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -49,11 +48,12 @@ import org.thoughtcrime.securesms.ui.LoadingArcOr
import org.thoughtcrime.securesms.ui.components.AppBarCloseIcon
import org.thoughtcrime.securesms.ui.components.BackAppBar
import org.thoughtcrime.securesms.ui.components.BorderlessButtonWithIcon
-import org.thoughtcrime.securesms.ui.components.QRScannerScreen
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
+import org.thoughtcrime.securesms.ui.components.QRScannerScreen
import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField
import org.thoughtcrime.securesms.ui.components.SessionTabRow
import org.thoughtcrime.securesms.ui.contentDescription
+import org.thoughtcrime.securesms.ui.qaTag
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.LocalType
@@ -64,7 +64,7 @@ import kotlin.math.max
private val TITLES = listOf(R.string.accountIdEnter, R.string.qrScan)
-@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun NewMessage(
state: State,
@@ -142,8 +142,8 @@ private fun EnterAccountId(
SessionOutlinedTextField(
text = state.newMessageIdOrOns,
modifier = Modifier
- .padding(horizontal = LocalDimensions.current.spacing),
- contentDescription = "Session id input box",
+ .padding(horizontal = LocalDimensions.current.spacing)
+ .qaTag(stringResource(R.string.AccessibilityId_sessionIdInput)),
placeholder = stringResource(R.string.accountIdOrOnsEnter),
onChange = callbacks::onChange,
onContinue = callbacks::onContinue,
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt
index be7630b536e..4d70f40471c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt
@@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.concurrent.TimeoutException
import javax.inject.Inject
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -14,12 +13,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeout
import network.loki.messenger.R
import org.session.libsession.snode.SnodeAPI
+import org.session.libsession.snode.utilities.await
import org.session.libsignal.utilities.PublicKeyValidation
-import org.session.libsignal.utilities.timeout
import org.thoughtcrime.securesms.ui.GetString
@HiltViewModel
@@ -68,12 +67,14 @@ internal class NewMessageViewModel @Inject constructor(
// This could be an ONS name
_state.update { it.copy(isTextErrorColor = false, error = null, loading = true) }
- loadOnsJob = viewModelScope.launch(Dispatchers.IO) {
+ loadOnsJob = viewModelScope.launch {
try {
- val publicKey = SnodeAPI.getAccountID(ons).timeout(30_000).get()
- if (isActive) onPublicKey(publicKey)
+ val publicKey = withTimeout(30_000L, {
+ SnodeAPI.getAccountID(ons).await()
+ })
+ onPublicKey(publicKey)
} catch (e: Exception) {
- if (isActive) onError(e)
+ onError(e)
}
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt
index 4c027b8d6c2..0ea9e374dd3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt
@@ -37,7 +37,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.content.ContextCompat
-import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.Lifecycle
@@ -58,6 +57,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -65,7 +69,8 @@ import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityConversationV2Binding
import network.loki.messenger.libsession_util.util.ExpiryMode
import nl.komponents.kovenant.ui.successUi
-import org.session.libsession.messaging.MessagingModuleConfiguration
+import org.session.libsession.database.StorageProtocol
+import org.session.libsession.messaging.groups.GroupManagerV2
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.applyExpiryMode
import org.session.libsession.messaging.messages.control.DataExtractionNotification
@@ -78,7 +83,6 @@ import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
-import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
@@ -87,8 +91,11 @@ import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.MediaTypes
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.CONVERSATION_NAME_KEY
+import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY
+import org.session.libsession.utilities.StringSubstitutionConstants.DATE_TIME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
+import org.session.libsession.utilities.StringSubstitutionConstants.URL_KEY
import org.session.libsession.utilities.Stub
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.concurrent.SimpleTask
@@ -98,6 +105,7 @@ import org.session.libsignal.crypto.MnemonicCodec
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.ListenableFuture
import org.session.libsignal.utilities.Log
+import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.hexEncodedPrivateKey
import org.thoughtcrime.securesms.ApplicationContext
@@ -148,16 +156,15 @@ import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.ReactionDatabase
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.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord
+import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.giph.ui.GiphyActivity
import org.thoughtcrime.securesms.groups.OpenGroupManager
-import org.thoughtcrime.securesms.home.HomeActivity
-import org.thoughtcrime.securesms.home.startHomeActivity
+import org.thoughtcrime.securesms.home.search.getSearchName
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
@@ -171,12 +178,12 @@ 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.openUrl
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.ActivityDispatcher
-import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.NetworkUtils
@@ -226,10 +233,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
@Inject lateinit var smsDb: SmsDatabase
@Inject lateinit var mmsDb: MmsDatabase
@Inject lateinit var lokiMessageDb: LokiMessageDatabase
- @Inject lateinit var storage: Storage
+ @Inject lateinit var storage: StorageProtocol
@Inject lateinit var reactionDb: ReactionDatabase
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
@Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory
+ @Inject lateinit var configFactory: ConfigFactory
+ @Inject lateinit var groupManagerV2: GroupManagerV2
private val screenshotObserver by lazy {
ScreenshotObserver(this, Handler(Looper.getMainLooper())) {
@@ -269,7 +278,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
private val viewModel: ConversationViewModel by viewModels {
- viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
+ viewModelFactory.create(threadId, storage.getUserED25519KeyPair())
}
private var actionMode: ActionMode? = null
private var unreadCount = Int.MAX_VALUE
@@ -404,6 +413,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
const val PICK_GIF = 10
const val PICK_FROM_LIBRARY = 12
const val INVITE_CONTACTS = 124
+ const val CONVERSATION_SETTINGS = 125 // used to open conversation search on result
}
// endregion
@@ -474,10 +484,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpBlockedBanner()
binding.searchBottomBar.setEventListener(this)
updateSendAfterApprovalText()
- setUpMessageRequestsBar()
-
- // Note: Do not `showOrHideInputIfNeeded` here - we'll never start this activity w/ the
- // keyboard visible and have no need to immediately display it.
+ setUpMessageRequests()
val weakActivity = WeakReference(this)
@@ -501,6 +508,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpSearchResultObserver()
scrollToFirstUnreadMessageIfNeeded()
setUpOutdatedClientBanner()
+ setUpLegacyGroupBanner()
if (author != null && messageTimestamp >= 0 && targetPosition >= 0) {
binding.conversationRecyclerView.scrollToPosition(targetPosition)
@@ -798,7 +806,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// called from onCreate
private fun setUpBlockedBanner() {
- val recipient = viewModel.recipient?.takeUnless { it.isGroupRecipient } ?: return
+ val recipient = viewModel.recipient?.takeUnless { it.isGroupOrCommunityRecipient } ?: return
binding.blockedBannerTextView.text = applicationContext.getString(R.string.blockBlockedDescription)
binding.blockedBanner.isVisible = recipient.isBlocked
binding.blockedBanner.setOnClickListener { viewModel.unblock() }
@@ -810,13 +818,44 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled &&
legacyRecipient != null
- binding.outdatedBanner.isVisible = shouldShowLegacy
+ binding.outdatedDisappearingBanner.isVisible = shouldShowLegacy
if (shouldShowLegacy) {
- val txt = Phrase.from(applicationContext, R.string.disappearingMessagesLegacy)
+ val txt = Phrase.from(this, R.string.disappearingMessagesLegacy)
.put(NAME_KEY, legacyRecipient!!.toShortString())
.format()
- binding?.outdatedBannerTextView?.text = txt
+ binding.outdatedDisappearingBannerTextView.text = txt
+ }
+ }
+
+ private fun setUpLegacyGroupBanner() {
+ val shouldDisplayBanner = viewModel.recipient?.isLegacyGroupRecipient ?: return
+
+ //TODO groupsv2, URL
+ val url = "https://getsession.org"
+
+ with(binding) {
+ outdatedGroupBanner.isVisible = shouldDisplayBanner
+ outdatedGroupBanner.text = Phrase.from(this@ConversationActivityV2, R.string.groupLegacyBanner)
+ //TODO groupsv2, date
+ .put(DATE_KEY, "")
+ .format()
+ outdatedGroupBanner.setOnClickListener {
+ showSessionDialog {
+ title(R.string.urlOpenBrowser)
+ text(Phrase.from(this@ConversationActivityV2, R.string.urlOpenDescription)
+ .put(URL_KEY, url)
+ .format())
+ cancelButton()
+ dangerButton(R.string.open) {
+ try {
+ openUrl(url)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error opening URL", e)
+ }
+ }
+ }
+ }
}
}
@@ -841,25 +880,46 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
private fun setUpUiStateObserver() {
- lifecycleScope.launchWhenStarted {
- viewModel.uiState.collect { uiState ->
- uiState.uiMessages.firstOrNull()?.let {
- Toast.makeText(this@ConversationActivityV2, it.message, Toast.LENGTH_LONG).show()
- viewModel.messageShown(it.id)
- }
- if (uiState.isMessageRequestAccepted == true) {
- binding.messageRequestBar.visibility = View.GONE
- }
- if (!uiState.conversationExists && !isFinishing) {
- // Conversation should be deleted now
+ // Observe toast messages
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.uiState
+ .mapNotNull { it.uiMessages.firstOrNull() }
+ .distinctUntilChanged()
+ .collect { msg ->
+ Toast.makeText(this@ConversationActivityV2, msg.message, Toast.LENGTH_LONG).show()
+ viewModel.messageShown(msg.id)
+ }
+ }
+ }
+
+ // When we see "shouldExit", we finish the activity once for all.
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ // Wait for `shouldExit == true` then finish the activity
+ viewModel.uiState
+ .filter { it.shouldExit }
+ .first()
+
+ if (!isFinishing) {
finish()
}
+ }
+ }
- // show or hide the text input
- binding.inputBar.isGone = uiState.hideInputBar
+ // Observe the rest misc "simple" state change. They are bundled in one big
+ // state observing as these changes are relatively cheap to perform even redundantly.
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.uiState.collect { state ->
+ binding.inputBar.run {
+ isVisible = state.showInput
+ showMediaControls = state.enableInputMediaControls
+ }
- // show or hide loading indicator
- binding.loader.isVisible = uiState.showLoader
+ // show or hide loading indicator
+ binding.loader.isVisible = state.showLoader
+ }
}
}
}
@@ -892,10 +952,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val recipient = viewModel.recipient ?: return false
if (!viewModel.isMessageRequestThread) {
ConversationMenuHelper.onPrepareOptionsMenu(
- menu,
- menuInflater,
- recipient,
- this
+ menu = menu,
+ inflater = menuInflater,
+ thread = recipient,
+ context = this,
+ configFactory = configFactory,
)
}
maybeUpdateToolbar(recipient)
@@ -921,11 +982,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (threadRecipient.isContactRecipient) {
binding.blockedBanner.isVisible = threadRecipient.isBlocked
}
- setUpMessageRequestsBar()
invalidateOptionsMenu()
updateSendAfterApprovalText()
- showOrHideInputIfNeeded()
-
maybeUpdateToolbar(threadRecipient)
}
}
@@ -938,27 +996,18 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding.textSendAfterApproval.isVisible = viewModel.showSendAfterApprovalText
}
- private fun showOrHideInputIfNeeded() {
- binding.inputBar.showInput = viewModel.recipient?.takeIf { it.isClosedGroupRecipient }
- ?.run { address.toGroupString().let(groupDb::getGroup).orNull()?.isActive == true }
- ?: true
- }
-
- private fun setUpMessageRequestsBar() {
- binding.inputBar.showMediaControls = !isOutgoingMessageRequestThread()
- binding.messageRequestBar.isVisible = isIncomingMessageRequestThread()
+ private fun setUpMessageRequests() {
binding.acceptMessageRequestButton.setOnClickListener {
- acceptMessageRequest()
+ viewModel.acceptMessageRequest()
}
+
binding.messageRequestBlock.setOnClickListener {
block(deleteThread = true)
}
+
binding.declineMessageRequestButton.setOnClickListener {
fun doDecline() {
viewModel.declineMessageRequest()
- lifecycleScope.launch(Dispatchers.IO) {
- ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2)
- }
finish()
}
@@ -969,27 +1018,30 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
button(R.string.cancel)
}
}
+
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.uiState
+ .map { it.messageRequestState }
+ .distinctUntilChanged()
+ .collectLatest { state ->
+ binding.messageRequestBar.isVisible = state is MessageRequestUiState.Visible
+
+ if (state is MessageRequestUiState.Visible) {
+ binding.sendAcceptsTextView.setText(state.acceptButtonText)
+ binding.messageRequestBlock.isVisible = state.showBlockButton
+ binding.declineMessageRequestButton.setText(state.declineButtonText)
+ }
+ }
+ }
+ }
}
private fun acceptMessageRequest() {
binding.messageRequestBar.isVisible = false
viewModel.acceptMessageRequest()
-
- lifecycleScope.launch(Dispatchers.IO) {
- ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2)
- }
}
- private fun isOutgoingMessageRequestThread(): Boolean = viewModel.recipient?.run {
- !isGroupRecipient && !isLocalNumber &&
- !(hasApprovedMe() || viewModel.hasReceived())
- } ?: false
-
- private fun isIncomingMessageRequestThread(): Boolean = viewModel.recipient?.run {
- !isGroupRecipient && !isApproved && !isLocalNumber &&
- !threadDb.getLastSeenAndHasSent(viewModel.threadId).second() && threadDb.getMessageCount(viewModel.threadId) > 0
- } ?: false
-
override fun inputBarEditTextContentChanged(newContent: CharSequence) {
val inputBarText = binding.inputBar.text // TODO check if we should be referencing newContent here instead
if (textSecurePreferences.isLinkPreviewsEnabled()) {
@@ -1142,7 +1194,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
// 10n1 and groups
- recipient.is1on1 || recipient.isGroupRecipient -> {
+ recipient.is1on1 || recipient.isGroupOrCommunityRecipient -> {
Phrase.from(applicationContext, R.string.groupNoMessages)
.put(GROUP_NAME_KEY, recipient.toShortString())
.format()
@@ -1185,25 +1237,32 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (item.itemId == android.R.id.home) {
return false
}
- return viewModel.recipient?.let { recipient ->
- ConversationMenuHelper.onOptionItemSelected(this, item, recipient)
- } ?: false
+
+ return viewModel.onOptionItemSelected(this, item)
}
override fun block(deleteThread: Boolean) {
val recipient = viewModel.recipient ?: return Log.w("Loki", "Recipient was null for block action")
+ val invitingAdmin = viewModel.invitingAdmin
+
+ val name = if (recipient.isGroupV2Recipient && invitingAdmin != null) {
+ invitingAdmin.getSearchName()
+ } else {
+ recipient.toShortString()
+ }
+
showSessionDialog {
title(R.string.block)
text(
Phrase.from(context, R.string.blockDescription)
- .put(NAME_KEY, recipient.toShortString())
+ .put(NAME_KEY, name)
.format()
)
dangerButton(R.string.block, R.string.AccessibilityId_blockConfirm) {
viewModel.block()
// Block confirmation toast added as per SS-64
- val txt = Phrase.from(context, R.string.blockBlockedUser).put(NAME_KEY, recipient.toShortString()).format().toString()
+ val txt = Phrase.from(context, R.string.blockBlockedUser).put(NAME_KEY, name).format().toString()
Toast.makeText(context, txt, Toast.LENGTH_LONG).show()
if (deleteThread) {
@@ -1234,8 +1293,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show()
}
+ // TODO: don't need to allow new closed group check here, removed in new disappearing messages
override fun showDisappearingMessages(thread: Recipient) {
- if (thread.isClosedGroupRecipient) {
+ if (thread.isLegacyGroupRecipient) {
groupDb.getGroup(thread.address.toGroupString()).orNull()?.run { if (!isActive) return }
}
Intent(this, DisappearingMessagesActivity::class.java)
@@ -1663,7 +1723,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
override fun onReactionLongClicked(messageId: MessageId, emoji: String?) {
- if (viewModel.recipient?.isGroupRecipient == true) {
+ if (viewModel.recipient?.isGroupOrCommunityRecipient == true) {
val isUserModerator = viewModel.openGroup?.let { openGroup ->
val userPublicKey = textSecurePreferences.getLocalNumber() ?: return@let false
OpenGroupManager.isUserModerator(this, openGroup.id, userPublicKey, viewModel.blindedPublicKey)
@@ -1706,19 +1766,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), recipient, getMessageBody()), PICK_FROM_LIBRARY)
}
- private fun processMessageRequestApproval() {
- if (isIncomingMessageRequestThread()) {
- acceptMessageRequest()
- } else if (viewModel.recipient?.isApproved == false) {
- // edge case for new outgoing thread on new recipient without sending approval messages
- viewModel.setRecipientApproved()
- }
- }
-
private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair? {
val recipient = viewModel.recipient ?: return null
val sentTimestamp = SnodeAPI.nowWithOffset
- processMessageRequestApproval()
+ viewModel.beforeSendingTextOnlyMessage()
val text = getMessageBody()
val userPublicKey = textSecurePreferences.getLocalNumber()
val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey)
@@ -1771,7 +1822,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
): Pair? {
val recipient = viewModel.recipient ?: return null
val sentTimestamp = SnodeAPI.nowWithOffset
- processMessageRequestApproval()
+ viewModel.beforeSendingAttachments()
// Create the message
val message = VisibleMessage().applyExpiryMode(viewModel.threadId)
message.sentTimestamp = sentTimestamp
@@ -2292,7 +2343,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun sendScreenshotNotification() {
val recipient = viewModel.recipient ?: return
- if (recipient.isGroupRecipient) return
+ if (recipient.isGroupOrCommunityRecipient) return
val kind = DataExtractionNotification.Kind.Screenshot()
val message = DataExtractionNotification(kind)
MessageSender.send(message, recipient.address)
@@ -2300,7 +2351,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun sendMediaSavedNotification() {
val recipient = viewModel.recipient ?: return
- if (recipient.isGroupRecipient) { return }
+ if (recipient.isGroupOrCommunityRecipient) { return }
val timestamp = SnodeAPI.nowWithOffset
val kind = DataExtractionNotification.Kind.MediaSaved(timestamp)
val message = DataExtractionNotification(kind)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt
index 5ecddd21476..8ab5c32f521 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt
@@ -2,47 +2,52 @@ package org.thoughtcrime.securesms.conversation.v2
import android.app.Application
import android.content.Context
+import android.view.MenuItem
import android.widget.Toast
+import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.goterl.lazysodium.utils.KeyPair
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
+import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.filterNotNull
-import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
import org.session.libsession.database.MessageDataProvider
+import org.session.libsession.database.StorageProtocol
+import org.session.libsession.messaging.groups.GroupManagerV2
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
-import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.TextSecurePreferences
-import org.session.libsession.utilities.recipients.Recipient
+import org.session.libsession.utilities.getGroup
import org.session.libsession.utilities.recipients.MessageType
+import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.recipients.getType
+import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.audio.AudioSlidePlayer
+import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper
+import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.LokiMessageDatabase
import org.thoughtcrime.securesms.database.ReactionDatabase
-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.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
+import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.mms.AudioSlide
import org.thoughtcrime.securesms.repository.ConversationRepository
@@ -53,19 +58,22 @@ class ConversationViewModel(
val edKeyPair: KeyPair?,
private val application: Application,
private val repository: ConversationRepository,
- private val storage: Storage,
+ private val storage: StorageProtocol,
private val messageDataProvider: MessageDataProvider,
+ private val groupDb: GroupDatabase,
private val threadDb: ThreadDatabase,
private val reactionDb: ReactionDatabase,
private val lokiMessageDb: LokiMessageDatabase,
- private val textSecurePreferences: TextSecurePreferences
+ private val textSecurePreferences: TextSecurePreferences,
+ private val configFactory: ConfigFactory,
+ private val groupManagerV2: GroupManagerV2,
) : ViewModel() {
val showSendAfterApprovalText: Boolean
get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false
- private val _uiState = MutableStateFlow(ConversationUiState(conversationExists = true))
- val uiState: StateFlow = _uiState
+ private val _uiState = MutableStateFlow(ConversationUiState())
+ val uiState: StateFlow get() = _uiState
private val _dialogsState = MutableStateFlow(DialogsState())
val dialogsState: StateFlow = _dialogsState
@@ -82,8 +90,7 @@ class ConversationViewModel(
_isAdmin.value = when(conversationType) {
// for Groups V2
MessageType.GROUPS_V2 -> {
- //todo GROUPS V2 add logic where code is commented to determine if user is an admin
- false // FANCHAO - properly set up admin for groups v2 here
+ configFactory.getGroup(AccountId(conversation.address.serialize()))?.hasAdminKey() == true
}
// for legacy groups, check if the user created the group
@@ -115,13 +122,24 @@ class ConversationViewModel(
val blindedRecipient: Recipient?
get() = _recipient.value?.let { recipient ->
when {
- recipient.isOpenGroupOutboxRecipient -> recipient
- recipient.isOpenGroupInboxRecipient -> repository.maybeGetBlindedRecipient(recipient)
+ recipient.isCommunityOutboxRecipient -> recipient
+ recipient.isCommunityInboxRecipient -> repository.maybeGetBlindedRecipient(recipient)
else -> null
}
}
- private var communityWriteAccessJob: Job? = null
+ /**
+ * The admin who invites us to this group(v2) conversation.
+ *
+ * null if this convo is not a group(v2) conversation, or error getting the info
+ */
+ val invitingAdmin: Recipient?
+ get() {
+ val recipient = recipient ?: return null
+ if (!recipient.isGroupV2Recipient) return null
+
+ return repository.getInvitingAdmin(threadId)
+ }
private var _openGroup: RetrieveOnce = RetrieveOnce {
storage.getOpenGroup(threadId)
@@ -141,7 +159,7 @@ class ConversationViewModel(
val isMessageRequestThread : Boolean
get() {
val recipient = recipient ?: return false
- return !recipient.isLocalNumber && !recipient.isGroupRecipient && !recipient.isApproved
+ return !recipient.isLocalNumber && !recipient.isLegacyGroupRecipient && !recipient.isCommunityRecipient && !recipient.isApproved
}
val canReactToMessages: Boolean
@@ -155,35 +173,97 @@ class ConversationViewModel(
)
init {
- viewModelScope.launch(Dispatchers.IO) {
+ viewModelScope.launch(Dispatchers.Default) {
repository.recipientUpdateFlow(threadId)
.collect { recipient ->
- if (recipient == null && _uiState.value.conversationExists) {
- _uiState.update { it.copy(conversationExists = false) }
+ _uiState.update {
+ it.copy(
+ shouldExit = recipient == null,
+ showInput = shouldShowInput(recipient),
+ enableInputMediaControls = shouldEnableInputMediaControls(recipient),
+ messageRequestState = buildMessageRequestState(recipient),
+ )
}
}
}
+ }
- // listen to community write access updates from this point
- communityWriteAccessJob?.cancel()
- communityWriteAccessJob = viewModelScope.launch {
- OpenGroupManager.getCommunitiesWriteAccessFlow()
- .map {
- if(openGroup?.groupId != null)
- it[openGroup?.groupId]
- else null
- }
- .filterNotNull()
- .collect{
- // update our community object
- _openGroup.updateTo(openGroup?.copy(canWrite = it))
- // when we get an update on the write access of a community
- // we need to update the input text accordingly
- _uiState.update { state ->
- state.copy(hideInputBar = shouldHideInputBar())
- }
+ /**
+ * Determines if the input media controls should be enabled.
+ *
+ * Normally we will show the input media controls, only in these situations we hide them:
+ * 1. First time we send message to a person.
+ * Since we haven't been approved by them, we can't send them any media, only text
+ */
+ private fun shouldEnableInputMediaControls(recipient: Recipient?): Boolean {
+ if (recipient != null &&
+ (recipient.is1on1 && !recipient.isLocalNumber) &&
+ !recipient.hasApprovedMe()) {
+ return false
+ }
+
+ return true
+ }
+
+ /**
+ * Determines if the input bar should be shown.
+ *
+ * For these situations we hide the input bar:
+ * 1. The user has been kicked from a group(v2), OR
+ * 2. The legacy group is inactive, OR
+ * 3. The community chat is read only
+ */
+ private fun shouldShowInput(recipient: Recipient?): Boolean {
+ return when {
+ recipient?.isGroupV2Recipient == true -> !repository.isGroupReadOnly(recipient)
+ recipient?.isLegacyGroupRecipient == true -> {
+ groupDb.getGroup(recipient.address.toGroupString()).orNull()?.isActive == true
+ }
+ openGroup != null -> openGroup?.canWrite == true
+ else -> true
+ }
+ }
+
+ private fun buildMessageRequestState(recipient: Recipient?): MessageRequestUiState {
+ // The basic requirement of showing a message request is:
+ // 1. The other party has not been approved by us, AND
+ // 2. We haven't sent a message to them before (if we do, we would be the one requesting permission), AND
+ // 3. We have received message from them AND
+ // 4. The type of conversation supports message request (only 1to1 and groups v2)
+
+ if (
+ recipient != null &&
+
+ // Req 1: we haven't approved the other party
+ (!recipient.isApproved && !recipient.isLocalNumber) &&
+
+ // Req 4: the type of conversation supports message request
+ (recipient.is1on1 || recipient.isGroupV2Recipient) &&
+
+ // Req 2: we haven't sent a message to them before
+ !threadDb.getLastSeenAndHasSent(threadId).second() &&
+
+ // Req 3: we have received message from them
+ threadDb.getMessageCount(threadId) > 0
+ ) {
+
+ return MessageRequestUiState.Visible(
+ acceptButtonText = if (recipient.isGroupOrCommunityRecipient) {
+ R.string.messageRequestGroupInviteDescription
+ } else {
+ R.string.messageRequestsAcceptDescription
+ },
+ // You can block a 1to1 conversation, or a normal groups v2 conversation
+ showBlockButton = recipient.is1on1 || recipient.isGroupV2Recipient,
+ declineButtonText = if (recipient.isGroupV2Recipient) {
+ R.string.delete
+ } else {
+ R.string.decline
}
+ )
}
+
+ return MessageRequestUiState.Invisible
}
override fun onCleared() {
@@ -214,16 +294,17 @@ class ConversationViewModel(
}
fun block() {
- val recipient = recipient ?: return Log.w("Loki", "Recipient was null for block action")
- if (recipient.isContactRecipient) {
- repository.setBlocked(recipient, true)
+ // inviting admin will be true if this request is a closed group message request
+ val recipient = invitingAdmin ?: recipient ?: return Log.w("Loki", "Recipient was null for block action")
+ if (recipient.isContactRecipient || recipient.isGroupV2Recipient) {
+ repository.setBlocked(threadId, recipient, true)
}
}
fun unblock() {
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for unblock action")
if (recipient.isContactRecipient) {
- repository.setBlocked(recipient, false)
+ repository.setBlocked(threadId, recipient, false)
}
}
@@ -514,13 +595,12 @@ class ConversationViewModel(
}
private fun markAsDeletedForEveryoneGroupsV2(data: DeleteForEveryoneDialogData){
- viewModelScope.launch(Dispatchers.IO) {
+ viewModelScope.launch(Dispatchers.Default) {
// show a loading indicator
_uiState.update { it.copy(showLoader = true) }
- //todo GROUPS V2 - uncomment below and use Fanchao's method to delete a group V2
try {
- //repository.callMethodFromFanchao(threadId, recipient, data.messages)
+ repository.deleteGroupV2MessagesRemotely(recipient!!, data.messages)
// the repo will handle the internal logic (calling `/delete` on the swarm
// and sending 'GroupUpdateDeleteMemberContentMessage'
@@ -542,7 +622,7 @@ class ConversationViewModel(
).show()
}
} catch (e: Exception) {
- Log.w("Loki", "FAILED TO delete messages ${data.messages} ")
+ Log.e("Loki", "FAILED TO delete messages ${data.messages}", e)
// failed to delete - show a toast and get back on the modal
withContext(Dispatchers.Main) {
Toast.makeText(
@@ -658,19 +738,36 @@ class ConversationViewModel(
fun acceptMessageRequest() = viewModelScope.launch {
val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for accept message request action")
+ val currentState = _uiState.value.messageRequestState as? MessageRequestUiState.Visible
+ ?: return@launch Log.w("Loki", "Current state was not visible for accept message request action")
+
+ _uiState.update {
+ it.copy(messageRequestState = MessageRequestUiState.Pending(currentState))
+ }
+
repository.acceptMessageRequest(threadId, recipient)
.onSuccess {
_uiState.update {
- it.copy(isMessageRequestAccepted = true)
+ it.copy(messageRequestState = MessageRequestUiState.Invisible)
}
}
.onFailure {
- Log.w("", "Failed to accept message request: $it")
+ Log.w("Loki", "Couldn't accept message request due to error", it)
+
+ _uiState.update { state ->
+ state.copy(messageRequestState = currentState)
+ }
}
}
- fun declineMessageRequest() {
- repository.declineMessageRequest(threadId)
+ fun declineMessageRequest() = viewModelScope.launch {
+ repository.declineMessageRequest(threadId, recipient!!)
+ .onSuccess {
+ _uiState.update { it.copy(shouldExit = true) }
+ }
+ .onFailure {
+ Log.w("Loki", "Couldn't decline message request due to error", it)
+ }
}
private fun showMessage(message: String) {
@@ -715,6 +812,25 @@ class ConversationViewModel(
attachmentDownloadHandler.onAttachmentDownloadRequest(attachment)
}
+ fun beforeSendingTextOnlyMessage() {
+ implicitlyApproveRecipient()
+ }
+
+ fun beforeSendingAttachments() {
+ implicitlyApproveRecipient()
+ }
+
+ private fun implicitlyApproveRecipient() {
+ val recipient = recipient
+
+ if (uiState.value.messageRequestState is MessageRequestUiState.Visible) {
+ acceptMessageRequest()
+ } else if (recipient?.isApproved == false) {
+ // edge case for new outgoing thread on new recipient without sending approval messages
+ repository.setApproved(recipient, true)
+ }
+ }
+
fun onCommand(command: Commands) {
when (command) {
is Commands.ShowOpenUrlDialog -> {
@@ -778,6 +894,37 @@ class ConversationViewModel(
}
}
+ fun onOptionItemSelected(
+ // This must be the context of the activity as requirement from ConversationMenuHelper
+ context: Context,
+ item: MenuItem
+ ): Boolean {
+ val recipient = recipient ?: return false
+
+ val inProgress = ConversationMenuHelper.onOptionItemSelected(
+ context = context,
+ item = item,
+ thread = recipient,
+ threadID = threadId,
+ factory = configFactory,
+ storage = storage,
+ groupManager = groupManagerV2,
+ )
+
+ if (inProgress != null) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(showLoader = true) }
+ try {
+ inProgress.receive()
+ } finally {
+ _uiState.update { it.copy(showLoader = false) }
+ }
+ }
+ }
+
+ return true
+ }
+
@dagger.assisted.AssistedFactory
interface AssistedFactory {
fun create(threadId: Long, edKeyPair: KeyPair?): Factory
@@ -789,12 +936,17 @@ class ConversationViewModel(
@Assisted private val edKeyPair: KeyPair?,
private val application: Application,
private val repository: ConversationRepository,
- private val storage: Storage,
+ private val storage: StorageProtocol,
private val messageDataProvider: MessageDataProvider,
+ private val groupDb: GroupDatabase,
private val threadDb: ThreadDatabase,
private val reactionDb: ReactionDatabase,
+ @ApplicationContext
+ private val context: Context,
private val lokiMessageDb: LokiMessageDatabase,
- private val textSecurePreferences: TextSecurePreferences
+ private val textSecurePreferences: TextSecurePreferences,
+ private val configFactory: ConfigFactory,
+ private val groupManagerV2: GroupManagerV2,
) : ViewModelProvider.Factory {
override fun create(modelClass: Class): T {
@@ -805,10 +957,13 @@ class ConversationViewModel(
repository = repository,
storage = storage,
messageDataProvider = messageDataProvider,
+ groupDb = groupDb,
threadDb = threadDb,
reactionDb = reactionDb,
lokiMessageDb = lokiMessageDb,
- textSecurePreferences = textSecurePreferences
+ textSecurePreferences = textSecurePreferences,
+ configFactory = configFactory,
+ groupManagerV2 = groupManagerV2,
) as T
}
}
@@ -850,12 +1005,25 @@ data class UiMessage(val id: Long, val message: String)
data class ConversationUiState(
val uiMessages: List = emptyList(),
- val isMessageRequestAccepted: Boolean? = null,
- val conversationExists: Boolean,
- val hideInputBar: Boolean = false,
+ val messageRequestState: MessageRequestUiState = MessageRequestUiState.Invisible,
+ val shouldExit: Boolean = false,
+ val showInput: Boolean = true,
+ val enableInputMediaControls: Boolean = true,
val showLoader: Boolean = false
)
+sealed interface MessageRequestUiState {
+ data object Invisible : MessageRequestUiState
+
+ data class Pending(val prevState: Visible) : MessageRequestUiState
+
+ data class Visible(
+ @StringRes val acceptButtonText: Int,
+ val showBlockButton: Boolean,
+ @StringRes val declineButtonText: Int,
+ ) : MessageRequestUiState
+}
+
data class RetrieveOnce(val retrieval: () -> T?) {
private var triedToRetrieve: Boolean = false
private var _value: T? = null
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt
deleted file mode 100644
index 58c5536248a..00000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt
+++ /dev/null
@@ -1,74 +0,0 @@
-package org.thoughtcrime.securesms.conversation.v2
-
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.core.view.isVisible
-import com.google.android.material.bottomsheet.BottomSheetDialogFragment
-import dagger.hilt.android.AndroidEntryPoint
-import network.loki.messenger.R
-import network.loki.messenger.databinding.FragmentDeleteMessageBottomSheetBinding
-import org.session.libsession.messaging.contacts.Contact
-import org.session.libsession.utilities.recipients.Recipient
-import org.thoughtcrime.securesms.database.SessionContactDatabase
-import org.thoughtcrime.securesms.util.UiModeUtilities
-import javax.inject.Inject
-
-@AndroidEntryPoint
-class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListener {
-
- @Inject
- lateinit var contactDatabase: SessionContactDatabase
-
- lateinit var recipient: Recipient
- private lateinit var binding: FragmentDeleteMessageBottomSheetBinding
- val contact by lazy {
- val senderId = recipient.address.serialize()
- // this dialog won't show for open group contacts
- contactDatabase.getContactWithAccountID(senderId)
- ?.displayName(Contact.ContactContext.REGULAR)
- }
-
- var onDeleteForMeTapped: (() -> Unit?)? = null
- var onDeleteForEveryoneTapped: (() -> Unit)? = null
- var onCancelTapped: (() -> Unit)? = null
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View {
- binding = FragmentDeleteMessageBottomSheetBinding.inflate(inflater, container, false)
- return binding.root
- }
-
- override fun onClick(v: View?) {
- when (v) {
- binding.deleteForMeTextView -> onDeleteForMeTapped?.invoke()
- binding.deleteForEveryoneTextView -> onDeleteForEveryoneTapped?.invoke()
- binding.cancelTextView -> onCancelTapped?.invoke()
- }
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- if (!this::recipient.isInitialized) {
- return dismiss()
- }
- if (!recipient.isGroupRecipient && !contact.isNullOrEmpty()) {
- binding.deleteForEveryoneTextView.text =
- resources.getString(R.string.clearMessagesForEveryone, contact)
- }
- binding.deleteForEveryoneTextView.isVisible = !recipient.isClosedGroupRecipient
- binding.deleteForMeTextView.setOnClickListener(this)
- binding.deleteForEveryoneTextView.setOnClickListener(this)
- binding.cancelTextView.setOnClickListener(this)
- }
-
- override fun onStart() {
- super.onStart()
- val window = dialog?.window ?: return
- window.setDimAmount(0.6f)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt
index bd491bbe705..038210f8ff9 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt
@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
-import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
@@ -57,11 +56,10 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
+import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
-import org.thoughtcrime.securesms.database.Storage
-import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.ui.Avatar
import org.thoughtcrime.securesms.ui.CarouselNextButton
import org.thoughtcrime.securesms.ui.CarouselPrevButton
@@ -88,7 +86,7 @@ import javax.inject.Inject
class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
@Inject
- lateinit var storage: Storage
+ lateinit var storage: StorageProtocol
private val viewModel: MessageDetailsViewModel by viewModels()
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt
index ac181b98ff0..4dd0b9a89db 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt
@@ -1,54 +1,48 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.app.Dialog
-import android.graphics.Typeface
import android.os.Bundle
-import android.text.Spannable
-import android.text.SpannableStringBuilder
-import android.text.style.StyleSpan
import androidx.fragment.app.DialogFragment
import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
-import org.session.libsession.messaging.contacts.Contact
-import org.session.libsession.messaging.jobs.AttachmentDownloadJob
+import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.jobs.JobQueue
+import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.StringSubstitutionConstants.CONVERSATION_NAME_KEY
import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.database.SessionContactDatabase
-import org.thoughtcrime.securesms.dependencies.DatabaseComponent
+import org.thoughtcrime.securesms.util.createAndStartAttachmentDownload
import javax.inject.Inject
/** Shown when receiving media from a contact for the first time, to confirm that
* they are to be trusted and files sent by them are to be downloaded. */
@AndroidEntryPoint
-class DownloadDialog(private val recipient: Recipient) : DialogFragment() {
+class AutoDownloadDialog(private val threadRecipient: Recipient,
+ private val databaseAttachment: DatabaseAttachment
+) : DialogFragment() {
+ @Inject lateinit var storage: StorageProtocol
@Inject lateinit var contactDB: SessionContactDatabase
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
- val accountID = recipient.address.toString()
- val contact = contactDB.getContactWithAccountID(accountID)
- val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: accountID
-
title(getString(R.string.attachmentsAutoDownloadModalTitle))
val explanation = Phrase.from(context, R.string.attachmentsAutoDownloadModalDescription)
- .put(CONVERSATION_NAME_KEY, recipient.toShortString())
+ .put(CONVERSATION_NAME_KEY, threadRecipient.toShortString())
.format()
text(explanation)
- button(R.string.download, R.string.AccessibilityId_download) { trust() }
+ button(R.string.download, R.string.AccessibilityId_download) {
+ setAutoDownload()
+ }
+
cancelButton { dismiss() }
}
- private fun trust() {
- val accountID = recipient.address.toString()
- val contact = contactDB.getContactWithAccountID(accountID) ?: return
- val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient)
- contactDB.setContactIsTrusted(contact, true, threadID)
- JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY)
- dismiss()
+ private fun setAutoDownload() {
+ storage.setAutoDownloadAttachments(threadRecipient, true)
+ JobQueue.shared.createAndStartAttachmentDownload(databaseAttachment)
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt
index 21405b26c5c..f2bd9fd222c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt
@@ -9,19 +9,26 @@ import android.text.SpannableStringBuilder
import android.text.style.StyleSpan
import android.widget.Toast
import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.lifecycleScope
import com.squareup.phrase.Phrase
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import network.loki.messenger.R
-import org.session.libsession.messaging.MessagingModuleConfiguration
+import org.session.libsession.database.StorageProtocol
import org.session.libsession.utilities.OpenGroupUrlParser
import org.session.libsession.utilities.StringSubstitutionConstants.COMMUNITY_NAME_KEY
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.groups.OpenGroupManager
-import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
+import javax.inject.Inject
/** Shown upon tapping an open group invitation. */
class JoinOpenGroupDialog(private val name: String, private val url: String) : DialogFragment() {
+ @Inject
+ lateinit var storage: StorageProtocol
+
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
title(resources.getString(R.string.communityJoin))
val explanation = Phrase.from(context, R.string.communityJoinDescription).put(COMMUNITY_NAME_KEY, name).format()
@@ -40,15 +47,23 @@ class JoinOpenGroupDialog(private val name: String, private val url: String) : D
private fun join() {
val openGroup = OpenGroupUrlParser.parseUrl(url)
val activity = requireActivity()
- ThreadUtils.queue {
+ lifecycleScope.launch {
try {
- openGroup.apply { OpenGroupManager.add(server, room, serverPublicKey, activity) }
- MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server, openGroup.room)
- ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(activity)
+ withContext(Dispatchers.Default) {
+ OpenGroupManager.add(
+ server = openGroup.server,
+ room = openGroup.room,
+ publicKey = openGroup.serverPublicKey,
+ context = activity
+ )
+
+ storage.onOpenGroupAdded(openGroup.server, openGroup.room)
+ }
} catch (e: Exception) {
Toast.makeText(activity, R.string.communityErrorDescription, Toast.LENGTH_SHORT).show()
}
}
+
dismiss()
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt
index cd911b2ace6..fe86f8d3827 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt
@@ -60,8 +60,13 @@ class InputBar @JvmOverloads constructor(
var delegate: InputBarDelegate? = null
var quote: MessageRecord? = null
var linkPreview: LinkPreview? = null
- var showInput: Boolean = true
- set(value) { field = value; showOrHideInputIfNeeded() }
+ private var showInput: Boolean = true
+ set(value) {
+ if (field != value) {
+ field = value
+ showOrHideInputIfNeeded()
+ }
+ }
var showMediaControls: Boolean = true
set(value) {
field = value
@@ -252,20 +257,20 @@ class InputBar @JvmOverloads constructor(
}
private fun showOrHideInputIfNeeded() {
- if (showInput) {
- setOf( binding.inputBarEditText, attachmentsButton ).forEach { it.isVisible = true }
- microphoneButton.isVisible = text.isEmpty()
- sendButton.isVisible = text.isNotEmpty()
- } else {
+ if (!showInput) {
cancelQuoteDraft()
cancelLinkPreviewDraft()
- val views = setOf( binding.inputBarEditText, attachmentsButton, microphoneButton, sendButton )
- views.forEach { it.isVisible = false }
}
+
+ binding.inputBarEditText.isVisible = showInput
+ attachmentsButton.isVisible = showInput
+ microphoneButton.isVisible = showInput && text.isEmpty()
+ sendButton.isVisible = showInput && text.isNotEmpty()
}
private fun showOrHideMediaControlsIfNeeded() {
- setOf(attachmentsButton, microphoneButton).forEach { it.snIsEnabled = showMediaControls }
+ attachmentsButton.snIsEnabled = showMediaControls
+ microphoneButton.snIsEnabled = showMediaControls
}
fun addTextChangedListener(listener: (String) -> Unit) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt
index e3e5df04580..b0ea0d399e5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/mention/MentionViewModel.kt
@@ -85,10 +85,13 @@ class MentionViewModel(
}
val memberIDs = when {
- recipient.isClosedGroupRecipient -> {
+ recipient.isLegacyGroupRecipient -> {
groupDatabase.getGroupMemberAddresses(recipient.address.toGroupString(), false)
.map { it.serialize() }
}
+ recipient.isGroupV2Recipient -> {
+ storage.getMembers(recipient.address.serialize()).map { it.sessionId }
+ }
recipient.isCommunityRecipient -> mmsDatabase.getRecentChatMemberIDs(threadID, 20)
recipient.isContactRecipient -> listOf(recipient.address.serialize())
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt
index ea265bdbbf8..a4c2685dc3d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt
@@ -6,11 +6,10 @@ import android.view.Menu
import android.view.MenuItem
import network.loki.messenger.R
import org.session.libsession.messaging.MessagingModuleConfiguration
-import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.IdPrefix
-import org.session.libsignal.utilities.Log
+import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
@@ -38,7 +37,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
val openGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID)
val thread = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadID)!!
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
- val edKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!
+ val edKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!
val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes }
?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString
@@ -64,7 +63,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
menu.findItem(R.id.menu_context_copy).isVisible = !containsControlMessage && hasText
// Copy Account ID
menu.findItem(R.id.menu_context_copy_public_key).isVisible =
- (thread.isGroupRecipient && !thread.isCommunityRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey)
+ (thread.isGroupOrCommunityRecipient && !thread.isCommunityRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey)
// Message detail
menu.findItem(R.id.menu_message_details).isVisible = selectedItems.size == 1
// Resend
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt
index 0997db18717..2aeebacc279 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt
@@ -18,7 +18,16 @@ import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import com.squareup.phrase.Phrase
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.IOException
import network.loki.messenger.R
+import org.session.libsession.database.StorageProtocol
+import org.session.libsession.messaging.groups.GroupManagerV2
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.leave
import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID
@@ -26,6 +35,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
+import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.toHexString
@@ -34,9 +44,11 @@ import org.thoughtcrime.securesms.calls.WebRtcCallActivity
import org.thoughtcrime.securesms.contacts.SelectContactsActivity
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils
+import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
-import org.thoughtcrime.securesms.groups.EditClosedGroupActivity
-import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey
+import org.thoughtcrime.securesms.groups.EditGroupActivity
+import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity
+import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity.Companion.groupIDKey
import org.thoughtcrime.securesms.media.MediaOverviewActivity
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
@@ -46,15 +58,15 @@ import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.findActivity
import org.thoughtcrime.securesms.ui.getSubbedString
import org.thoughtcrime.securesms.util.BitmapUtil
-import java.io.IOException
object ConversationMenuHelper {
-
+
fun onPrepareOptionsMenu(
menu: Menu,
inflater: MenuInflater,
thread: Recipient,
- context: Context
+ context: Context,
+ configFactory: ConfigFactory,
) {
// Prepare
menu.clear()
@@ -62,7 +74,7 @@ object ConversationMenuHelper {
// Base menu (options that should always be present)
inflater.inflate(R.menu.menu_conversation, menu)
// Expiring messages
- if (!isCommunity && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) {
+ if (!isCommunity && (thread.hasApprovedMe() || thread.isLegacyGroupRecipient || thread.isLocalNumber)) {
inflater.inflate(R.menu.menu_conversation_expiration, menu)
}
// One-on-one chat menu allows copying the account id
@@ -77,10 +89,21 @@ object ConversationMenuHelper {
inflater.inflate(R.menu.menu_conversation_block, menu)
}
}
- // Closed group menu (options that should only be present in closed groups)
- if (thread.isClosedGroupRecipient) {
- inflater.inflate(R.menu.menu_conversation_closed_group, menu)
+ // (Legacy) Closed group menu (options that should only be present in closed groups)
+ if (thread.isLegacyGroupRecipient) {
+ inflater.inflate(R.menu.menu_conversation_legacy_group, menu)
+ }
+
+ // Groups v2 menu
+ if (thread.isGroupV2Recipient) {
+ val hasAdminKey = configFactory.withUserConfigs { it.userGroups.getClosedGroup(thread.address.serialize())?.hasAdminKey() }
+ if (hasAdminKey == true) {
+ inflater.inflate(R.menu.menu_conversation_groups_v2_admin, menu)
+ }
+
+ inflater.inflate(R.menu.menu_conversation_groups_v2, menu)
}
+
// Open group menu
if (isCommunity) {
inflater.inflate(R.menu.menu_conversation_open_group, menu)
@@ -92,7 +115,7 @@ object ConversationMenuHelper {
inflater.inflate(R.menu.menu_conversation_unmuted, menu)
}
- if (thread.isGroupRecipient && !thread.isMuted) {
+ if (thread.isGroupOrCommunityRecipient && !thread.isMuted) {
inflater.inflate(R.menu.menu_conversation_notification_settings, menu)
}
@@ -134,7 +157,21 @@ object ConversationMenuHelper {
})
}
- fun onOptionItemSelected(context: Context, item: MenuItem, thread: Recipient): Boolean {
+ /**
+ * Handle the selected option
+ *
+ * @return An asynchronous channel that can be used to wait for the action to complete. Null if
+ * the action does not require waiting.
+ */
+ fun onOptionItemSelected(
+ context: Context,
+ item: MenuItem,
+ thread: Recipient,
+ threadID: Long,
+ factory: ConfigFactory,
+ storage: StorageProtocol,
+ groupManager: GroupManagerV2,
+ ): ReceiveChannel? {
when (item.itemId) {
R.id.menu_view_all_media -> { showAllMedia(context, thread) }
R.id.menu_search -> { search(context) }
@@ -145,15 +182,16 @@ object ConversationMenuHelper {
R.id.menu_block_delete -> { blockAndDelete(context, thread) }
R.id.menu_copy_account_id -> { copyAccountID(context, thread) }
R.id.menu_copy_open_group_url -> { copyOpenGroupUrl(context, thread) }
- R.id.menu_edit_group -> { editClosedGroup(context, thread) }
- R.id.menu_leave_group -> { leaveClosedGroup(context, thread) }
+ R.id.menu_edit_group -> { editGroup(context, thread) }
+ R.id.menu_leave_group -> { return leaveGroup(context, thread, threadID, factory, storage, groupManager) }
R.id.menu_invite_to_open_group -> { inviteContacts(context, thread) }
R.id.menu_unmute_notifications -> { unmute(context, thread) }
R.id.menu_mute_notifications -> { mute(context, thread) }
R.id.menu_notification_settings -> { setNotifyType(context, thread) }
R.id.menu_call -> { call(context, thread) }
}
- return true
+
+ return null
}
private fun showAllMedia(context: Context, thread: Recipient) {
@@ -221,7 +259,7 @@ object ConversationMenuHelper {
}
}
if (icon == null) {
- icon = IconCompat.createWithResource(context, if (thread.isGroupRecipient) R.mipmap.ic_group_shortcut else R.mipmap.ic_person_shortcut)
+ icon = IconCompat.createWithResource(context, if (thread.isGroupOrCommunityRecipient) R.mipmap.ic_group_shortcut else R.mipmap.ic_person_shortcut)
}
return icon
}
@@ -278,34 +316,105 @@ object ConversationMenuHelper {
listener.copyOpenGroupUrl(thread)
}
- private fun editClosedGroup(context: Context, thread: Recipient) {
- if (!thread.isClosedGroupRecipient) { return }
- val intent = Intent(context, EditClosedGroupActivity::class.java)
- val groupID: String = thread.address.toGroupString()
- intent.putExtra(groupIDKey, groupID)
- context.startActivity(intent)
+ private fun editGroup(context: Context, thread: Recipient) {
+ when {
+ thread.isGroupV2Recipient -> {
+ context.startActivity(EditGroupActivity.createIntent(context, thread.address.serialize()))
+ }
+
+ thread.isLegacyGroupRecipient -> {
+ val intent = Intent(context, EditLegacyGroupActivity::class.java)
+ val groupID: String = thread.address.toGroupString()
+ intent.putExtra(groupIDKey, groupID)
+ context.startActivity(intent)
+ }
+ }
}
- private fun leaveClosedGroup(context: Context, thread: Recipient) {
- if (!thread.isClosedGroupRecipient) { return }
+ fun leaveGroup(
+ context: Context,
+ thread: Recipient,
+ threadID: Long,
+ configFactory: ConfigFactory,
+ storage: StorageProtocol,
+ groupManager: GroupManagerV2,
+ ): ReceiveChannel? {
+ when {
+ thread.isLegacyGroupRecipient -> {
+ val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull()
+ val admins = group.admins
+ val accountID = TextSecurePreferences.getLocalNumber(context)
+ val isCurrentUserAdmin = admins.any { it.toString() == accountID }
+
+ confirmAndLeaveGroup(
+ context = context,
+ groupName = group.title,
+ isAdmin = isCurrentUserAdmin,
+ threadID = threadID,
+ storage = storage,
+ doLeave = {
+ val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString()
+
+ check(DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey)) {
+ "Invalid group public key"
+ }
+ MessageSender.leave(groupPublicKey, notifyUser = false)
+ }
+ )
+ }
+
+ thread.isGroupV2Recipient -> {
+ val accountId = AccountId(thread.address.serialize())
+ val group = configFactory.withUserConfigs { it.userGroups.getClosedGroup(accountId.hexString) } ?: return null
+ val name = configFactory.withGroupConfigs(accountId) {
+ it.groupInfo.getName()
+ } ?: group.name
+
+ val channel = Channel()
+
+ confirmAndLeaveGroup(
+ context = context,
+ groupName = name,
+ isAdmin = group.hasAdminKey(),
+ threadID = threadID,
+ storage = storage,
+ doLeave = {
+ try {
+ groupManager.leaveGroup(accountId, true)
+ } finally {
+ channel.send(Unit)
+ }
+ }
+ )
- val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull()
- val admins = group.admins
- val accountID = TextSecurePreferences.getLocalNumber(context)
- val isCurrentUserAdmin = admins.any { it.toString() == accountID }
- val message = if (isCurrentUserAdmin) {
+ return channel
+ }
+ }
+
+ return null
+ }
+
+ private fun confirmAndLeaveGroup(
+ context: Context,
+ groupName: String,
+ isAdmin: Boolean,
+ threadID: Long,
+ storage: StorageProtocol,
+ doLeave: suspend () -> Unit,
+ ) {
+ val message = if (isAdmin) {
Phrase.from(context, R.string.groupDeleteDescription)
- .put(GROUP_NAME_KEY, group.title)
+ .put(GROUP_NAME_KEY, groupName)
.format()
} else {
Phrase.from(context, R.string.groupLeaveDescription)
- .put(GROUP_NAME_KEY, group.title)
+ .put(GROUP_NAME_KEY, groupName)
.format()
}
fun onLeaveFailed() {
val txt = Phrase.from(context, R.string.groupLeaveErrorFailed)
- .put(GROUP_NAME_KEY, group.title)
+ .put(GROUP_NAME_KEY, groupName)
.format().toString()
Toast.makeText(context, txt, Toast.LENGTH_LONG).show()
}
@@ -314,15 +423,20 @@ object ConversationMenuHelper {
title(R.string.groupLeave)
text(message)
dangerButton(R.string.leave) {
- try {
- val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString()
- val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey)
-
- if (isClosedGroup) MessageSender.leave(groupPublicKey, notifyUser = false)
- else onLeaveFailed()
- } catch (e: Exception) {
- onLeaveFailed()
+ GlobalScope.launch(Dispatchers.Default) {
+ try {
+ // Cancel any outstanding jobs
+ storage.cancelPendingMessageSendJobs(threadID)
+
+ doLeave()
+ } catch (e: Exception) {
+ Log.e("Conversation", "Error leaving group", e)
+ withContext(Dispatchers.Main) {
+ onLeaveFailed()
+ }
+ }
}
+
}
button(R.string.cancel)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt
index 08a06407c54..8723be7b913 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt
@@ -4,7 +4,6 @@ import android.Manifest
import android.content.Context
import android.content.Intent
import android.util.AttributeSet
-import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
@@ -12,13 +11,13 @@ import androidx.core.content.res.ResourcesCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
-import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewControlMessageBinding
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.ExpirationConfiguration
+import org.session.libsession.messaging.utilities.UpdateMessageData
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
import org.session.libsession.utilities.TextSecurePreferences
@@ -67,6 +66,7 @@ class ControlMessageView : LinearLayout {
binding.expirationTimerView.isGone = true
binding.followSetting.isGone = true
var messageBody: CharSequence = message.getDisplayBody(context)
+
binding.root.contentDescription = null
binding.textView.text = messageBody
when {
@@ -76,7 +76,7 @@ class ControlMessageView : LinearLayout {
val threadRecipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(message.threadId)
- if (threadRecipient?.isClosedGroupRecipient == true) {
+ if (threadRecipient?.isGroupRecipient == true) {
expirationTimerView.setTimerIcon()
} else {
expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
@@ -85,7 +85,7 @@ class ControlMessageView : LinearLayout {
followSetting.isVisible = ExpirationConfiguration.isNewConfigEnabled
&& !message.isOutgoing
&& message.expiryMode != (MessagingModuleConfiguration.shared.storage.getExpirationConfiguration(message.threadId)?.expiryMode ?: ExpiryMode.NONE)
- && threadRecipient?.isGroupRecipient != true
+ && threadRecipient?.isGroupOrCommunityRecipient != true
if (followSetting.isVisible) {
binding.controlContentView.setOnClickListener { disappearingMessages.showFollowSettingDialog(context, message) }
@@ -202,6 +202,12 @@ class ControlMessageView : LinearLayout {
}
}
}
+ message.isGroupUpdateMessage -> {
+ val updateMessageData: UpdateMessageData? = UpdateMessageData.fromJSON(message.body)
+ if (updateMessageData?.isGroupErrorQuitKind() == true) {
+ binding.textView.setTextColor(context.getColorFromAttr(R.attr.danger))
+ }
+ }
}
binding.textView.isGone = message.isCallLog
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/PendingAttachmentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/PendingAttachmentView.kt
new file mode 100644
index 00000000000..72e37b5dddb
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/PendingAttachmentView.kt
@@ -0,0 +1,65 @@
+package org.thoughtcrime.securesms.conversation.v2.messages
+
+import android.content.Context
+import android.util.AttributeSet
+import android.widget.LinearLayout
+import androidx.annotation.ColorInt
+import com.squareup.phrase.Phrase
+import dagger.hilt.android.AndroidEntryPoint
+import network.loki.messenger.R
+import network.loki.messenger.databinding.ViewPendingAttachmentBinding
+import org.session.libsession.database.StorageProtocol
+import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
+import org.session.libsession.utilities.StringSubstitutionConstants.FILE_TYPE_KEY
+import org.session.libsession.utilities.recipients.Recipient
+import org.thoughtcrime.securesms.conversation.v2.dialogs.AutoDownloadDialog
+import org.thoughtcrime.securesms.util.ActivityDispatcher
+import org.thoughtcrime.securesms.util.displaySize
+import java.util.Locale
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class PendingAttachmentView: LinearLayout {
+ private val binding by lazy { ViewPendingAttachmentBinding.bind(this) }
+ enum class AttachmentType {
+ AUDIO,
+ DOCUMENT,
+ IMAGE,
+ VIDEO,
+ }
+
+ // 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)
+
+ // endregion
+ @Inject lateinit var storage: StorageProtocol
+
+ // region Updating
+ fun bind(attachmentType: AttachmentType, @ColorInt textColor: Int, attachment: DatabaseAttachment) {
+ val stringRes = when (attachmentType) {
+ AttachmentType.AUDIO -> R.string.audio
+ AttachmentType.DOCUMENT -> R.string.document
+ AttachmentType.IMAGE -> R.string.image
+ AttachmentType.VIDEO -> R.string.video
+ }
+
+ val text = Phrase.from(context, R.string.attachmentsTapToDownload)
+ .put(FILE_TYPE_KEY, context.getString(stringRes).lowercase(Locale.ROOT))
+ .format()
+
+ binding.pendingDownloadIcon.setColorFilter(textColor)
+ binding.pendingDownloadSize.text = attachment.displaySize()
+ binding.pendingDownloadTitle.text = text
+ }
+ // endregion
+
+ // region Interaction
+ fun showDownloadDialog(threadRecipient: Recipient, attachment: DatabaseAttachment) {
+ if (!storage.shouldAutoDownloadAttachments(threadRecipient)) {
+ // just download
+ ActivityDispatcher.get(context)?.showDialog(AutoDownloadDialog(threadRecipient, attachment))
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt
deleted file mode 100644
index 7d1dc625f68..00000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-package org.thoughtcrime.securesms.conversation.v2.messages
-
-import android.content.Context
-import android.util.AttributeSet
-import android.widget.LinearLayout
-import androidx.annotation.ColorInt
-import androidx.core.content.ContextCompat
-import com.squareup.phrase.Phrase
-import network.loki.messenger.R
-import network.loki.messenger.databinding.ViewUntrustedAttachmentBinding
-import org.session.libsession.utilities.StringSubstitutionConstants.FILE_TYPE_KEY
-import org.session.libsession.utilities.recipients.Recipient
-import org.thoughtcrime.securesms.conversation.v2.dialogs.DownloadDialog
-import org.thoughtcrime.securesms.util.ActivityDispatcher
-
-class UntrustedAttachmentView: LinearLayout {
- private val binding: ViewUntrustedAttachmentBinding by lazy { ViewUntrustedAttachmentBinding.bind(this) }
- enum class AttachmentType {
- AUDIO,
- DOCUMENT,
- MEDIA
- }
-
- // 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)
-
- // endregion
-
- // region Updating
- fun bind(attachmentType: AttachmentType, @ColorInt textColor: Int) {
- val (iconRes, stringRes) = when (attachmentType) {
- AttachmentType.AUDIO -> R.drawable.ic_microphone to R.string.audio
- AttachmentType.DOCUMENT -> R.drawable.ic_document_large_light to R.string.files
- AttachmentType.MEDIA -> R.drawable.ic_image_white_24dp to R.string.media
- }
- val iconDrawable = ContextCompat.getDrawable(context,iconRes)!!
- iconDrawable.mutate().setTint(textColor)
-
- val text = Phrase.from(context, R.string.attachmentsTapToDownload)
- .put(FILE_TYPE_KEY, context.getString(stringRes))
- .format()
- binding.untrustedAttachmentTitle.text = text
-
- binding.untrustedAttachmentIcon.setImageDrawable(iconDrawable)
- binding.untrustedAttachmentTitle.text = text
- }
- // endregion
-
- // region Interaction
- fun showTrustDialog(recipient: Recipient) {
- ActivityDispatcher.get(context)?.showDialog(DownloadDialog(recipient))
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt
index d4949347a97..a99e7b60d17 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt
@@ -23,6 +23,7 @@ import com.bumptech.glide.RequestManager
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.getColorFromAttr
@@ -34,7 +35,6 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.ModalURLSpan
import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
-import org.thoughtcrime.securesms.database.model.SmsMessageRecord
import org.thoughtcrime.securesms.util.GlowViewUtilities
import org.thoughtcrime.securesms.util.SearchUtil
import org.thoughtcrime.securesms.util.getAccentColor
@@ -61,7 +61,6 @@ class VisibleMessageContentView : ConstraintLayout {
glide: RequestManager = Glide.with(this),
thread: Recipient,
searchQuery: String? = null,
- contactIsTrusted: Boolean = true,
onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit,
suppressThumbnails: Boolean = false
) {
@@ -71,7 +70,9 @@ class VisibleMessageContentView : ConstraintLayout {
binding.contentParent.mainColor = color
binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius)
- val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null
+ val mediaDownloaded = message is MmsMessageRecord && message.slideDeck.asAttachments().all { it.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE }
+ val mediaInProgress = message is MmsMessageRecord && message.slideDeck.asAttachments().any { it.isInProgress }
+ val mediaThumbnailMessage = message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null
// reset visibilities / containers
onContentClick.clear()
@@ -85,7 +86,6 @@ class VisibleMessageContentView : ConstraintLayout {
binding.bodyTextView.isVisible = false
binding.quoteView.root.isVisible = false
binding.linkPreviewView.root.isVisible = false
- binding.untrustedView.root.isVisible = false
binding.voiceMessageView.root.isVisible = false
binding.documentView.root.isVisible = false
binding.albumThumbnailView.root.isVisible = false
@@ -100,9 +100,9 @@ class VisibleMessageContentView : ConstraintLayout {
binding.bodyTextView.text = null
binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null
binding.linkPreviewView.root.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty()
- binding.untrustedView.root.isVisible = !contactIsTrusted && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty()
- binding.voiceMessageView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.audioSlide != null
- binding.documentView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.documentSlide != null
+ binding.pendingAttachmentView.root.isVisible = !mediaDownloaded && !mediaInProgress && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty()
+ binding.voiceMessageView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.audioSlide != null
+ binding.documentView.root.isVisible = (mediaDownloaded || mediaInProgress) && message is MmsMessageRecord && message.slideDeck.documentSlide != null
binding.albumThumbnailView.root.isVisible = mediaThumbnailMessage
binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation
@@ -140,6 +140,7 @@ class VisibleMessageContentView : ConstraintLayout {
}
when {
+ // LINK PREVIEW
message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> {
binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster)
onContentClick.add { event -> binding.linkPreviewView.root.calculateHit(event) }
@@ -147,10 +148,11 @@ class VisibleMessageContentView : ConstraintLayout {
// When in a link preview ensure the bodyTextView can expand to the full width
binding.bodyTextView.maxWidth = binding.linkPreviewView.root.layoutParams.width
}
+ // AUDIO
message is MmsMessageRecord && message.slideDeck.audioSlide != null -> {
hideBody = true
// Audio attachment
- if (contactIsTrusted || message.isOutgoing) {
+ if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
binding.voiceMessageView.root.indexInAdapter = indexInAdapter
binding.voiceMessageView.root.delegate = context as? ConversationActivityV2
binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster)
@@ -159,26 +161,38 @@ class VisibleMessageContentView : ConstraintLayout {
onContentClick.add { binding.voiceMessageView.root.togglePlayback() }
onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() }
} else {
- // TODO: move this out to its own area
- binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.AUDIO, VisibleMessageContentView.getTextColor(context,message))
- onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
+ hideBody = true
+ (message.slideDeck.audioSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment ->
+ binding.pendingAttachmentView.root.bind(
+ PendingAttachmentView.AttachmentType.AUDIO,
+ getTextColor(context,message),
+ attachment
+ )
+ onContentClick.add { binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) }
+ }
}
}
+ // DOCUMENT
message is MmsMessageRecord && message.slideDeck.documentSlide != null -> {
- hideBody = true
+ hideBody = true // TODO: check if this is still the logic we want
// Document attachment
- if (contactIsTrusted || message.isOutgoing) {
- binding.documentView.root.bind(message, VisibleMessageContentView.getTextColor(context, message))
+ if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
+ binding.documentView.root.bind(message, getTextColor(context, message))
} else {
- binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message))
- onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
+ hideBody = true
+ (message.slideDeck.documentSlide?.asAttachment() as? DatabaseAttachment)?.let { attachment ->
+ binding.pendingAttachmentView.root.bind(
+ PendingAttachmentView.AttachmentType.DOCUMENT,
+ getTextColor(context,message),
+ attachment
+ )
+ onContentClick.add { binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment) }
+ }
}
}
+ // IMAGE / VIDEO
message is MmsMessageRecord && !suppressThumbnails && message.slideDeck.asAttachments().isNotEmpty() -> {
- /*
- * Images / Video attachment
- */
- if (contactIsTrusted || message.isOutgoing) {
+ if (mediaDownloaded || mediaInProgress || message.isOutgoing) {
// isStart and isEnd of cluster needed for calculating the mask for full bubble image groups
// bind after add view because views are inflated and calculated during bind
binding.albumThumbnailView.root.bind(
@@ -196,13 +210,22 @@ class VisibleMessageContentView : ConstraintLayout {
} else {
hideBody = true
binding.albumThumbnailView.root.clearViews()
- binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message))
- onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
+ val firstAttachment = message.slideDeck.asAttachments().first() as? DatabaseAttachment
+ firstAttachment?.let { attachment ->
+ binding.pendingAttachmentView.root.bind(
+ PendingAttachmentView.AttachmentType.IMAGE,
+ getTextColor(context,message),
+ attachment
+ )
+ onContentClick.add {
+ binding.pendingAttachmentView.root.showDownloadDialog(thread, attachment)
+ }
+ }
}
}
message.isOpenGroupInvitation -> {
hideBody = true
- binding.openGroupInvitationView.root.bind(message, VisibleMessageContentView.getTextColor(context, message))
+ binding.openGroupInvitationView.root.bind(message, getTextColor(context, message))
onContentClick.add { binding.openGroupInvitationView.root.joinOpenGroup() }
}
}
@@ -239,7 +262,7 @@ class VisibleMessageContentView : ConstraintLayout {
fun recycle() {
arrayOf(
binding.deletedMessageView.root,
- binding.untrustedView.root,
+ binding.pendingAttachmentView.root,
binding.voiceMessageView.root,
binding.openGroupInvitationView.root,
binding.documentView.root,
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt
index 98e864e4721..38ed6706735 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt
@@ -16,7 +16,6 @@ import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
-import android.widget.LinearLayout
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
@@ -61,7 +60,6 @@ import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.home.UserDetailsBottomSheet
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager
-import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.disableClipping
@@ -155,14 +153,14 @@ class VisibleMessageView : FrameLayout {
replyDisabled = message.isOpenGroupInvitation
val threadID = message.threadId
val thread = threadDb.getRecipientForThreadId(threadID) ?: return
- val isGroupThread = thread.isGroupRecipient
+ val isGroupThread = thread.isGroupOrCommunityRecipient
val isStartOfMessageCluster = isStartOfMessageCluster(message, previous, isGroupThread)
val isEndOfMessageCluster = isEndOfMessageCluster(message, next, isGroupThread)
// Show profile picture and sender name if this is a group thread AND the message is incoming
binding.moderatorIconImageView.isVisible = false
binding.profilePictureView.visibility = when {
- thread.isGroupRecipient && !message.isOutgoing && isEndOfMessageCluster -> View.VISIBLE
- thread.isGroupRecipient -> View.INVISIBLE
+ thread.isGroupOrCommunityRecipient && !message.isOutgoing && isEndOfMessageCluster -> View.VISIBLE
+ thread.isGroupOrCommunityRecipient -> View.INVISIBLE
else -> View.GONE
}
@@ -261,7 +259,6 @@ class VisibleMessageView : FrameLayout {
glide,
thread,
searchQuery,
- message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false),
onAttachmentNeedsDownload
)
binding.messageContentView.root.delegate = delegate
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt
index c1d69879044..8215eecd9b8 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt
@@ -34,7 +34,7 @@ object ResendMessageUtilities {
message.text = messageRecord.body
}
message.sentTimestamp = messageRecord.timestamp
- if (recipient.isGroupRecipient) {
+ if (recipient.isGroupOrCommunityRecipient) {
message.groupPublicKey = recipient.address.toGroupString()
} else {
message.recipient = messageRecord.recipient.address.serialize()
diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java
index 4db46a3abc4..e2fe41b6259 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java
@@ -35,6 +35,12 @@
import java.io.IOException;
+import kotlin.Unit;
+import kotlinx.coroutines.channels.BufferOverflow;
+import kotlinx.coroutines.flow.MutableSharedFlow;
+import kotlinx.coroutines.flow.MutableStateFlow;
+import kotlinx.coroutines.flow.SharedFlowKt;
+
/**
* Utility class for working with identity keys.
*
@@ -56,6 +62,8 @@ public class IdentityKeyUtil {
public static final String LOKI_SEED = "loki_seed";
public static final String HAS_MIGRATED_KEY = "has_migrated_keys";
+ public static final MutableSharedFlow CHANGES = SharedFlowKt.MutableSharedFlow(0, 1, BufferOverflow.DROP_LATEST);
+
private static SharedPreferences getSharedPreferences(Context context) {
return context.getSharedPreferences(MASTER_SECRET_UTIL_PREFERENCES_NAME, 0);
}
@@ -158,9 +166,11 @@ public static void save(Context context, String key, String value) {
}
if (!preferencesEditor.commit()) throw new AssertionError("failed to save identity key/value to shared preferences");
+ CHANGES.tryEmit(Unit.INSTANCE);
}
public static void delete(Context context, String key) {
context.getSharedPreferences(MASTER_SECRET_UTIL_PREFERENCES_NAME, 0).edit().remove(key).commit();
+ CHANGES.tryEmit(Unit.INSTANCE);
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt
index f95d62db11c..d4c455308cc 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt
@@ -4,8 +4,13 @@ import android.content.Context
import androidx.core.content.contentValuesOf
import androidx.core.database.getBlobOrNull
import androidx.core.database.getLongOrNull
+import androidx.sqlite.db.transaction
+import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage
+import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
+typealias ConfigVariant = String
+
class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) {
companion object {
@@ -20,9 +25,19 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co
"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 = ?"
+ private const val VARIANT_IN_AND_PUBKEY_WHERE = "$VARIANT in (?) AND $PUBKEY = ?"
+
+ val CONTACTS_VARIANT: ConfigVariant = SharedConfigMessage.Kind.CONTACTS.name
+ val USER_GROUPS_VARIANT: ConfigVariant = SharedConfigMessage.Kind.GROUPS.name
+ val USER_PROFILE_VARIANT: ConfigVariant = SharedConfigMessage.Kind.USER_PROFILE.name
+ val CONVO_INFO_VARIANT: ConfigVariant = SharedConfigMessage.Kind.CONVO_INFO_VOLATILE.name
+
+ val KEYS_VARIANT: ConfigVariant = SharedConfigMessage.Kind.ENCRYPTION_KEYS.name
+ val INFO_VARIANT: ConfigVariant = SharedConfigMessage.Kind.CLOSED_GROUP_INFO.name
+ val MEMBER_VARIANT: ConfigVariant = SharedConfigMessage.Kind.CLOSED_GROUP_MEMBERS.name
}
- fun storeConfig(variant: String, publicKey: String, data: ByteArray, timestamp: Long) {
+ fun storeConfig(variant: ConfigVariant, publicKey: String, data: ByteArray, timestamp: Long) {
val db = writableDatabase
val contentValues = contentValuesOf(
VARIANT to variant,
@@ -33,7 +48,50 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co
db.insertOrUpdate(TABLE_NAME, contentValues, VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey))
}
- fun retrieveConfigAndHashes(variant: String, publicKey: String): ByteArray? {
+ fun deleteGroupConfigs(closedGroupId: AccountId) {
+ val db = writableDatabase
+ db.transaction {
+ val variants = arrayOf(KEYS_VARIANT, INFO_VARIANT, MEMBER_VARIANT)
+ db.delete(TABLE_NAME, VARIANT_IN_AND_PUBKEY_WHERE,
+ arrayOf(variants, closedGroupId.hexString)
+ )
+ }
+ }
+
+ fun storeGroupConfigs(publicKey: String, keysConfig: ByteArray, infoConfig: ByteArray, memberConfig: ByteArray, timestamp: Long) {
+ val db = writableDatabase
+ db.transaction {
+ val keyContent = contentValuesOf(
+ VARIANT to KEYS_VARIANT,
+ PUBKEY to publicKey,
+ DATA to keysConfig,
+ TIMESTAMP to timestamp
+ )
+ db.insertOrUpdate(TABLE_NAME, keyContent, VARIANT_AND_PUBKEY_WHERE,
+ arrayOf(KEYS_VARIANT, publicKey)
+ )
+ val infoContent = contentValuesOf(
+ VARIANT to INFO_VARIANT,
+ PUBKEY to publicKey,
+ DATA to infoConfig,
+ TIMESTAMP to timestamp
+ )
+ db.insertOrUpdate(TABLE_NAME, infoContent, VARIANT_AND_PUBKEY_WHERE,
+ arrayOf(INFO_VARIANT, publicKey)
+ )
+ val memberContent = contentValuesOf(
+ VARIANT to MEMBER_VARIANT,
+ PUBKEY to publicKey,
+ DATA to memberConfig,
+ TIMESTAMP to timestamp
+ )
+ db.insertOrUpdate(TABLE_NAME, memberContent, VARIANT_AND_PUBKEY_WHERE,
+ arrayOf(MEMBER_VARIANT, publicKey)
+ )
+ }
+ }
+
+ fun retrieveConfigAndHashes(variant: ConfigVariant, 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 ->
@@ -43,7 +101,7 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co
}
}
- fun retrieveConfigLastUpdateTimestamp(variant: String, publicKey: String): Long {
+ fun retrieveConfigLastUpdateTimestamp(variant: ConfigVariant, publicKey: String): Long {
return readableDatabase
.query(TABLE_NAME, arrayOf(TIMESTAMP), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey), null, null, null)
?.use { cursor ->
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt
index 013bbf5cb52..6af3048e65b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt
@@ -5,7 +5,7 @@ import android.content.Context
import android.database.Cursor
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.ExpirationDatabaseMetadata
-import org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX
+import org.session.libsession.utilities.GroupUtil.LEGACY_CLOSED_GROUP_PREFIX
import org.session.libsession.utilities.GroupUtil.COMMUNITY_INBOX_PREFIX
import org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
@@ -29,7 +29,7 @@ class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHel
val MIGRATE_GROUP_CONVERSATION_EXPIRY_TYPE_COMMAND = """
INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID}
FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME}
- WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} LIKE '$CLOSED_GROUP_PREFIX%'
+ WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} LIKE '$LEGACY_CLOSED_GROUP_PREFIX%'
AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0)
""".trimIndent()
@@ -37,7 +37,7 @@ class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHel
val MIGRATE_ONE_TO_ONE_CONVERSATION_EXPIRY_TYPE_COMMAND = """
INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID}
FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME}
- WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$CLOSED_GROUP_PREFIX%'
+ WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$LEGACY_CLOSED_GROUP_PREFIX%'
AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_PREFIX%'
AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_INBOX_PREFIX%'
AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java
index 6e1f72c5680..f5c71f1676e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java
@@ -32,6 +32,13 @@
import java.util.LinkedList;
import java.util.List;
+/**
+ * @deprecated This database table management is only used for
+ * legacy group management. It is not used in groupv2. For group v2 data, you generally need
+ * to query config system directly. The Storage class may also be more up-to-date.
+ *
+ */
+@Deprecated
public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProtocol {
@SuppressWarnings("unused")
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt
index 18dd42818d9..e9c13ca52f1 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt
@@ -3,26 +3,36 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import net.zetetic.database.sqlcipher.SQLiteDatabase.CONFLICT_REPLACE
+import org.intellij.lang.annotations.Language
+import org.json.JSONArray
+import org.session.libsession.database.ServerHashToMessageId
import org.session.libsignal.database.LokiMessageDatabaseProtocol
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
+import org.thoughtcrime.securesms.util.asSequence
class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), LokiMessageDatabaseProtocol {
companion object {
- private val messageIDTable = "loki_message_friend_request_database"
- private val messageThreadMappingTable = "loki_message_thread_mapping_database"
- private val errorMessageTable = "loki_error_message_database"
- private val messageHashTable = "loki_message_hash_database"
- private val smsHashTable = "loki_sms_hash_database"
- private val mmsHashTable = "loki_mms_hash_database"
- private val messageID = "message_id"
- private val serverID = "server_id"
- private val friendRequestStatus = "friend_request_status"
- private val threadID = "thread_id"
- private val errorMessage = "error_message"
- private val messageType = "message_type"
- private val serverHash = "server_hash"
+ private const val messageIDTable = "loki_message_friend_request_database"
+ private const val messageThreadMappingTable = "loki_message_thread_mapping_database"
+ private const val errorMessageTable = "loki_error_message_database"
+ private const val messageHashTable = "loki_message_hash_database"
+ private const val smsHashTable = "loki_sms_hash_database"
+ private const val mmsHashTable = "loki_mms_hash_database"
+ const val groupInviteTable = "loki_group_invites"
+
+ private const val groupInviteDeleteTrigger = "group_invite_delete_trigger"
+
+ private const val messageID = "message_id"
+ private const val serverID = "server_id"
+ private const val friendRequestStatus = "friend_request_status"
+ private const val threadID = "thread_id"
+ private const val errorMessage = "error_message"
+ private const val messageType = "message_type"
+ private const val serverHash = "server_hash"
+ const val invitingSessionId = "inviting_session_id"
+
@JvmStatic
val createMessageIDTableCommand = "CREATE TABLE $messageIDTable ($messageID INTEGER PRIMARY KEY, $serverID INTEGER DEFAULT 0, $friendRequestStatus INTEGER DEFAULT 0);"
@JvmStatic
@@ -39,6 +49,10 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
val createMmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $mmsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);"
@JvmStatic
val createSmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $smsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);"
+ @JvmStatic
+ val createGroupInviteTableCommand = "CREATE TABLE IF NOT EXISTS $groupInviteTable ($threadID INTEGER PRIMARY KEY, $invitingSessionId STRING);"
+ @JvmStatic
+ val createThreadDeleteTrigger = "CREATE TRIGGER IF NOT EXISTS $groupInviteDeleteTrigger AFTER DELETE ON ${ThreadDatabase.TABLE_NAME} BEGIN DELETE FROM $groupInviteTable WHERE $threadID = OLD.${ThreadDatabase.ID}; END;"
const val SMS_TYPE = 0
const val MMS_TYPE = 1
@@ -224,6 +238,55 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
}
}
+ fun getSendersForHashes(threadId: Long, hashes: Set): List {
+ @Language("RoomSql")
+ val query = """
+ WITH
+ sender_hash_mapping AS (
+ SELECT
+ sms_hash_table.$serverHash AS hash,
+ sms.${MmsSmsColumns.ID} AS message_id,
+ sms.${MmsSmsColumns.ADDRESS} AS sender,
+ sms.${SmsDatabase.TYPE} AS type,
+ true AS is_sms
+ FROM $smsHashTable sms_hash_table
+ LEFT OUTER JOIN ${SmsDatabase.TABLE_NAME} sms ON sms_hash_table.${messageID} = sms.${MmsSmsColumns.ID}
+ WHERE sms.${MmsSmsColumns.THREAD_ID} = :threadId
+
+ UNION ALL
+
+ SELECT
+ mms_hash_table.$serverHash,
+ mms.${MmsSmsColumns.ID},
+ mms.${MmsSmsColumns.ADDRESS},
+ mms.${MmsDatabase.MESSAGE_TYPE},
+ false
+ FROM $mmsHashTable mms_hash_table
+ LEFT OUTER JOIN ${MmsDatabase.TABLE_NAME} mms ON mms_hash_table.${messageID} = mms.${MmsSmsColumns.ID}
+ WHERE mms.${MmsSmsColumns.THREAD_ID} = :threadId
+ )
+ SELECT * FROM sender_hash_mapping
+ WHERE hash IN (SELECT value FROM json_each(:hashes))
+ """.trimIndent()
+
+ val result = databaseHelper.readableDatabase.query(query, arrayOf(threadId, JSONArray(hashes).toString()))
+ .use { cursor ->
+ cursor.asSequence()
+ .map {
+ ServerHashToMessageId(
+ serverHash = cursor.getString(0),
+ messageId = cursor.getLong(1),
+ sender = cursor.getString(2),
+ isSms = cursor.getInt(4) == 1,
+ isOutgoing = MmsSmsColumns.Types.isOutgoingMessageType(cursor.getLong(3))
+ )
+ }
+ .toList()
+ }
+
+ return result
+ }
+
fun getMessageServerHash(messageID: Long, mms: Boolean): String? = getMessageTables(mms).firstNotNullOfOrNull {
databaseHelper.readableDatabase.get(it, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor ->
cursor.getString(serverHash)
@@ -255,6 +318,27 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
)
}
+ fun addGroupInviteReferrer(groupThreadId: Long, referrerSessionId: String) {
+ val contentValues = ContentValues(2).apply {
+ put(threadID, groupThreadId)
+ put(invitingSessionId, referrerSessionId)
+ }
+ databaseHelper.writableDatabase.insertOrUpdate(
+ groupInviteTable, contentValues, "$threadID = ?", arrayOf(groupThreadId.toString())
+ )
+ }
+
+ fun groupInviteReferrer(groupThreadId: Long): String? {
+ return databaseHelper.readableDatabase.get(groupInviteTable, "$threadID = ?", arrayOf(groupThreadId.toString())) {cursor ->
+ cursor.getString(invitingSessionId)
+ }
+ }
+
+ fun deleteGroupInviteReferrer(groupThreadId: Long) {
+ databaseHelper.writableDatabase.delete(
+ groupInviteTable, "$threadID = ?", arrayOf(groupThreadId.toString())
+ )
+ }
private fun getMessageTables(mms: Boolean) = sequenceOf(
getMessageTable(mms),
messageHashTable
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java
index 63db0c66ba1..3f44588393c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java
@@ -44,7 +44,8 @@ public class MediaDatabase extends Database {
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.MESSAGE_BOX + ", "
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + ", "
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + ", "
- + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ADDRESS + " "
+ + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ADDRESS + ", "
+ + MmsDatabase.TABLE_NAME + "." + MmsDatabase.LINK_PREVIEWS + " "
+ "FROM " + AttachmentDatabase.TABLE_NAME + " LEFT JOIN " + MmsDatabase.TABLE_NAME
+ " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " "
+ "WHERE " + AttachmentDatabase.MMS_ID + " IN (SELECT " + MmsSmsColumns.ID
@@ -52,7 +53,8 @@ public class MediaDatabase extends Database {
+ " WHERE " + MmsDatabase.THREAD_ID + " = ?) AND (%s) AND "
+ AttachmentDatabase.DATA + " IS NOT NULL AND "
+ AttachmentDatabase.QUOTE + " = 0 AND "
- + AttachmentDatabase.STICKER_PACK_ID + " IS NULL "
+ + AttachmentDatabase.STICKER_PACK_ID + " IS NULL AND "
+ + MmsDatabase.LINK_PREVIEWS + " IS NULL "
+ "ORDER BY " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + " DESC";
private static final String GALLERY_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, AttachmentDatabase.CONTENT_TYPE + " LIKE 'image/%' OR " + AttachmentDatabase.CONTENT_TYPE + " LIKE 'video/%'");
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java
index 2377ccf3017..5622807127d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java
@@ -14,7 +14,6 @@
import org.session.libsignal.crypto.IdentityKey;
import org.session.libsignal.utilities.JsonUtil;
import org.session.libsignal.utilities.Log;
-import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.util.SqlUtil;
@@ -55,6 +54,8 @@ public MessagingDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
public abstract MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException;
+ public abstract String getTypeColumn();
+
public void addMismatchedIdentity(long messageId, Address address, IdentityKey identityKey) {
try {
addToDocument(messageId, MISMATCHED_IDENTITIES,
@@ -206,6 +207,19 @@ public void migrateThreadId(long oldThreadId, long newThreadId) {
contentValues.put(THREAD_ID, newThreadId);
db.update(getTableName(), contentValues, where, args);
}
+
+ public boolean isOutgoing(long messageId) {
+ SQLiteDatabase db = databaseHelper.getReadableDatabase();
+ try(Cursor cursor = db.query(getTableName(), new String[]{getTypeColumn()},
+ ID_WHERE, new String[]{String.valueOf(messageId)},
+ null, null, null)) {
+ if (cursor != null && cursor.moveToNext()) {
+ return MmsSmsColumns.Types.isOutgoingMessageType(cursor.getLong(0));
+ }
+ }
+ return false;
+ }
+
public static class SyncMessageId {
private final Address address;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt
index 244f5db117b..3a13690e8b6 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt
@@ -162,7 +162,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val ourAddress = messageId.address
val columnName =
if (deliveryReceipt) DELIVERY_RECEIPT_COUNT else READ_RECEIPT_COUNT
- if (ourAddress.equals(theirAddress) || theirAddress.isGroup) {
+ if (ourAddress.equals(theirAddress) || theirAddress.isGroupOrCommunity) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID))
val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID))
val status =
@@ -195,6 +195,22 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
}
+ fun updateInfoMessage(messageId: Long, body: String?, runThreadUpdate: Boolean = true) {
+ val threadId = getThreadIdForMessage(messageId)
+ val db = databaseHelper.writableDatabase
+ db.execSQL(
+ "UPDATE $TABLE_NAME SET $BODY = ? WHERE $ID = ?",
+ arrayOf(body, messageId.toString())
+ )
+ with (get(context).threadDatabase()) {
+ setLastSeen(threadId)
+ setHasSent(threadId, true)
+ if (runThreadUpdate) {
+ update(threadId, true)
+ }
+ }
+ }
+
fun updateSentTimestamp(messageId: Long, newTimestamp: Long, threadId: Long) {
val db = databaseHelper.writableDatabase
db.execSQL(
@@ -329,7 +345,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
contentValues.put(HAS_MENTION, 0)
database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString()))
val attachmentDatabase = get(context).attachmentDatabase()
- queue(Runnable { attachmentDatabase.deleteAttachmentsForMessage(messageId) })
+ queue { attachmentDatabase.deleteAttachmentsForMessage(messageId) }
val threadId = getThreadIdForMessage(messageId)
val deletedType = if (isOutgoing) { MmsSmsColumns.Types.BASE_DELETED_OUTGOING_TYPE} else {
@@ -763,7 +779,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
contentValues,
insertListener,
)
- if (message.recipient.address.isGroup) {
+ if (message.recipient.address.isGroupOrCommunity) {
val members = get(context).groupDatabase()
.getGroupMembers(message.recipient.address.toGroupString(), false)
val receiptDatabase = get(context).groupReceiptDatabase()
@@ -871,23 +887,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
}
- private fun deleteQuotedFromMessages(toDeleteRecords: List) {
- if (toDeleteRecords.isEmpty()) return
- val queryBuilder = StringBuilder()
- for (i in toDeleteRecords.indices) {
- queryBuilder.append("$QUOTE_ID = ").append(toDeleteRecords[i].getId())
- if (i + 1 < toDeleteRecords.size) {
- queryBuilder.append(" OR ")
- }
- }
- val query = queryBuilder.toString()
- val db = databaseHelper.writableDatabase
- val values = ContentValues(2)
- values.put(QUOTE_MISSING, 1)
- values.put(QUOTE_AUTHOR, "")
- db!!.update(TABLE_NAME, values, query, null)
- }
-
/**
* Delete all the messages in single queries where possible
* @param messageIds a String array representation of regularly Long types representing message IDs
@@ -910,7 +909,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
val idsAsString = queryBuilder.toString()
val attachmentDatabase = get(context).attachmentDatabase()
- queue(Runnable { attachmentDatabase.deleteAttachmentsForMessages(messageIds) })
+ queue { attachmentDatabase.deleteAttachmentsForMessages(messageIds) }
val groupReceiptDatabase = get(context).groupReceiptDatabase()
groupReceiptDatabase.deleteRowsForMessages(messageIds)
val database = databaseHelper.writableDatabase
@@ -920,12 +919,14 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
notifyStickerPackListeners()
}
+ override fun getTypeColumn(): String = MESSAGE_BOX
+
// Caution: The bool returned from `deleteMessage` is NOT "Was the message successfully deleted?"
// - it is "Was the thread deleted because removing that message resulted in an empty thread"!
override fun deleteMessage(messageId: Long): Boolean {
val threadId = getThreadIdForMessage(messageId)
val attachmentDatabase = get(context).attachmentDatabase()
- queue(Runnable { attachmentDatabase.deleteAttachmentsForMessage(messageId) })
+ queue { attachmentDatabase.deleteAttachmentsForMessage(messageId) }
val groupReceiptDatabase = get(context).groupReceiptDatabase()
groupReceiptDatabase.deleteRowsForMessage(messageId)
val database = databaseHelper.writableDatabase
@@ -941,6 +942,12 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val argsArray = messageIds.map { "?" }
val argValues = messageIds.map { it.toString() }.toTypedArray()
+ val attachmentDatabase = get(context).attachmentDatabase()
+ val groupReceiptDatabase = get(context).groupReceiptDatabase()
+
+ queue { attachmentDatabase.deleteAttachmentsForMessages(messageIds) }
+ groupReceiptDatabase.deleteRowsForMessages(messageIds)
+
val db = databaseHelper.writableDatabase
db.delete(
TABLE_NAME,
@@ -976,6 +983,62 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
deleteThreads(setOf(threadId))
}
+ fun deleteMediaFor(threadId: Long, fromUser: String? = null) {
+ val db = databaseHelper.writableDatabase
+ val whereString =
+ if (fromUser == null) "$THREAD_ID = ? AND $LINK_PREVIEWS IS NULL"
+ else "$THREAD_ID = ? AND $ADDRESS = ? AND $LINK_PREVIEWS IS NULL"
+ val whereArgs = if (fromUser == null) arrayOf(threadId.toString()) else arrayOf(threadId.toString(), fromUser)
+ var cursor: Cursor? = null
+ try {
+ cursor = db.query(TABLE_NAME, arrayOf(ID), whereString, whereArgs, null, null, null, null)
+ val toDeleteStringMessageIds = mutableListOf()
+ while (cursor.moveToNext()) {
+ toDeleteStringMessageIds += cursor.getLong(0).toString() // get the ID as a string
+ }
+ // TODO: this can probably be optimized out,
+ // currently attachmentDB uses MmsID not threadID which makes it difficult to delete
+ // and clean up on threadID alone
+ toDeleteStringMessageIds.toList().chunked(50).forEach { sublist ->
+ deleteMessages(sublist.toTypedArray())
+ }
+ } finally {
+ cursor?.close()
+ }
+ val threadDb = get(context).threadDatabase()
+ threadDb.update(threadId, false)
+ notifyConversationListeners(threadId)
+ notifyStickerListeners()
+ notifyStickerPackListeners()
+ }
+
+ fun deleteMessagesFrom(threadId: Long, fromUser: String) { // copied from deleteThreads implementation
+ val db = databaseHelper.writableDatabase
+ var cursor: Cursor? = null
+ val whereString = "$THREAD_ID = ? AND $ADDRESS = ?"
+ try {
+ cursor =
+ db!!.query(TABLE_NAME, arrayOf(ID), whereString, arrayOf(threadId.toString(), fromUser), null, null, null)
+ val toDeleteStringMessageIds = mutableListOf()
+ while (cursor.moveToNext()) {
+ toDeleteStringMessageIds += cursor.getLong(0).toString() // get the ID as a string
+ }
+ // TODO: this can probably be optimized out,
+ // currently attachmentDB uses MmsID not threadID which makes it difficult to delete
+ // and clean up on threadID alone
+ toDeleteStringMessageIds.toList().chunked(50).forEach { sublist ->
+ deleteMessages(sublist.toTypedArray())
+ }
+ } finally {
+ cursor?.close()
+ }
+ val threadDb = get(context).threadDatabase()
+ threadDb.update(threadId, false)
+ notifyConversationListeners(threadId)
+ notifyStickerListeners()
+ notifyStickerPackListeners()
+ }
+
private fun getSerializedSharedContacts(
insertedAttachmentIds: Map,
contacts: List
@@ -1119,7 +1182,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
return false
}
- /*package*/
private fun deleteThreads(threadIds: Set) {
val db = databaseHelper.writableDatabase
val where = StringBuilder()
@@ -1153,7 +1215,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
/*package*/
- fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long) {
+ fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long, onlyMedia: Boolean) {
var cursor: Cursor? = null
try {
val db = databaseHelper.readableDatabase
@@ -1163,7 +1225,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
where += " WHEN $outgoingType THEN $DATE_SENT < $date"
}
where += " ELSE $DATE_RECEIVED < $date END)"
- cursor = db!!.query(
+ if (onlyMedia) where += " AND $PART_COUNT >= 1"
+ cursor = db.query(
TABLE_NAME,
arrayOf(ID),
where,
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java
index 510272cfb8f..3ff600eefc5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java
@@ -40,7 +40,9 @@
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import java.io.Closeable;
+import java.util.ArrayList;
import java.util.HashSet;
+import java.util.List;
import java.util.Set;
import kotlin.Pair;
@@ -272,38 +274,36 @@ public long getLastMessageID(long threadId) {
}
}
- // Builds up and returns a list of all all the messages sent by this user in the given thread.
- // Used to do a pass through our local database to remove records when a user has "Ban & Delete"
- // called on them in a Community.
- public Set getAllMessageRecordsFromSenderInThread(long threadId, String serializedAuthor) {
- String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.ADDRESS + " = \"" + serializedAuthor + "\"";
- Set identifiedMessages = new HashSet();
+ public List getUserMessages(long threadId, String sender) {
- // Try everything with resources so that they auto-close on end of scope
- try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) {
- try (MmsSmsDatabase.Reader reader = readerFor(cursor)) {
- MessageRecord messageRecord;
- while ((messageRecord = reader.getNext()) != null) {
- identifiedMessages.add(messageRecord);
+ List idList = new ArrayList<>();
+
+ try (Cursor cursor = getConversation(threadId, false)) {
+ Reader reader = readerFor(cursor);
+ while (reader.getNext() != null) {
+ MessageRecord record = reader.getCurrent();
+ if (record.getIndividualRecipient().getAddress().serialize().equals(sender)) {
+ idList.add(record);
}
}
}
- return identifiedMessages;
+
+ return idList;
}
- // Version of the above `getAllMessageRecordsFromSenderInThread` method that returns the message
- // Ids rather than the set of MessageRecords - currently unused by potentially useful in the future.
- public Set getAllMessageIdsFromSenderInThread(long threadId, String serializedAuthor) {
+ // Builds up and returns a list of all all the messages sent by this user in the given thread.
+ // Used to do a pass through our local database to remove records when a user has "Ban & Delete"
+ // called on them in a Community.
+ public Set getAllMessageRecordsFromSenderInThread(long threadId, String serializedAuthor) {
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.ADDRESS + " = \"" + serializedAuthor + "\"";
-
- Set identifiedMessages = new HashSet();
+ Set identifiedMessages = new HashSet();
// Try everything with resources so that they auto-close on end of scope
try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) {
try (MmsSmsDatabase.Reader reader = readerFor(cursor)) {
MessageRecord messageRecord;
while ((messageRecord = reader.getNext()) != null) {
- identifiedMessages.add(messageRecord.id);
+ identifiedMessages.add(messageRecord);
}
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java
index dcd7778c9a2..fb32fad978c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java
@@ -65,13 +65,14 @@ public class RecipientDatabase extends Database {
private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none
private static final String WRAPPER_HASH = "wrapper_hash";
private static final String BLOCKS_COMMUNITY_MESSAGE_REQUESTS = "blocks_community_message_requests";
+ private static final String AUTO_DOWNLOAD = "auto_download"; // 1 / 0 / -1 flag for whether to auto-download in a conversation, or if the user hasn't selected a preference
private static final String[] RECIPIENT_PROJECTION = new String[] {
BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED,
PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI,
SIGNAL_PROFILE_NAME, SESSION_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
UNIDENTIFIED_ACCESS_MODE,
- FORCE_SMS_SELECTION, NOTIFY_TYPE, DISAPPEARING_STATE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS
+ FORCE_SMS_SELECTION, NOTIFY_TYPE, DISAPPEARING_STATE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS, AUTO_DOWNLOAD,
};
static final List TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
@@ -110,6 +111,17 @@ public static String getCreateNotificationTypeCommand() {
"ADD COLUMN " + NOTIFY_TYPE + " INTEGER DEFAULT 0;";
}
+ public static String getCreateAutoDownloadCommand() {
+ return "ALTER TABLE "+ TABLE_NAME + " " +
+ "ADD COLUMN " + AUTO_DOWNLOAD + " INTEGER DEFAULT -1;";
+ }
+
+ public static String getUpdateAutoDownloadValuesCommand() {
+ return "UPDATE "+TABLE_NAME+" SET "+AUTO_DOWNLOAD+" = 1 "+
+ "WHERE "+ADDRESS+" IN (SELECT "+SessionContactDatabase.sessionContactTable+"."+SessionContactDatabase.accountID+" "+
+ "FROM "+SessionContactDatabase.sessionContactTable+" WHERE ("+SessionContactDatabase.isTrusted+" != 0))";
+ }
+
public static String getCreateApprovedCommand() {
return "ALTER TABLE "+ TABLE_NAME + " " +
"ADD COLUMN " + APPROVED + " INTEGER DEFAULT 0;";
@@ -184,31 +196,32 @@ public Optional getRecipientSettings(@NonNull Address address
}
Optional getRecipientSettings(@NonNull Cursor cursor) {
- boolean blocked = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCK)) == 1;
- boolean approved = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED)) == 1;
- boolean approvedMe = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED_ME)) == 1;
- String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION));
- String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE));
- int disappearingState = cursor.getInt(cursor.getColumnIndexOrThrow(DISAPPEARING_STATE));
- int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE));
- int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE));
- long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL));
- int notifyType = cursor.getInt(cursor.getColumnIndexOrThrow(NOTIFY_TYPE));
- String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR));
- int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID));
- int expireMessages = cursor.getInt(cursor.getColumnIndexOrThrow(EXPIRE_MESSAGES));
- int registeredState = cursor.getInt(cursor.getColumnIndexOrThrow(REGISTERED));
- String profileKeyString = cursor.getString(cursor.getColumnIndexOrThrow(PROFILE_KEY));
- String systemDisplayName = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_DISPLAY_NAME));
- String systemContactPhoto = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHOTO_URI));
- String systemPhoneLabel = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHONE_LABEL));
- String systemContactUri = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_CONTACT_URI));
- String signalProfileName = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_NAME));
- String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SESSION_PROFILE_AVATAR));
- boolean profileSharing = cursor.getInt(cursor.getColumnIndexOrThrow(PROFILE_SHARING)) == 1;
- String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL));
- int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE));
- boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1;
+ boolean blocked = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCK)) == 1;
+ boolean approved = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED)) == 1;
+ boolean approvedMe = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED_ME)) == 1;
+ String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION));
+ String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE));
+ int disappearingState = cursor.getInt(cursor.getColumnIndexOrThrow(DISAPPEARING_STATE));
+ int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE));
+ int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE));
+ long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL));
+ int notifyType = cursor.getInt(cursor.getColumnIndexOrThrow(NOTIFY_TYPE));
+ boolean autoDownloadAttachments = cursor.getInt(cursor.getColumnIndexOrThrow(AUTO_DOWNLOAD)) == 1;
+ String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR));
+ int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID));
+ int expireMessages = cursor.getInt(cursor.getColumnIndexOrThrow(EXPIRE_MESSAGES));
+ int registeredState = cursor.getInt(cursor.getColumnIndexOrThrow(REGISTERED));
+ String profileKeyString = cursor.getString(cursor.getColumnIndexOrThrow(PROFILE_KEY));
+ String systemDisplayName = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_DISPLAY_NAME));
+ String systemContactPhoto = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHOTO_URI));
+ String systemPhoneLabel = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHONE_LABEL));
+ String systemContactUri = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_CONTACT_URI));
+ String signalProfileName = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_NAME));
+ String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SESSION_PROFILE_AVATAR));
+ boolean profileSharing = cursor.getInt(cursor.getColumnIndexOrThrow(PROFILE_SHARING)) == 1;
+ String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL));
+ int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE));
+ boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1;
String wrapperHash = cursor.getString(cursor.getColumnIndexOrThrow(WRAPPER_HASH));
boolean blocksCommunityMessageRequests = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCKS_COMMUNITY_MESSAGE_REQUESTS)) == 1;
@@ -232,7 +245,7 @@ Optional getRecipientSettings(@NonNull Cursor cursor) {
}
return Optional.of(new RecipientSettings(blocked, approved, approvedMe, muteUntil,
- notifyType,
+ notifyType, autoDownloadAttachments,
Recipient.DisappearingState.fromId(disappearingState),
Recipient.VibrateState.fromId(messageVibrateState),
Recipient.VibrateState.fromId(callVibrateState),
@@ -246,6 +259,22 @@ Optional getRecipientSettings(@NonNull Cursor cursor) {
forceSmsSelection, wrapperHash, blocksCommunityMessageRequests));
}
+ public boolean isAutoDownloadFlagSet(Recipient recipient) {
+ SQLiteDatabase db = getReadableDatabase();
+ Cursor cursor = db.query(TABLE_NAME, new String[]{ AUTO_DOWNLOAD }, ADDRESS+" = ?", new String[]{ recipient.getAddress().serialize() }, null, null, null);
+ boolean flagUnset = false;
+ try {
+ if (cursor.moveToFirst()) {
+ // flag isn't set if it is -1
+ flagUnset = cursor.getInt(cursor.getColumnIndexOrThrow(AUTO_DOWNLOAD)) == -1;
+ }
+ } finally {
+ cursor.close();
+ }
+ // negate result (is flag set)
+ return !flagUnset;
+ }
+
public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) {
ContentValues values = new ContentValues();
values.put(COLOR, color.serialize());
@@ -321,6 +350,21 @@ public void setBlocked(@NonNull Iterable recipients, boolean blocked)
notifyRecipientListeners();
}
+ public void setAutoDownloadAttachments(@NonNull Recipient recipient, boolean shouldAutoDownloadAttachments) {
+ SQLiteDatabase db = getWritableDatabase();
+ db.beginTransaction();
+ try {
+ ContentValues values = new ContentValues();
+ values.put(AUTO_DOWNLOAD, shouldAutoDownloadAttachments ? 1 : 0);
+ db.update(TABLE_NAME, values, ADDRESS+ " = ?", new String[]{recipient.getAddress().serialize()});
+ recipient.resolve().setAutoDownloadAttachments(shouldAutoDownloadAttachments);
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ notifyRecipientListeners();
+ }
+
public void setMuted(@NonNull Recipient recipient, long until) {
ContentValues values = new ContentValues();
values.put(MUTE_UNTIL, until);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt
index 27b3e73397c..70d12c0c32b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt
@@ -3,10 +3,9 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
-import androidx.core.database.getStringOrNull
import org.json.JSONArray
import org.session.libsession.messaging.contacts.Contact
-import org.session.libsession.messaging.utilities.AccountId
+import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
@@ -14,7 +13,7 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
companion object {
- private const val sessionContactTable = "session_contact_database"
+ const val sessionContactTable = "session_contact_database"
const val accountID = "session_id"
const val name = "name"
const val nickname = "nickname"
@@ -83,23 +82,20 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
contentValues.put(profilePictureEncryptionKey, Base64.encodeBytes(it))
}
contentValues.put(threadID, contact.threadID)
- contentValues.put(isTrusted, if (contact.isTrusted) 1 else 0)
database.insertOrUpdate(sessionContactTable, contentValues, "$accountID = ?", arrayOf( contact.accountID ))
notifyConversationListListeners()
}
fun contactFromCursor(cursor: Cursor): Contact {
- val accountID = cursor.getString(cursor.getColumnIndexOrThrow(accountID))
- val contact = Contact(accountID)
- contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name))
- contact.nickname = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(nickname))
- contact.profilePictureURL = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureURL))
- contact.profilePictureFileName = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureFileName))
- cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureEncryptionKey))?.let {
+ val contact = Contact(cursor.getString(accountID))
+ contact.name = cursor.getStringOrNull(name)
+ contact.nickname = cursor.getStringOrNull(nickname)
+ contact.profilePictureURL = cursor.getStringOrNull(profilePictureURL)
+ contact.profilePictureFileName = cursor.getStringOrNull(profilePictureFileName)
+ cursor.getStringOrNull(profilePictureEncryptionKey)?.let {
contact.profilePictureEncryptionKey = Base64.decode(it)
}
- contact.threadID = cursor.getLong(cursor.getColumnIndexOrThrow(threadID))
- contact.isTrusted = cursor.getInt(cursor.getColumnIndexOrThrow(isTrusted)) != 0
+ contact.threadID = cursor.getLong(threadID)
return contact
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
index 6deca0c939c..a308bf30dd2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
@@ -322,6 +322,11 @@ public boolean isDeletedMessage(long timestamp) {
return isDeleted;
}
+ @Override
+ public String getTypeColumn() {
+ return TYPE;
+ }
+
public void incrementReceiptCount(SyncMessageId messageId, boolean deliveryReceipt, boolean readReceipt) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
Cursor cursor = null;
@@ -723,15 +728,14 @@ private boolean isDuplicate(OutgoingTextMessage message, long threadId) {
}
}
- void deleteMessagesInThreadBeforeDate(long threadId, long date) {
+ void deleteMessagesFrom(long threadId, String fromUser) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
- String where = THREAD_ID + " = ? AND (CASE " + TYPE;
-
- for (long outgoingType : Types.OUTGOING_MESSAGE_TYPES) {
- where += " WHEN " + outgoingType + " THEN " + DATE_SENT + " < " + date;
- }
+ db.delete(TABLE_NAME, THREAD_ID+" = ? AND "+ADDRESS+" = ?", new String[]{threadId+"", fromUser});
+ }
- where += (" ELSE " + DATE_RECEIVED + " < " + date + " END)");
+ void deleteMessagesInThreadBeforeDate(long threadId, long date) {
+ SQLiteDatabase db = databaseHelper.getWritableDatabase();
+ String where = THREAD_ID + " = ? AND " + DATE_SENT + " < " + date;
db.delete(TABLE_NAME, where, new String[] {threadId + ""});
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt
index 384be73cb36..225ab4cb5de 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt
@@ -2,40 +2,34 @@ package org.thoughtcrime.securesms.database
import android.content.Context
import android.net.Uri
+import com.goterl.lazysodium.utils.KeyPair
+import dagger.hilt.android.qualifiers.ApplicationContext
import java.security.MessageDigest
-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.ConfigBase.Companion.PRIORITY_VISIBLE
-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 as LibSessionContact
-import network.loki.messenger.libsession_util.util.Conversation
import network.loki.messenger.libsession_util.util.ExpiryMode
+import network.loki.messenger.libsession_util.util.GroupDisplayInfo
import network.loki.messenger.libsession_util.util.GroupInfo
import network.loki.messenger.libsession_util.util.UserPic
-import network.loki.messenger.libsession_util.util.afterSend
import org.session.libsession.avatars.AvatarHelper
+import org.session.libsession.database.MessageDataProvider
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.BlindedIdMapping
import org.session.libsession.messaging.calls.CallMessageType
import org.session.libsession.messaging.contacts.Contact
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.ExpirationConfiguration
import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.messages.control.ConfigurationMessage
+import org.session.libsession.messaging.messages.control.GroupUpdated
import org.session.libsession.messaging.messages.control.MessageRequestResponse
import org.session.libsession.messaging.messages.signal.IncomingEncryptedMessage
import org.session.libsession.messaging.messages.signal.IncomingGroupMessage
@@ -55,27 +49,24 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
-import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
-import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
+import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
-import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.messaging.utilities.UpdateMessageData
import org.session.libsession.snode.OnionRequestAPI
-import org.session.libsession.snode.SnodeAPI
+import org.session.libsession.snode.SnodeClock
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.ProfileKeyUtil
import org.session.libsession.utilities.SSKEnvironment
-import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol.Companion.NAME_PADDED_LENGTH
import org.session.libsession.utilities.TextSecurePreferences
+import org.session.libsession.utilities.getGroup
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.recipients.Recipient.DisappearingState
import org.session.libsession.utilities.recipients.MessageType
import org.session.libsession.utilities.recipients.getType
-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.messages.SignalServiceAttachmentPointer
@@ -85,103 +76,159 @@ import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.Log
+import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.guava.Optional
+import org.session.libsignal.utilities.toHexString
+import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.database.model.MessageId
+import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
-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.mms.PartAuthority
-import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.SessionMetaProtocol
+import javax.inject.Inject
+import javax.inject.Singleton
+import network.loki.messenger.libsession_util.util.Contact as LibSessionContact
+import network.loki.messenger.libsession_util.util.GroupMember as LibSessionGroupMember
private const val TAG = "Storage"
-open class Storage(
- context: Context,
+@Singleton
+open class Storage @Inject constructor(
+ @ApplicationContext context: Context,
helper: SQLCipherOpenHelper,
- val configFactory: ConfigFactory
+ private val configFactory: ConfigFactory,
+ private val jobDatabase: SessionJobDatabase,
+ private val threadDatabase: ThreadDatabase,
+ private val recipientDatabase: RecipientDatabase,
+ private val attachmentDatabase: AttachmentDatabase,
+ private val lokiAPIDatabase: LokiAPIDatabase,
+ private val groupDatabase: GroupDatabase,
+ private val lokiMessageDatabase: LokiMessageDatabase,
+ private val mmsSmsDatabase: MmsSmsDatabase,
+ private val mmsDatabase: MmsDatabase,
+ private val smsDatabase: SmsDatabase,
+ private val blindedIdMappingDatabase: BlindedIdMappingDatabase,
+ private val groupMemberDatabase: GroupMemberDatabase,
+ private val reactionDatabase: ReactionDatabase,
+ private val lokiThreadDatabase: LokiThreadDatabase,
+ private val sessionContactDatabase: SessionContactDatabase,
+ private val expirationConfigurationDatabase: ExpirationConfigurationDatabase,
+ private val profileManager: SSKEnvironment.ProfileManagerProtocol,
+ private val notificationManager: MessageNotifier,
+ private val messageDataProvider: MessageDataProvider,
+ private val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol,
+ private val clock: SnodeClock,
+ private val preferences: TextSecurePreferences,
) : Database(context, helper), StorageProtocol, ThreadDatabase.ConversationThreadUpdateListener {
+ init {
+ threadDatabase.setUpdateListener(this)
+ }
+
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) {
+ when {
+ address.isLegacyGroup -> {
val accountId = GroupUtil.doubleDecodeGroupId(address.serialize())
val closedGroup = getGroup(address.toGroupString())
if (closedGroup != null && closedGroup.isActive) {
- val legacyGroup = groups.getOrConstructLegacyGroupInfo(accountId)
- groups.set(legacyGroup)
- val newVolatileParams = volatile.getOrConstructLegacyGroup(accountId).copy(
- lastRead = SnodeAPI.nowWithOffset,
- )
- volatile.set(newVolatileParams)
+ configFactory.withMutableUserConfigs { configs ->
+ val legacyGroup = configs.userGroups.getOrConstructLegacyGroupInfo(accountId)
+ configs.userGroups.set(legacyGroup)
+ val newVolatileParams = configs.convoInfoVolatile.getOrConstructLegacyGroup(accountId).copy(
+ lastRead = clock.currentTimeMills(),
+ )
+ configs.convoInfoVolatile.set(newVolatileParams)
+ }
+
}
- } else if (address.isCommunity) {
+ }
+ address.isGroupV2 -> {
+ configFactory.withMutableUserConfigs { configs ->
+ val accountId = address.serialize()
+ configs.userGroups.getClosedGroup(accountId)
+ ?: return@withMutableUserConfigs Log.d("Closed group doesn't exist locally", NullPointerException())
+
+ configs.convoInfoVolatile.getOrConstructClosedGroup(accountId)
+ }
+
+ }
+ address.isCommunity -> {
// 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 (AccountId(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 = PRIORITY_VISIBLE
+
+ address.isContact -> {
+ // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config
+ if (AccountId(address.serialize()).prefix != IdPrefix.STANDARD) return
+ // don't update our own address into the contacts DB
+ if (getUserPublicKey() != address.serialize()) {
+ configFactory.withMutableUserConfigs { configs ->
+ configs.contacts.upsertContact(address.serialize()) {
+ priority = PRIORITY_VISIBLE
+ }
+ }
+ } else {
+ configFactory.withMutableUserConfigs { configs ->
+ configs.userProfile.setNtsPriority(PRIORITY_VISIBLE)
+ }
+
+ threadDatabase.setHasSent(threadId, true)
+ }
+
+ configFactory.withMutableUserConfigs { configs ->
+ configs.convoInfoVolatile.getOrConstructOneToOne(address.serialize())
}
- } else {
- val userProfile = configFactory.user ?: return
- userProfile.setNtsPriority(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 accountId = GroupUtil.doubleDecodeGroupId(address.serialize())
- volatile.eraseLegacyClosedGroup(accountId)
- groups.eraseLegacyGroup(accountId)
- } else if (address.isCommunity) {
- // 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 (AccountId(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
+ configFactory.withMutableUserConfigs { configs ->
+ if (address.isGroupOrCommunity) {
+ if (address.isLegacyGroup) {
+ val accountId = GroupUtil.doubleDecodeGroupId(address.serialize())
+ configs.convoInfoVolatile.eraseLegacyClosedGroup(accountId)
+ configs.userGroups.eraseLegacyGroup(accountId)
+ } else if (address.isCommunity) {
+ // 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 if (address.isGroupV2) {
+ Log.w("Loki", "Thread delete called for closed group address, expecting to be handled elsewhere")
}
} else {
- val userProfile = configFactory.user ?: return
- userProfile.setNtsPriority(PRIORITY_HIDDEN)
+ // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config
+ if (AccountId(address.serialize()).prefix != IdPrefix.STANDARD) return@withMutableUserConfigs
+ configs.convoInfoVolatile.eraseOneToOne(address.serialize())
+ if (getUserPublicKey() != address.serialize()) {
+ configs.contacts.upsertContact(address.serialize()) {
+ priority = PRIORITY_HIDDEN
+ }
+ } else {
+ configs.userProfile.setNtsPriority(PRIORITY_HIDDEN)
+ }
}
+
+ Unit
}
- ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
override fun getUserPublicKey(): String? {
- return TextSecurePreferences.getLocalNumber(context)
+ return preferences.getLocalNumber()
}
override fun getUserX25519KeyPair(): ECKeyPair {
- return DatabaseComponent.get(context).lokiAPIDatabase().getUserX25519KeyPair()
+ return lokiAPIDatabase.getUserX25519KeyPair()
+ }
+
+ override fun getUserED25519KeyPair(): KeyPair? {
+ return KeyPairUtilities.getUserED25519KeyPair(context)
}
override fun getUserProfile(): Profile {
@@ -192,13 +239,13 @@ open class Storage(
}
override fun setProfilePicture(recipient: Recipient, newProfilePicture: String?, newProfileKey: ByteArray?) {
- val db = DatabaseComponent.get(context).recipientDatabase()
+ val db = recipientDatabase
db.setProfileAvatar(recipient, newProfilePicture)
db.setProfileKey(recipient, newProfileKey)
}
override fun setBlocksCommunityMessageRequests(recipient: Recipient, blocksMessageRequests: Boolean) {
- val db = DatabaseComponent.get(context).recipientDatabase()
+ val db = recipientDatabase
db.setBlocksCommunityMessageRequests(recipient, blocksMessageRequests)
}
@@ -207,8 +254,8 @@ open class Storage(
Recipient.from(context, it, false)
}
ourRecipient.resolve().profileKey = newProfileKey
- TextSecurePreferences.setProfileKey(context, newProfileKey?.let { Base64.encodeBytes(it) })
- TextSecurePreferences.setProfilePictureURL(context, newProfilePicture)
+ preferences.setProfileKey(newProfileKey?.let { Base64.encodeBytes(it) })
+ preferences.setProfilePictureURL(newProfilePicture)
if (newProfileKey != null) {
JobQueue.shared.add(RetrieveProfileAvatarJob(newProfilePicture, ourRecipient.address, newProfileKey))
@@ -225,46 +272,83 @@ open class Storage(
}
override fun persistAttachments(messageID: Long, attachments: List): List {
- val database = DatabaseComponent.get(context).attachmentDatabase()
+ val database = attachmentDatabase
val databaseAttachments = attachments.mapNotNull { it.toSignalAttachment() }
return database.insertAttachments(messageID, databaseAttachments)
}
override fun getAttachmentsForMessage(messageID: Long): List {
- val database = DatabaseComponent.get(context).attachmentDatabase()
- return database.getAttachmentsForMessage(messageID)
+ return attachmentDatabase.getAttachmentsForMessage(messageID)
}
override fun getLastSeen(threadId: Long): Long {
- val threadDb = DatabaseComponent.get(context).threadDatabase()
- return threadDb.getLastSeenAndHasSent(threadId)?.first() ?: 0L
+ return threadDatabase.getLastSeenAndHasSent(threadId)?.first() ?: 0L
+ }
+
+ override fun ensureMessageHashesAreSender(
+ hashes: Set,
+ sender: String,
+ closedGroupId: String
+ ): Boolean {
+ val threadId = getThreadId(fromSerialized(closedGroupId))!!
+ val senderIsMe = sender == getUserPublicKey()
+
+ val info = lokiMessageDatabase.getSendersForHashes(threadId, hashes)
+
+ if (senderIsMe) {
+ return info.all { it.isOutgoing }
+ } else {
+ return info.all { it.sender == sender }
+ }
+ }
+
+ override fun deleteMessagesByHash(threadId: Long, hashes: List) {
+ for (info in lokiMessageDatabase.getSendersForHashes(threadId, hashes.toSet())) {
+ messageDataProvider.deleteMessage(info.messageId, info.isSms)
+ if (!info.isOutgoing) {
+ notificationManager.updateNotification(context)
+ }
+ }
+ }
+
+ override fun deleteMessagesByUser(threadId: Long, userSessionId: String) {
+ val userMessages = mmsSmsDatabase.getUserMessages(threadId, userSessionId)
+ val (mmsMessages, smsMessages) = userMessages.partition { it.isMms }
+ if (mmsMessages.isNotEmpty()) {
+ messageDataProvider.deleteMessages(mmsMessages.map(MessageRecord::id), threadId, isSms = false)
+ }
+ if (smsMessages.isNotEmpty()) {
+ messageDataProvider.deleteMessages(smsMessages.map(MessageRecord::id), threadId, isSms = true)
+ }
}
override fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean) {
- val threadDb = DatabaseComponent.get(context).threadDatabase()
+ val threadDb = threadDatabase
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
+ if (!threadDb.markAllAsRead(threadId, recipient.isGroupOrCommunityRecipient, lastSeenTime, force) && !force) return
// don't process configs for inbox recipients
- if (recipient.isOpenGroupInboxRecipient) return
+ if (recipient.isCommunityInboxRecipient) return
- configFactory.convoVolatile?.let { config ->
+ configFactory.withMutableUserConfigs { configs ->
+ val config = configs.convoInfoVolatile
val convo = when {
// recipient closed group
- recipient.isClosedGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize()))
+ recipient.isLegacyGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize()))
+ recipient.isGroupV2Recipient -> config.getOrConstructClosedGroup(recipient.address.serialize())
// recipient is open group
recipient.isCommunityRecipient -> {
- val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return
+ val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return@withMutableUserConfigs
BaseCommunityInfo.parseFullUrl(openGroupJoinUrl)?.let { (base, room, pubKey) ->
config.getOrConstructCommunity(base, room, pubKey)
- } ?: return
+ } ?: return@withMutableUserConfigs
}
// otherwise recipient is one to one
recipient.isContactRecipient -> {
// don't process non-standard account IDs though
- if (AccountId(recipient.address.serialize()).prefix != IdPrefix.STANDARD) return
+ if (AccountId(recipient.address.serialize()).prefix != IdPrefix.STANDARD) return@withMutableUserConfigs
config.getOrConstructOneToOne(recipient.address.serialize())
}
else -> throw NullPointerException("Weren't expecting to have a convo with address ${recipient.address.serialize()}")
@@ -275,13 +359,12 @@ open class Storage(
notifyConversationListListeners()
}
config.set(convo)
- ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
}
}
override fun updateThread(threadId: Long, unarchive: Boolean) {
- val threadDb = DatabaseComponent.get(context).threadDatabase()
+ val threadDb = threadDatabase
threadDb.update(threadId, unarchive)
}
@@ -299,6 +382,9 @@ open class Storage(
?.let { SodiumUtilities.accountId(getUserPublicKey()!!, message.sender!!, it) } ?: false
val group: Optional = when {
openGroupID != null -> Optional.of(SignalServiceGroup(openGroupID.toByteArray(), SignalServiceGroup.GroupType.PUBLIC_CHAT))
+ groupPublicKey != null && groupPublicKey.startsWith(IdPrefix.GROUP.value) -> {
+ Optional.of(SignalServiceGroup(Hex.fromStringCondensed(groupPublicKey), SignalServiceGroup.GroupType.SIGNAL))
+ }
groupPublicKey != null -> {
val doubleEncoded = GroupUtil.doubleEncodeGroupID(groupPublicKey)
Optional.of(SignalServiceGroup(GroupUtil.getDecodedGroupIDAsData(doubleEncoded), SignalServiceGroup.GroupType.SIGNAL))
@@ -311,12 +397,19 @@ open class Storage(
val targetAddress = if ((isUserSender || isUserBlindedSender) && !message.syncTarget.isNullOrEmpty()) {
fromSerialized(message.syncTarget!!)
} else if (group.isPresent) {
- fromSerialized(GroupUtil.getEncodedId(group.get()))
+ val idHex = group.get().groupId.toHexString()
+ if (idHex.startsWith(IdPrefix.GROUP.value)) {
+ fromSerialized(idHex)
+ } else {
+ fromSerialized(GroupUtil.getEncodedId(group.get()))
+ }
+ } else if (message.recipient?.startsWith(IdPrefix.GROUP.value) == true) {
+ fromSerialized(message.recipient!!)
} else {
senderAddress
}
val targetRecipient = Recipient.from(context, targetAddress, false)
- if (!targetRecipient.isGroupRecipient) {
+ if (!targetRecipient.isGroupOrCommunityRecipient) {
if (isUserSender || isUserBlindedSender) {
setRecipientApproved(targetRecipient, true)
} else {
@@ -333,7 +426,6 @@ open class Storage(
if (message.isMediaMessage() || attachments.isNotEmpty()) {
val quote: Optional = if (quotes != null) Optional.of(quotes) else Optional.absent()
val linkPreviews: Optional> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! })
- val mmsDatabase = DatabaseComponent.get(context).mmsDatabase()
val insertResult = if (isUserSender || isUserBlindedSender) {
val mediaMessage = OutgoingMediaMessage.from(
message,
@@ -357,7 +449,6 @@ open class Storage(
messageID = insertResult.get().messageId
}
} else {
- val smsDatabase = DatabaseComponent.get(context).smsDatabase()
val isOpenGroupInvitation = (message.openGroupInvitation != null)
val insertResult = if (isUserSender || isUserBlindedSender) {
@@ -376,71 +467,61 @@ open class Storage(
}
message.serverHash?.let { serverHash ->
messageID?.let { id ->
- DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(id, message.isMediaMessage(), serverHash)
+ lokiMessageDatabase.setMessageServerHash(id, message.isMediaMessage(), serverHash)
}
}
return messageID
}
override fun persistJob(job: Job) {
- DatabaseComponent.get(context).sessionJobDatabase().persistJob(job)
+ jobDatabase.persistJob(job)
}
override fun markJobAsSucceeded(jobId: String) {
- DatabaseComponent.get(context).sessionJobDatabase().markJobAsSucceeded(jobId)
+ jobDatabase.markJobAsSucceeded(jobId)
}
override fun markJobAsFailedPermanently(jobId: String) {
- DatabaseComponent.get(context).sessionJobDatabase().markJobAsFailedPermanently(jobId)
+ jobDatabase.markJobAsFailedPermanently(jobId)
}
override fun getAllPendingJobs(vararg types: String): Map {
- return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(*types)
+ return jobDatabase.getAllJobs(*types)
}
override fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob? {
- return DatabaseComponent.get(context).sessionJobDatabase().getAttachmentUploadJob(attachmentID)
+ return jobDatabase.getAttachmentUploadJob(attachmentID)
}
override fun getMessageSendJob(messageSendJobID: String): MessageSendJob? {
- return DatabaseComponent.get(context).sessionJobDatabase().getMessageSendJob(messageSendJobID)
+ return jobDatabase.getMessageSendJob(messageSendJobID)
}
override fun getMessageReceiveJob(messageReceiveJobID: String): MessageReceiveJob? {
- return DatabaseComponent.get(context).sessionJobDatabase().getMessageReceiveJob(messageReceiveJobID)
+ return jobDatabase.getMessageReceiveJob(messageReceiveJobID)
}
override fun getGroupAvatarDownloadJob(server: String, room: String, imageId: String?): GroupAvatarDownloadJob? {
- 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
- }
+ return jobDatabase.getGroupAvatarDownloadJob(server, room, imageId)
}
override fun resumeMessageSendJobIfNeeded(messageSendJobID: String) {
- val job = DatabaseComponent.get(context).sessionJobDatabase().getMessageSendJob(messageSendJobID) ?: return
+ val job = jobDatabase.getMessageSendJob(messageSendJobID) ?: return
JobQueue.shared.resumePendingSendMessage(job)
}
override fun isJobCanceled(job: Job): Boolean {
- return DatabaseComponent.get(context).sessionJobDatabase().isJobCanceled(job)
+ return jobDatabase.isJobCanceled(job)
}
override fun cancelPendingMessageSendJobs(threadID: Long) {
- val jobDb = DatabaseComponent.get(context).sessionJobDatabase()
+ val jobDb = jobDatabase
jobDb.cancelPendingMessageSendJobs(threadID)
}
override fun getAuthToken(room: String, server: String): String? {
val id = "$server.$room"
- return DatabaseComponent.get(context).lokiAPIDatabase().getAuthToken(id)
- }
-
- override fun notifyConfigUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) {
- notifyUpdates(forConfigObject, messageTimestamp)
+ return lokiAPIDatabase.getAuthToken(id)
}
override fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean {
@@ -452,210 +533,36 @@ open class Storage(
}
override fun isCheckingCommunityRequests(): Boolean {
- return configFactory.user?.getCommunityMessageRequests() == true
- }
-
- private fun notifyUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) {
- when (forConfigObject) {
- is UserProfile -> updateUser(forConfigObject, messageTimestamp)
- is Contacts -> updateContacts(forConfigObject, messageTimestamp)
- is ConversationVolatileConfig -> updateConvoVolatile(forConfigObject, messageTimestamp)
- is UserGroupsConfig -> updateUserGroups(forConfigObject, messageTimestamp)
- }
- }
-
- private fun updateUser(userProfile: UserProfile, messageTimestamp: Long) {
- val userPublicKey = getUserPublicKey() ?: return
- // would love to get rid of recipient and context from this
- val recipient = Recipient.from(context, fromSerialized(userPublicKey), false)
-
- // Update profile name
- val name = userProfile.getName() ?: return
- val userPic = userProfile.getPic()
- val profileManager = SSKEnvironment.shared.profileManager
-
- name.takeUnless { it.isEmpty() }?.truncate(NAME_PADDED_LENGTH)?.let {
- TextSecurePreferences.setProfileName(context, it)
- profileManager.setName(context, recipient, it)
- if (it != name) userProfile.setName(it)
- }
-
- // Update profile picture
- 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 address = recipient.address
- val ourThread = getThreadId(address) ?: getOrCreateThreadIdFor(address).also {
- setThreadDate(it, 0)
- }
- DatabaseComponent.get(context).threadDatabase().setHasSent(ourThread, true)
- setPinned(ourThread, userProfile.getNtsPriority() > 0)
- }
-
- // Set or reset the shared library to use latest expiration config
- getThreadId(recipient)?.let {
- setExpirationConfiguration(
- getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp } ?: ExpirationConfiguration(it, userProfile.getNtsExpiry(), messageTimestamp)
- )
- }
- }
-
- private fun updateContacts(contacts: Contacts, messageTimestamp: Long) {
- val extracted = contacts.all().toList()
- addLibSessionContacts(extracted, messageTimestamp)
+ return configFactory.withUserConfigs { it.userProfile.getCommunityMessageRequests() }
}
- override fun clearUserPic() {
+ override fun clearUserPic(clearConfig: Boolean) {
val userPublicKey = getUserPublicKey() ?: return Log.w(TAG, "No user public key when trying to clear user pic")
- val recipientDatabase = DatabaseComponent.get(context).recipientDatabase()
-
val recipient = Recipient.from(context, fromSerialized(userPublicKey), false)
// Clear details related to the user's profile picture
- TextSecurePreferences.setProfileKey(context, null)
+ preferences.setProfileKey(null)
ProfileKeyUtil.setEncodedProfileKey(context, null)
recipientDatabase.setProfileAvatar(recipient, null)
- TextSecurePreferences.setProfileAvatarId(context, 0)
- TextSecurePreferences.setProfilePictureURL(context, null)
+ preferences.setProfileAvatarId(0)
+ preferences.setProfilePictureURL(null)
Recipient.removeCached(fromSerialized(userPublicKey))
- configFactory.user?.setPic(UserPic.DEFAULT)
- }
-
- private fun updateConvoVolatile(convos: ConversationVolatileConfig, messageTimestamp: Long) {
- val extracted = convos.all().filterNotNull()
- for (conversation in extracted) {
- val threadId = when (conversation) {
- is Conversation.OneToOne -> getThreadIdFor(conversation.accountId, 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, messageTimestamp: Long) {
- 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 = 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.accountId }
- 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 groupId = GroupUtil.doubleEncodeGroupID(group.accountId)
- val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.accountId }
- 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 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.accountId)
- // Store the encryption key pair
- val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey))
- addClosedGroupEncryptionKeyPair(keyPair, group.accountId, SnodeAPI.nowWithOffset)
- // Notify the PN server
- PushRegistryV1.subscribeGroup(group.accountId, publicKey = localUserPublicKey)
- // Notify the user
- val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId))
- threadDb.setDate(threadID, formationTimestamp)
-
- // Note: Commenting out this line prevents the timestamp of room creation being added to a new closed group,
- // which in turn allows us to show the `groupNoMessages` control message text.
- //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.accountId)
- }
- getThreadId(Address.fromSerialized(groupId))?.let {
- setExpirationConfiguration(
- getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp }
- ?: ExpirationConfiguration(it, afterSend(group.disappearingTimer), messageTimestamp)
- )
+ if (clearConfig) {
+ configFactory.withMutableUserConfigs {
+ it.userProfile.setPic(UserPic.DEFAULT)
}
}
}
override fun setAuthToken(room: String, server: String, newValue: String) {
val id = "$server.$room"
- DatabaseComponent.get(context).lokiAPIDatabase().setAuthToken(id, newValue)
+ lokiAPIDatabase.setAuthToken(id, newValue)
}
override fun removeAuthToken(room: String, server: String) {
val id = "$server.$room"
- DatabaseComponent.get(context).lokiAPIDatabase().setAuthToken(id, null)
+ lokiAPIDatabase.setAuthToken(id, null)
}
override fun getOpenGroup(threadId: Long): OpenGroup? {
@@ -668,44 +575,44 @@ open class Storage(
}
override fun getOpenGroupPublicKey(server: String): String? {
- return DatabaseComponent.get(context).lokiAPIDatabase().getOpenGroupPublicKey(server)
+ return lokiAPIDatabase.getOpenGroupPublicKey(server)
}
override fun setOpenGroupPublicKey(server: String, newValue: String) {
- DatabaseComponent.get(context).lokiAPIDatabase().setOpenGroupPublicKey(server, newValue)
+ lokiAPIDatabase.setOpenGroupPublicKey(server, newValue)
}
override fun getLastMessageServerID(room: String, server: String): Long? {
- return DatabaseComponent.get(context).lokiAPIDatabase().getLastMessageServerID(room, server)
+ return lokiAPIDatabase.getLastMessageServerID(room, server)
}
override fun setLastMessageServerID(room: String, server: String, newValue: Long) {
- DatabaseComponent.get(context).lokiAPIDatabase().setLastMessageServerID(room, server, newValue)
+ lokiAPIDatabase.setLastMessageServerID(room, server, newValue)
}
override fun removeLastMessageServerID(room: String, server: String) {
- DatabaseComponent.get(context).lokiAPIDatabase().removeLastMessageServerID(room, server)
+ lokiAPIDatabase.removeLastMessageServerID(room, server)
}
override fun getLastDeletionServerID(room: String, server: String): Long? {
- return DatabaseComponent.get(context).lokiAPIDatabase().getLastDeletionServerID(room, server)
+ return lokiAPIDatabase.getLastDeletionServerID(room, server)
}
override fun setLastDeletionServerID(room: String, server: String, newValue: Long) {
- DatabaseComponent.get(context).lokiAPIDatabase().setLastDeletionServerID(room, server, newValue)
+ lokiAPIDatabase.setLastDeletionServerID(room, server, newValue)
}
override fun removeLastDeletionServerID(room: String, server: String) {
- DatabaseComponent.get(context).lokiAPIDatabase().removeLastDeletionServerID(room, server)
+ lokiAPIDatabase.removeLastDeletionServerID(room, server)
}
override fun setUserCount(room: String, server: String, newValue: Int) {
- DatabaseComponent.get(context).lokiAPIDatabase().setUserCount(room, server, newValue)
+ lokiAPIDatabase.setUserCount(room, server, newValue)
}
override fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean) {
- DatabaseComponent.get(context).lokiMessageDatabase().setServerID(messageID, serverID, isSms)
- DatabaseComponent.get(context).lokiMessageDatabase().setOriginalThreadID(messageID, serverID, threadID)
+ lokiMessageDatabase.setServerID(messageID, serverID, isSms)
+ lokiMessageDatabase.setOriginalThreadID(messageID, serverID, threadID)
}
override fun getOpenGroup(room: String, server: String): OpenGroup? {
@@ -713,7 +620,7 @@ open class Storage(
}
override fun setGroupMemberRoles(members: List) {
- DatabaseComponent.get(context).groupMemberDatabase().setGroupMembers(members)
+ groupMemberDatabase.setGroupMembers(members)
}
override fun isDuplicateMessage(timestamp: Long): Boolean {
@@ -721,19 +628,19 @@ open class Storage(
}
override fun updateTitle(groupID: String, newValue: String) {
- DatabaseComponent.get(context).groupDatabase().updateTitle(groupID, newValue)
+ groupDatabase.updateTitle(groupID, newValue)
}
override fun updateProfilePicture(groupID: String, newValue: ByteArray) {
- DatabaseComponent.get(context).groupDatabase().updateProfilePicture(groupID, newValue)
+ groupDatabase.updateProfilePicture(groupID, newValue)
}
override fun removeProfilePicture(groupID: String) {
- DatabaseComponent.get(context).groupDatabase().removeProfilePicture(groupID)
+ groupDatabase.removeProfilePicture(groupID)
}
override fun hasDownloadedProfilePicture(groupID: String): Boolean {
- return DatabaseComponent.get(context).groupDatabase().hasDownloadedProfilePicture(groupID)
+ return groupDatabase.hasDownloadedProfilePicture(groupID)
}
override fun getReceivedMessageTimestamps(): Set {
@@ -749,15 +656,14 @@ open class Storage(
}
override fun getMessageIdInDatabase(timestamp: Long, author: String): Pair? {
- val database = DatabaseComponent.get(context).mmsSmsDatabase()
+ val database = mmsSmsDatabase
val address = fromSerialized(author)
return database.getMessageFor(timestamp, address)?.run { getId() to isMms }
}
override fun getMessageType(timestamp: Long, author: String): MessageType? {
- val database = DatabaseComponent.get(context).mmsSmsDatabase()
val address = fromSerialized(author)
- return database.getMessageFor(timestamp, address)?.individualRecipient?.getType()
+ return mmsSmsDatabase.getMessageFor(timestamp, address)?.individualRecipient?.getType()
}
override fun updateSentTimestamp(
@@ -767,16 +673,16 @@ open class Storage(
threadId: Long
) {
if (isMms) {
- val mmsDb = DatabaseComponent.get(context).mmsDatabase()
+ val mmsDb = mmsDatabase
mmsDb.updateSentTimestamp(messageID, openGroupSentTimestamp, threadId)
} else {
- val smsDb = DatabaseComponent.get(context).smsDatabase()
+ val smsDb = smsDatabase
smsDb.updateSentTimestamp(messageID, openGroupSentTimestamp, threadId)
}
}
override fun markAsSent(timestamp: Long, author: String) {
- val database = DatabaseComponent.get(context).mmsSmsDatabase()
+ val database = mmsSmsDatabase
val messageRecord = database.getSentMessageFor(timestamp, author)
if (messageRecord == null) {
Log.w(TAG, "Failed to retrieve local message record in Storage.markAsSent - aborting.")
@@ -784,17 +690,17 @@ open class Storage(
}
if (messageRecord.isMms) {
- DatabaseComponent.get(context).mmsDatabase().markAsSent(messageRecord.getId(), true)
+ mmsDatabase.markAsSent(messageRecord.getId(), true)
} else {
- DatabaseComponent.get(context).smsDatabase().markAsSent(messageRecord.getId(), true)
+ smsDatabase.markAsSent(messageRecord.getId(), true)
}
}
// Method that marks a message as sent in Communities (only!) - where the server modifies the
// message timestamp and as such we cannot use that to identify the local message.
override fun markAsSentToCommunity(threadId: Long, messageID: Long) {
- val database = DatabaseComponent.get(context).mmsSmsDatabase()
- val message = database.getLastSentMessageRecordFromSender(threadId, TextSecurePreferences.getLocalNumber(context))
+ val database = mmsSmsDatabase
+ val message = database.getLastSentMessageRecordFromSender(threadId, preferences.getLocalNumber())
// Ensure we can find the local message..
if (message == null) {
@@ -804,53 +710,53 @@ open class Storage(
// ..and mark as sent if found.
if (message.isMms) {
- DatabaseComponent.get(context).mmsDatabase().markAsSent(message.getId(), true)
+ mmsDatabase.markAsSent(message.getId(), true)
} else {
- DatabaseComponent.get(context).smsDatabase().markAsSent(message.getId(), true)
+ smsDatabase.markAsSent(message.getId(), true)
}
}
override fun markAsSyncing(timestamp: Long, author: String) {
- DatabaseComponent.get(context).mmsSmsDatabase()
+ mmsSmsDatabase
.getMessageFor(timestamp, author)
?.run { getMmsDatabaseElseSms(isMms).markAsSyncing(id) }
}
private fun getMmsDatabaseElseSms(isMms: Boolean) =
- if (isMms) DatabaseComponent.get(context).mmsDatabase()
- else DatabaseComponent.get(context).smsDatabase()
+ if (isMms) mmsDatabase
+ else smsDatabase
override fun markAsResyncing(timestamp: Long, author: String) {
- DatabaseComponent.get(context).mmsSmsDatabase()
+ mmsSmsDatabase
.getMessageFor(timestamp, author)
?.run { getMmsDatabaseElseSms(isMms).markAsResyncing(id) }
}
override fun markAsSending(timestamp: Long, author: String) {
- val database = DatabaseComponent.get(context).mmsSmsDatabase()
+ val database = mmsSmsDatabase
val messageRecord = database.getMessageFor(timestamp, author) ?: return
if (messageRecord.isMms) {
- val mmsDatabase = DatabaseComponent.get(context).mmsDatabase()
+ val mmsDatabase = mmsDatabase
mmsDatabase.markAsSending(messageRecord.getId())
} else {
- val smsDatabase = DatabaseComponent.get(context).smsDatabase()
+ val smsDatabase = smsDatabase
smsDatabase.markAsSending(messageRecord.getId())
messageRecord.isPending
}
}
override fun markUnidentified(timestamp: Long, author: String) {
- val database = DatabaseComponent.get(context).mmsSmsDatabase()
+ val database = mmsSmsDatabase
val messageRecord = database.getMessageFor(timestamp, author)
if (messageRecord == null) {
Log.w(TAG, "Could not identify message with timestamp: $timestamp from author: $author")
return
}
if (messageRecord.isMms) {
- val mmsDatabase = DatabaseComponent.get(context).mmsDatabase()
+ val mmsDatabase = mmsDatabase
mmsDatabase.markUnidentified(messageRecord.getId(), true)
} else {
- val smsDatabase = DatabaseComponent.get(context).smsDatabase()
+ val smsDatabase = smsDatabase
smsDatabase.markUnidentified(messageRecord.getId(), true)
}
}
@@ -858,8 +764,8 @@ open class Storage(
// Method that marks a message as unidentified in Communities (only!) - where the server
// modifies the message timestamp and as such we cannot use that to identify the local message.
override fun markUnidentifiedInCommunity(threadId: Long, messageId: Long) {
- val database = DatabaseComponent.get(context).mmsSmsDatabase()
- val message = database.getLastSentMessageRecordFromSender(threadId, TextSecurePreferences.getLocalNumber(context))
+ val database = mmsSmsDatabase
+ val message = database.getLastSentMessageRecordFromSender(threadId, preferences.getLocalNumber())
// Check to ensure the message exists
if (message == null) {
@@ -869,20 +775,20 @@ open class Storage(
// Mark it as unidentified if we found the message successfully
if (message.isMms) {
- DatabaseComponent.get(context).mmsDatabase().markUnidentified(message.getId(), true)
+ mmsDatabase.markUnidentified(message.getId(), true)
} else {
- DatabaseComponent.get(context).smsDatabase().markUnidentified(message.getId(), true)
+ smsDatabase.markUnidentified(message.getId(), true)
}
}
override fun markAsSentFailed(timestamp: Long, author: String, error: Exception) {
- val database = DatabaseComponent.get(context).mmsSmsDatabase()
+ val database = mmsSmsDatabase
val messageRecord = database.getMessageFor(timestamp, author) ?: return
if (messageRecord.isMms) {
- val mmsDatabase = DatabaseComponent.get(context).mmsDatabase()
+ val mmsDatabase = mmsDatabase
mmsDatabase.markAsSentFailed(messageRecord.getId())
} else {
- val smsDatabase = DatabaseComponent.get(context).smsDatabase()
+ val smsDatabase = smsDatabase
smsDatabase.markAsSentFailed(messageRecord.getId())
}
if (error.localizedMessage != null) {
@@ -892,14 +798,14 @@ open class Storage(
} else {
message = error.localizedMessage!!
}
- DatabaseComponent.get(context).lokiMessageDatabase().setErrorMessage(messageRecord.getId(), message)
+ lokiMessageDatabase.setErrorMessage(messageRecord.getId(), message)
} else {
- DatabaseComponent.get(context).lokiMessageDatabase().setErrorMessage(messageRecord.getId(), error.javaClass.simpleName)
+ lokiMessageDatabase.setErrorMessage(messageRecord.getId(), error.javaClass.simpleName)
}
}
override fun markAsSyncFailed(timestamp: Long, author: String, error: Exception) {
- val database = DatabaseComponent.get(context).mmsSmsDatabase()
+ val database = mmsSmsDatabase
val messageRecord = database.getMessageFor(timestamp, author) ?: return
database.getMessageFor(timestamp, author)
@@ -912,50 +818,54 @@ open class Storage(
} else {
message = error.localizedMessage!!
}
- DatabaseComponent.get(context).lokiMessageDatabase().setErrorMessage(messageRecord.getId(), message)
+ lokiMessageDatabase.setErrorMessage(messageRecord.getId(), message)
} else {
- DatabaseComponent.get(context).lokiMessageDatabase().setErrorMessage(messageRecord.getId(), error.javaClass.simpleName)
+ lokiMessageDatabase.setErrorMessage(messageRecord.getId(), error.javaClass.simpleName)
}
}
override fun clearErrorMessage(messageID: Long) {
- val db = DatabaseComponent.get(context).lokiMessageDatabase()
+ val db = lokiMessageDatabase
db.clearErrorMessage(messageID)
}
override fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) {
- DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(messageID, mms, serverHash)
+ lokiMessageDatabase.setMessageServerHash(messageID, mms, serverHash)
}
override fun getGroup(groupID: String): GroupRecord? {
- val group = DatabaseComponent.get(context).groupDatabase().getGroup(groupID)
+ val group = groupDatabase.getGroup(groupID)
return if (group.isPresent) { group.get() } else null
}
override fun createGroup(groupId: String, title: String?, members: List, avatar: SignalServiceAttachmentPointer?, relay: String?, admins: List, formationTimestamp: Long) {
- DatabaseComponent.get(context).groupDatabase().create(groupId, title, members, avatar, relay, admins, formationTimestamp)
+ groupDatabase.create(groupId, title, members, avatar, relay, admins, formationTimestamp)
}
override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map, formationTimestamp: Long, encryptionKeyPair: ECKeyPair, expirationTimer: Int) {
- val volatiles = configFactory.convoVolatile ?: return
- val userGroups = configFactory.userGroups ?: return
- if (volatiles.getLegacyClosedGroup(groupPublicKey) != null && userGroups.getLegacyGroupInfo(groupPublicKey) != null) return
- val groupVolatileConfig = volatiles.getOrConstructLegacyGroup(groupPublicKey)
- groupVolatileConfig.lastRead = formationTimestamp
- volatiles.set(groupVolatileConfig)
- val groupInfo = GroupInfo.LegacyGroupInfo(
- accountId = groupPublicKey,
- name = name,
- members = members,
- priority = PRIORITY_VISIBLE,
- encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
- encSecKey = encryptionKeyPair.privateKey.serialize(),
- disappearingTimer = expirationTimer.toLong(),
- joinedAt = (formationTimestamp / 1000L)
- )
- // shouldn't exist, don't use getOrConstruct + copy
- userGroups.set(groupInfo)
- ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
+ configFactory.withMutableUserConfigs {
+ val volatiles = it.convoInfoVolatile
+ val userGroups = it.userGroups
+ if (volatiles.getLegacyClosedGroup(groupPublicKey) != null && userGroups.getLegacyGroupInfo(groupPublicKey) != null) {
+ return@withMutableUserConfigs
+ }
+
+ val groupVolatileConfig = volatiles.getOrConstructLegacyGroup(groupPublicKey)
+ groupVolatileConfig.lastRead = formationTimestamp
+ volatiles.set(groupVolatileConfig)
+ val groupInfo = GroupInfo.LegacyGroupInfo(
+ accountId = groupPublicKey,
+ name = name,
+ members = members,
+ priority = PRIORITY_VISIBLE,
+ encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
+ encSecKey = encryptionKeyPair.privateKey.serialize(),
+ disappearingTimer = expirationTimer.toLong(),
+ joinedAt = (formationTimestamp / 1000L)
+ )
+ // shouldn't exist, don't use getOrConstruct + copy
+ userGroups.set(groupInfo)
+ }
}
override fun updateGroupConfig(groupPublicKey: String) {
@@ -963,139 +873,249 @@ open class Storage(
val groupAddress = fromSerialized(groupID)
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
+ configFactory.withMutableUserConfigs {
+ val userGroups = it.userGroups
+ if (!existingGroup.isActive) {
+ userGroups.eraseLegacyGroup(groupPublicKey)
+ return@withMutableUserConfigs
+ }
+ 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@withMutableUserConfigs Log.w("Loki-DBG", "No latest closed group encryption key pair for ${groupPublicKey.take(4)}} when updating group config")
+
+ val threadID = getThreadId(groupAddress) ?: return@withMutableUserConfigs
+ 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 PRIORITY_VISIBLE,
+ disappearingTimer = getExpirationConfiguration(threadID)?.expiryMode?.expirySeconds ?: 0L,
+ joinedAt = (existingGroup.formationTimestamp / 1000L)
+ )
+ userGroups.set(groupInfo)
}
- 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 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 PRIORITY_VISIBLE,
- disappearingTimer = getExpirationConfiguration(threadID)?.expiryMode?.expirySeconds ?: 0L,
- joinedAt = (existingGroup.formationTimestamp / 1000L)
- )
- userGroups.set(groupInfo)
}
override fun isGroupActive(groupPublicKey: String): Boolean {
- return DatabaseComponent.get(context).groupDatabase().getGroup(GroupUtil.doubleEncodeGroupID(groupPublicKey)).orNull()?.isActive == true
+ return groupDatabase.getGroup(GroupUtil.doubleEncodeGroupID(groupPublicKey)).orNull()?.isActive == true
}
override fun setActive(groupID: String, value: Boolean) {
- DatabaseComponent.get(context).groupDatabase().setActive(groupID, value)
+ groupDatabase.setActive(groupID, value)
}
override fun getZombieMembers(groupID: String): Set {
- return DatabaseComponent.get(context).groupDatabase().getGroupZombieMembers(groupID).map { it.address.serialize() }.toHashSet()
+ return groupDatabase.getGroupZombieMembers(groupID).map { it.address.serialize() }.toHashSet()
}
override fun removeMember(groupID: String, member: Address) {
- DatabaseComponent.get(context).groupDatabase().removeMember(groupID, member)
+ groupDatabase.removeMember(groupID, member)
}
override fun updateMembers(groupID: String, members: List) {
- DatabaseComponent.get(context).groupDatabase().updateMembers(groupID, members)
+ groupDatabase.updateMembers(groupID, members)
}
override fun setZombieMembers(groupID: String, members: List) {
- DatabaseComponent.get(context).groupDatabase().updateZombieMembers(groupID, members)
+ groupDatabase.updateZombieMembers(groupID, members)
}
- override fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, sentTimestamp: Long) {
+ override fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, sentTimestamp: Long): Long? {
val group = SignalServiceGroup(type, GroupUtil.getDecodedGroupIDAsData(groupID), SignalServiceGroup.GroupType.SIGNAL, name, members.toList(), null, admins.toList())
val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), 0, 0, true, false)
val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON()
- val infoMessage = IncomingGroupMessage(m, groupID, updateData, true)
- val smsDB = DatabaseComponent.get(context).smsDatabase()
- smsDB.insertMessageInbox(infoMessage, true)
+ val infoMessage = IncomingGroupMessage(m, updateData, true)
+ val smsDB = smsDatabase
+ return smsDB.insertMessageInbox(infoMessage, true).orNull().messageId
+ }
+
+ override fun updateInfoMessage(context: Context, messageId: Long, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection) {
+ val mmsDB = mmsDatabase
+ val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON()
+ mmsDB.updateInfoMessage(messageId, updateData)
}
- override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, threadID: Long, sentTimestamp: Long) {
+ override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, threadID: Long, sentTimestamp: Long): Long? {
val userPublicKey = getUserPublicKey()!!
val recipient = Recipient.from(context, fromSerialized(groupID), false)
val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() ?: ""
val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, groupID, null, sentTimestamp, 0, 0, true, null, listOf(), listOf())
- val mmsDB = DatabaseComponent.get(context).mmsDatabase()
- val mmsSmsDB = DatabaseComponent.get(context).mmsSmsDatabase()
+ val mmsDB = mmsDatabase
+ val mmsSmsDB = mmsSmsDatabase
if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) {
Log.w(TAG, "Bailing from insertOutgoingInfoMessage because we believe the message has already been sent!")
- return
+ return null
}
val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null, runThreadUpdate = true)
mmsDB.markAsSent(infoMessageID, true)
+ return infoMessageID
}
- override fun isClosedGroup(publicKey: String): Boolean {
- val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(publicKey)
- val address = fromSerialized(publicKey)
- return address.isClosedGroup || isClosedGroup
+ override fun isLegacyClosedGroup(publicKey: String): Boolean {
+ return lokiAPIDatabase.isClosedGroup(publicKey)
}
override fun getClosedGroupEncryptionKeyPairs(groupPublicKey: String): MutableList {
- return DatabaseComponent.get(context).lokiAPIDatabase().getClosedGroupEncryptionKeyPairs(groupPublicKey).toMutableList()
+ return lokiAPIDatabase.getClosedGroupEncryptionKeyPairs(groupPublicKey).toMutableList()
}
override fun getLatestClosedGroupEncryptionKeyPair(groupPublicKey: String): ECKeyPair? {
- return DatabaseComponent.get(context).lokiAPIDatabase().getLatestClosedGroupEncryptionKeyPair(groupPublicKey)
+ return lokiAPIDatabase.getLatestClosedGroupEncryptionKeyPair(groupPublicKey)
}
override fun getAllClosedGroupPublicKeys(): Set {
- return DatabaseComponent.get(context).lokiAPIDatabase().getAllClosedGroupPublicKeys()
+ return lokiAPIDatabase.getAllClosedGroupPublicKeys()
}
override fun getAllActiveClosedGroupPublicKeys(): Set {
- return DatabaseComponent.get(context).lokiAPIDatabase().getAllClosedGroupPublicKeys().filter {
+ return lokiAPIDatabase.getAllClosedGroupPublicKeys().filter {
getGroup(GroupUtil.doubleEncodeGroupID(it))?.isActive == true
}.toSet()
}
override fun addClosedGroupPublicKey(groupPublicKey: String) {
- DatabaseComponent.get(context).lokiAPIDatabase().addClosedGroupPublicKey(groupPublicKey)
+ lokiAPIDatabase.addClosedGroupPublicKey(groupPublicKey)
}
override fun removeClosedGroupPublicKey(groupPublicKey: String) {
- DatabaseComponent.get(context).lokiAPIDatabase().removeClosedGroupPublicKey(groupPublicKey)
+ lokiAPIDatabase.removeClosedGroupPublicKey(groupPublicKey)
}
override fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) {
- DatabaseComponent.get(context).lokiAPIDatabase().addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, timestamp)
+ lokiAPIDatabase.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, timestamp)
}
override fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String) {
- DatabaseComponent.get(context).lokiAPIDatabase().removeAllClosedGroupEncryptionKeyPairs(groupPublicKey)
+ lokiAPIDatabase.removeAllClosedGroupEncryptionKeyPairs(groupPublicKey)
+ }
+
+ override fun removeClosedGroupThread(threadID: Long) {
+ threadDatabase.deleteConversation(threadID)
}
override fun updateFormationTimestamp(groupID: String, formationTimestamp: Long) {
- DatabaseComponent.get(context).groupDatabase()
+ groupDatabase
.updateFormationTimestamp(groupID, formationTimestamp)
}
override fun updateTimestampUpdated(groupID: String, updatedTimestamp: Long) {
- DatabaseComponent.get(context).groupDatabase()
+ groupDatabase
.updateTimestampUpdated(groupID, updatedTimestamp)
}
+ /**
+ * For new closed groups
+ */
+ override fun getMembers(groupPublicKey: String): List =
+ configFactory.withGroupConfigs(AccountId(groupPublicKey)) {
+ it.groupMembers.all()
+ }
+
+ override fun getClosedGroupDisplayInfo(groupAccountId: String): GroupDisplayInfo? {
+ val groupIsAdmin = configFactory.getGroup(AccountId(groupAccountId))?.hasAdminKey() ?: return null
+
+ return configFactory.withGroupConfigs(AccountId(groupAccountId)) { configs ->
+ val info = configs.groupInfo
+ GroupDisplayInfo(
+ id = info.id(),
+ name = info.getName(),
+ profilePic = info.getProfilePic(),
+ expiryTimer = info.getExpiryTimer(),
+ destroyed = false,
+ created = info.getCreated(),
+ description = info.getDescription(),
+ isUserAdmin = groupIsAdmin
+ )
+ }
+ }
+
+ override fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId): Long? {
+ val sentTimestamp = message.sentTimestamp ?: clock.currentTimeMills()
+ val senderPublicKey = message.sender
+ val groupName = configFactory.withGroupConfigs(closedGroup) { it.groupInfo.getName() }
+ ?: configFactory.getGroup(closedGroup)?.name
+
+ val updateData = UpdateMessageData.buildGroupUpdate(message, groupName.orEmpty()) ?: return null
+
+ return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup)
+ }
+
+ override fun insertGroupInfoLeaving(closedGroup: AccountId): Long? {
+ val sentTimestamp = clock.currentTimeMills()
+ val senderPublicKey = getUserPublicKey() ?: return null
+ val updateData = UpdateMessageData.buildGroupLeaveUpdate(UpdateMessageData.Kind.GroupLeaving)
+
+ return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup)
+ }
+
+ override fun updateGroupInfoChange(messageId: Long, newType: UpdateMessageData.Kind) {
+ val mmsDB = mmsDatabase
+ val newMessage = UpdateMessageData.buildGroupLeaveUpdate(newType)
+ mmsDB.updateInfoMessage(messageId, newMessage.toJSON())
+ }
+
+ override fun insertGroupInviteControlMessage(sentTimestamp: Long, senderPublicKey: String, closedGroup: AccountId, groupName: String): Long? {
+ val updateData = UpdateMessageData(UpdateMessageData.Kind.GroupInvitation(closedGroup.hexString, senderPublicKey, groupName))
+ return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup)
+ }
+
+ private fun insertUpdateControlMessage(updateData: UpdateMessageData, sentTimestamp: Long, senderPublicKey: String?, closedGroup: AccountId): Long? {
+ val userPublicKey = getUserPublicKey()!!
+ val recipient = Recipient.from(context, fromSerialized(closedGroup.hexString), false)
+ val threadDb = threadDatabase
+ val threadID = threadDb.getThreadIdIfExistsFor(recipient)
+ val expirationConfig = getExpirationConfiguration(threadID)
+ val expiryMode = expirationConfig?.expiryMode
+ val expiresInMillis = expiryMode?.expiryMillis ?: 0
+ val expireStartedAt = if (expiryMode is ExpiryMode.AfterSend) sentTimestamp else 0
+ val inviteJson = updateData.toJSON()
+
+
+ if (senderPublicKey == null || senderPublicKey == userPublicKey) {
+ val infoMessage = OutgoingGroupMediaMessage(
+ recipient,
+ inviteJson,
+ closedGroup.hexString,
+ null,
+ sentTimestamp,
+ expiresInMillis,
+ expireStartedAt,
+ true,
+ null,
+ listOf(),
+ listOf()
+ )
+ val mmsDB = mmsDatabase
+ val mmsSmsDB = mmsSmsDatabase
+ // check for conflict here, not returning duplicate in case it's different
+ if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) return null
+ val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null, runThreadUpdate = true)
+ mmsDB.markAsSent(infoMessageID, true)
+ return infoMessageID
+ } else {
+ val group = SignalServiceGroup(Hex.fromStringCondensed(closedGroup.hexString), SignalServiceGroup.GroupType.SIGNAL)
+ val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), expiresInMillis, expireStartedAt, true, false)
+ val infoMessage = IncomingGroupMessage(m, inviteJson, true)
+ val smsDB = smsDatabase
+ val insertResult = smsDB.insertMessageInbox(infoMessage, true)
+ return insertResult.orNull()?.messageId
+ }
+ }
+
override fun setServerCapabilities(server: String, capabilities: List) {
- return DatabaseComponent.get(context).lokiAPIDatabase().setServerCapabilities(server, capabilities)
+ return lokiAPIDatabase.setServerCapabilities(server, capabilities)
}
override fun getServerCapabilities(server: String): List {
- return DatabaseComponent.get(context).lokiAPIDatabase().getServerCapabilities(server)
+ return lokiAPIDatabase.getServerCapabilities(server)
}
override fun getAllOpenGroups(): Map {
- return DatabaseComponent.get(context).lokiThreadDatabase().getAllOpenGroups()
+ return lokiThreadDatabase.getAllOpenGroups()
}
override fun updateOpenGroup(openGroup: OpenGroup) {
@@ -1103,7 +1123,7 @@ open class Storage(
}
override fun getAllGroups(includeInactive: Boolean): List {
- return DatabaseComponent.get(context).groupDatabase().getAllGroups(includeInactive)
+ return groupDatabase.getAllGroups(includeInactive)
}
override fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? {
@@ -1112,45 +1132,50 @@ open class Storage(
override fun onOpenGroupAdded(server: String, room: String) {
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)
+ configFactory.withMutableUserConfigs { configs ->
+ val groups = configs.userGroups
+ val volatileConfig = configs.convoInfoVolatile
+ val openGroup = getOpenGroup(room, server) ?: return@withMutableUserConfigs
+ val (infoServer, infoRoom, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return@withMutableUserConfigs
+ 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@withMutableUserConfigs
+ markConversationAsRead(threadId, volatile.lastRead, force = true)
+ }
+ volatileConfig.set(volatile)
}
- volatileConfig.set(volatile)
}
override fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean {
- val jobDb = DatabaseComponent.get(context).sessionJobDatabase()
- return jobDb.hasBackgroundGroupAddJob(groupJoinUrl)
+ return jobDatabase.hasBackgroundGroupAddJob(groupJoinUrl)
}
override fun setProfileSharing(address: Address, value: Boolean) {
val recipient = Recipient.from(context, address, false)
- DatabaseComponent.get(context).recipientDatabase().setProfileSharing(recipient, value)
+ recipientDatabase.setProfileSharing(recipient, value)
}
override fun getOrCreateThreadIdFor(address: Address): Long {
val recipient = Recipient.from(context, address, false)
- return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient)
+ return threadDatabase.getOrCreateThreadIdFor(recipient)
}
override fun getThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?, createThread: Boolean): Long? {
- val database = DatabaseComponent.get(context).threadDatabase()
+ val database = threadDatabase
return if (!openGroupID.isNullOrEmpty()) {
val recipient = Recipient.from(context, fromSerialized(GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())), false)
database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it }
- } else if (!groupPublicKey.isNullOrEmpty()) {
+ } else if (!groupPublicKey.isNullOrEmpty() && !groupPublicKey.startsWith(IdPrefix.GROUP.value)) {
val recipient = Recipient.from(context, fromSerialized(GroupUtil.doubleEncodeGroupID(groupPublicKey)), false)
if (createThread) database.getOrCreateThreadIdFor(recipient)
else database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it }
+ } else if (!groupPublicKey.isNullOrEmpty()) {
+ val recipient = Recipient.from(context, fromSerialized(groupPublicKey), false)
+ if (createThread) database.getOrCreateThreadIdFor(recipient)
+ else database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it }
} else {
val recipient = Recipient.from(context, fromSerialized(publicKey), false)
if (createThread) database.getOrCreateThreadIdFor(recipient)
@@ -1173,12 +1198,12 @@ open class Storage(
}
override fun getThreadId(recipient: Recipient): Long? {
- val threadID = DatabaseComponent.get(context).threadDatabase().getThreadIdIfExistsFor(recipient)
+ val threadID = threadDatabase.getThreadIdIfExistsFor(recipient)
return if (threadID < 0) null else threadID
}
override fun getThreadIdForMms(mmsId: Long): Long {
- val mmsDb = DatabaseComponent.get(context).mmsDatabase()
+ val mmsDb = mmsDatabase
val cursor = mmsDb.getMessage(mmsId)
val reader = mmsDb.readerFor(cursor)
val threadId = reader.next?.threadId
@@ -1187,37 +1212,40 @@ open class Storage(
}
override fun getContactWithAccountID(accountID: String): Contact? {
- return DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(accountID)
+ return sessionContactDatabase.getContactWithAccountID(accountID)
}
override fun getAllContacts(): Set {
- return DatabaseComponent.get(context).sessionContactDatabase().getAllContacts()
+ return sessionContactDatabase.getAllContacts()
}
override fun setContact(contact: Contact) {
- DatabaseComponent.get(context).sessionContactDatabase().setContact(contact)
+ sessionContactDatabase.setContact(contact)
val address = fromSerialized(contact.accountID)
if (!getRecipientApproved(address)) return
- val recipientHash = SSKEnvironment.shared.profileManager.contactUpdatedInternal(contact)
+ val recipientHash = profileManager.contactUpdatedInternal(contact)
val recipient = Recipient.from(context, address, false)
setRecipientHash(recipient, recipientHash)
}
override fun getRecipientForThread(threadId: Long): Recipient? {
- return DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(threadId)
+ return threadDatabase.getRecipientForThreadId(threadId)
}
override fun getRecipientSettings(address: Address): Recipient.RecipientSettings? {
- return DatabaseComponent.get(context).recipientDatabase().getRecipientSettings(address).orNull()
+ return recipientDatabase.getRecipientSettings(address).orNull()
+ }
+
+ override fun hasAutoDownloadFlagBeenSet(recipient: Recipient): Boolean {
+ return recipientDatabase.isAutoDownloadFlagSet(recipient)
}
override fun addLibSessionContacts(contacts: List, timestamp: Long) {
- val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
+ val mappingDb = blindedIdMappingDatabase
val moreContacts = contacts.filter { contact ->
val id = AccountId(contact.id)
id.prefix?.isBlinded() == false || mappingDb.getBlindedIdMapping(contact.id).none { it.accountId != null }
}
- val profileManager = SSKEnvironment.shared.profileManager
moreContacts.forEach { contact ->
val address = fromSerialized(contact.id)
val recipient = Recipient.from(context, address, false)
@@ -1273,9 +1301,9 @@ open class Storage(
}
override fun addContacts(contacts: List) {
- val recipientDatabase = DatabaseComponent.get(context).recipientDatabase()
- val threadDatabase = DatabaseComponent.get(context).threadDatabase()
- val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
+ val recipientDatabase = recipientDatabase
+ val threadDatabase = threadDatabase
+ val mappingDb = blindedIdMappingDatabase
val moreContacts = contacts.filter { contact ->
val id = AccountId(contact.publicKey)
id.prefix != IdPrefix.BLINDED || mappingDb.getBlindedIdMapping(contact.publicKey).none { it.accountId != null }
@@ -1314,83 +1342,107 @@ open class Storage(
}
}
+ override fun shouldAutoDownloadAttachments(recipient: Recipient): Boolean {
+ return recipient.autoDownloadAttachments
+ }
+
+ override fun setAutoDownloadAttachments(
+ recipient: Recipient,
+ shouldAutoDownloadAttachments: Boolean
+ ) {
+ val recipientDb = recipientDatabase
+ recipientDb.setAutoDownloadAttachments(recipient, shouldAutoDownloadAttachments)
+ }
+
override fun setRecipientHash(recipient: Recipient, recipientHash: String?) {
- val recipientDb = DatabaseComponent.get(context).recipientDatabase()
+ val recipientDb = recipientDatabase
recipientDb.setRecipientHash(recipient, recipientHash)
}
override fun getLastUpdated(threadID: Long): Long {
- val threadDB = DatabaseComponent.get(context).threadDatabase()
+ val threadDB = threadDatabase
return threadDB.getLastUpdated(threadID)
}
override fun trimThread(threadID: Long, threadLimit: Int) {
- val threadDB = DatabaseComponent.get(context).threadDatabase()
+ val threadDB = threadDatabase
threadDB.trimThread(threadID, threadLimit)
}
override fun trimThreadBefore(threadID: Long, timestamp: Long) {
- val threadDB = DatabaseComponent.get(context).threadDatabase()
+ val threadDB = threadDatabase
threadDB.trimThreadBefore(threadID, timestamp)
}
override fun getMessageCount(threadID: Long): Long {
- val mmsSmsDb = DatabaseComponent.get(context).mmsSmsDatabase()
+ val mmsSmsDb = mmsSmsDatabase
return mmsSmsDb.getConversationCount(threadID)
}
override fun setPinned(threadID: Long, isPinned: Boolean) {
- val threadDB = DatabaseComponent.get(context).threadDatabase()
+ val threadDB = threadDatabase
threadDB.setPinned(threadID, isPinned)
val threadRecipient = getRecipientForThread(threadID) ?: return
- if (threadRecipient.isLocalNumber) {
- val user = configFactory.user ?: return
- user.setNtsPriority(if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE)
- } else if (threadRecipient.isContactRecipient) {
- val contacts = configFactory.contacts ?: return
- contacts.upsertContact(threadRecipient.address.serialize()) {
- priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE
- }
- } else if (threadRecipient.isGroupRecipient) {
- val groups = configFactory.userGroups ?: return
- if (threadRecipient.isClosedGroupRecipient) {
- threadRecipient.address.serialize()
- .let(GroupUtil::doubleDecodeGroupId)
- .let(groups::getOrConstructLegacyGroupInfo)
- .copy (priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE)
- .let(groups::set)
- } else if (threadRecipient.isCommunityRecipient) {
- val openGroup = getOpenGroup(threadID) ?: return
- val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return
- val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy (
+ configFactory.withMutableUserConfigs { configs ->
+ if (threadRecipient.isLocalNumber) {
+ configs.userProfile.setNtsPriority(if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE)
+ } else if (threadRecipient.isContactRecipient) {
+ configs.contacts.upsertContact(threadRecipient.address.serialize()) {
priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE
- )
- groups.set(newGroupInfo)
+ }
+ } else if (threadRecipient.isGroupOrCommunityRecipient) {
+ when {
+ threadRecipient.isLegacyGroupRecipient -> {
+ threadRecipient.address.serialize()
+ .let(GroupUtil::doubleDecodeGroupId)
+ .let(configs.userGroups::getOrConstructLegacyGroupInfo)
+ .copy(priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE)
+ .let(configs.userGroups::set)
+ }
+
+ threadRecipient.isGroupV2Recipient -> {
+ val newGroupInfo = configs.userGroups
+ .getOrConstructClosedGroup(threadRecipient.address.serialize())
+ .copy(priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE)
+ configs.userGroups.set(newGroupInfo)
+ }
+
+ threadRecipient.isCommunityRecipient -> {
+ val openGroup = getOpenGroup(threadID) ?: return@withMutableUserConfigs
+ val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL)
+ ?: return@withMutableUserConfigs
+ val newGroupInfo = configs.userGroups.getOrConstructCommunityInfo(
+ baseUrl,
+ room,
+ Hex.toStringCondensed(pubKeyHex)
+ ).copy(priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE)
+ configs.userGroups.set(newGroupInfo)
+ }
+ }
}
}
- ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
override fun isPinned(threadID: Long): Boolean {
- val threadDB = DatabaseComponent.get(context).threadDatabase()
+ val threadDB = threadDatabase
return threadDB.isPinned(threadID)
}
override fun setThreadDate(threadId: Long, newDate: Long) {
- val threadDb = DatabaseComponent.get(context).threadDatabase()
+ val threadDb = threadDatabase
threadDb.setDate(threadId, newDate)
}
override fun getLastLegacyRecipient(threadRecipient: String): String? =
- DatabaseComponent.get(context).lokiAPIDatabase().getLastLegacySenderAddress(threadRecipient)
+ lokiAPIDatabase.getLastLegacySenderAddress(threadRecipient)
override fun setLastLegacyRecipient(threadRecipient: String, senderRecipient: String?) {
- DatabaseComponent.get(context).lokiAPIDatabase().setLastLegacySenderAddress(threadRecipient, senderRecipient)
+ lokiAPIDatabase.setLastLegacySenderAddress(threadRecipient, senderRecipient)
}
override fun deleteConversation(threadID: Long) {
- val threadDB = DatabaseComponent.get(context).threadDatabase()
- val groupDB = DatabaseComponent.get(context).groupDatabase()
+ val threadDB = threadDatabase
+ val groupDB = groupDatabase
threadDB.deleteConversation(threadID)
val recipient = getRecipientForThread(threadID)
@@ -1404,18 +1456,40 @@ open class Storage(
if (recipient.isContactRecipient || recipient.isCommunityRecipient) return
// If we get here then this is a closed group conversation (i.e., recipient.isClosedGroupRecipient)
- 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)
- volatile.eraseLegacyClosedGroup(groupPublicKey)
- groups.eraseLegacyGroup(groupPublicKey)
+ configFactory.withMutableUserConfigs { configs ->
+ val volatile = configs.convoInfoVolatile
+ val groups = configs.userGroups
+ val groupID = recipient.address.toGroupString()
+ val closedGroup = getGroup(groupID)
+ val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize())
+ if (closedGroup != null) {
+ groupDB.delete(groupID)
+ volatile.eraseLegacyClosedGroup(groupPublicKey)
+ groups.eraseLegacyGroup(groupPublicKey)
+ } else {
+ Log.w("Loki-DBG", "Failed to find a closed group for ${groupPublicKey.take(4)}")
+ }
+ }
+ }
+
+ override fun clearMessages(threadID: Long, fromUser: Address?): Boolean {
+ val threadDb = threadDatabase
+ if (fromUser == null) {
+ // this deletes all *from* thread, not deleting the actual thread
+ smsDatabase.deleteThread(threadID)
+ mmsDatabase.deleteThread(threadID) // threadDB update called from within
} else {
- Log.w("Loki-DBG", "Failed to find a closed group for ${groupPublicKey.take(4)}")
+ // this deletes all *from* thread, not deleting the actual thread
+ smsDatabase.deleteMessagesFrom(threadID, fromUser.serialize())
+ mmsDatabase.deleteMessagesFrom(threadID, fromUser.serialize())
+ threadDb.update(threadID, false)
}
+ return true
+ }
+
+ override fun clearMedia(threadID: Long, fromUser: Address?): Boolean {
+ mmsDatabase.deleteMediaFor(threadID, fromUser?.serialize())
+ return true
}
override fun getAttachmentDataUri(attachmentId: AttachmentId): Uri {
@@ -1427,7 +1501,7 @@ open class Storage(
}
override fun insertDataExtractionNotificationMessage(senderPublicKey: String, message: DataExtractionNotificationInfoMessage, sentTimestamp: Long) {
- val database = DatabaseComponent.get(context).mmsDatabase()
+ val database = mmsDatabase
val address = fromSerialized(senderPublicKey)
val recipient = Recipient.from(context, address, false)
@@ -1457,8 +1531,7 @@ open class Storage(
)
database.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true)
-
- SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode)
+ messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode)
}
/**
@@ -1476,21 +1549,16 @@ open class Storage(
|| (userPublicKey == recipientPublicKey && userPublicKey == senderPublicKey)
) return
- val recipientDb = DatabaseComponent.get(context).recipientDatabase()
- val threadDB = DatabaseComponent.get(context).threadDatabase()
if (userPublicKey == senderPublicKey) {
val requestRecipient = Recipient.from(context, fromSerialized(recipientPublicKey), false)
- recipientDb.setApproved(requestRecipient, true)
- val threadId = threadDB.getOrCreateThreadIdFor(requestRecipient)
- threadDB.setHasSent(threadId, true)
+ recipientDatabase.setApproved(requestRecipient, true)
+ val threadId = threadDatabase.getOrCreateThreadIdFor(requestRecipient)
+ threadDatabase.setHasSent(threadId, true)
} else {
- val mmsDb = DatabaseComponent.get(context).mmsDatabase()
- val smsDb = DatabaseComponent.get(context).smsDatabase()
val sender = Recipient.from(context, fromSerialized(senderPublicKey), false)
val threadId = getOrCreateThreadIdFor(sender.address)
val profile = response.profile
if (profile != null) {
- val profileManager = SSKEnvironment.shared.profileManager
val name = profile.displayName!!
if (name.isNotEmpty()) {
profileManager.setName(context, sender, name)
@@ -1506,16 +1574,16 @@ open class Storage(
profileManager.setUnidentifiedAccessMode(context, sender, Recipient.UnidentifiedAccessMode.UNKNOWN)
}
}
- threadDB.setHasSent(threadId, true)
- val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
+ threadDatabase.setHasSent(threadId, true)
+ val mappingDb = blindedIdMappingDatabase
val mappings = mutableMapOf()
- threadDB.readerFor(threadDB.conversationList).use { reader ->
+ threadDatabase.readerFor(threadDatabase.conversationList).use { reader ->
while (reader.next != null) {
val recipient = reader.current.recipient
val address = recipient.address.serialize()
val blindedId = when {
- recipient.isGroupRecipient -> null
- recipient.isOpenGroupInboxRecipient -> GroupUtil.getDecodedOpenGroupInboxAccountId(address)
+ recipient.isGroupOrCommunityRecipient -> null
+ recipient.isCommunityInboxRecipient -> GroupUtil.getDecodedOpenGroupInboxAccountId(address)
else -> address.takeIf { AccountId(it).prefix == IdPrefix.BLINDED }
} ?: continue
mappingDb.getBlindedIdMapping(blindedId).firstOrNull()?.let {
@@ -1529,13 +1597,22 @@ open class Storage(
}
mappingDb.addBlindedIdMapping(mapping.value.copy(accountId = senderPublicKey))
- val blindedThreadId = threadDB.getOrCreateThreadIdFor(Recipient.from(context, fromSerialized(mapping.key), false))
- mmsDb.updateThreadId(blindedThreadId, threadId)
- smsDb.updateThreadId(blindedThreadId, threadId)
- threadDB.deleteConversation(blindedThreadId)
+ val blindedThreadId = threadDatabase.getOrCreateThreadIdFor(Recipient.from(context, fromSerialized(mapping.key), false))
+ mmsDatabase.updateThreadId(blindedThreadId, threadId)
+ smsDatabase.updateThreadId(blindedThreadId, threadId)
+ threadDatabase.deleteConversation(blindedThreadId)
}
setRecipientApproved(sender, true)
setRecipientApprovedMe(sender, true)
+
+ // Also update the config about this contact
+ configFactory.withMutableUserConfigs {
+ it.contacts.upsertContact(sender.address.serialize()) {
+ approved = true
+ approvedMe = true
+ }
+ }
+
val message = IncomingMediaMessage(
sender.address,
response.sentTimestamp!!,
@@ -1554,7 +1631,7 @@ open class Storage(
Optional.absent(),
Optional.absent()
)
- mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = true)
+ mmsDatabase.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = true)
}
}
@@ -1564,10 +1641,9 @@ open class Storage(
override fun insertMessageRequestResponseFromYou(threadId: Long){
val userPublicKey = getUserPublicKey() ?: return
- val mmsDb = DatabaseComponent.get(context).mmsDatabase()
val message = IncomingMediaMessage(
fromSerialized(userPublicKey),
- SnodeAPI.nowWithOffset,
+ clock.currentTimeMills(),
-1,
0,
0,
@@ -1583,49 +1659,53 @@ open class Storage(
Optional.absent(),
Optional.absent()
)
- mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = false)
+ mmsDatabase.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = false)
}
override fun getRecipientApproved(address: Address): Boolean {
- return DatabaseComponent.get(context).recipientDatabase().getApproved(address)
+ return address.isGroupV2 || recipientDatabase.getApproved(address)
}
override fun setRecipientApproved(recipient: Recipient, approved: Boolean) {
- DatabaseComponent.get(context).recipientDatabase().setApproved(recipient, approved)
+ recipientDatabase.setApproved(recipient, approved)
if (recipient.isLocalNumber || !recipient.isContactRecipient) return
- configFactory.contacts?.upsertContact(recipient.address.serialize()) {
- // if the contact wasn't approved before but is approved now, make sure it's visible
- if(approved && !this.approved) this.priority = PRIORITY_VISIBLE
+ configFactory.withMutableUserConfigs {
+ it.contacts.upsertContact(recipient.address.serialize()) {
+ // if the contact wasn't approved before but is approved now, make sure it's visible
+ if(approved && !this.approved) this.priority = PRIORITY_VISIBLE
- // update approval
- this.approved = approved
+ // update approval
+ this.approved = approved
+ }
}
}
override fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean) {
- DatabaseComponent.get(context).recipientDatabase().setApprovedMe(recipient, approvedMe)
+ recipientDatabase.setApprovedMe(recipient, approvedMe)
if (recipient.isLocalNumber || !recipient.isContactRecipient) return
- configFactory.contacts?.upsertContact(recipient.address.serialize()) {
- this.approvedMe = approvedMe
+ configFactory.withMutableUserConfigs {
+ it.contacts.upsertContact(recipient.address.serialize()) {
+ this.approvedMe = approvedMe
+ }
}
}
override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) {
- val database = DatabaseComponent.get(context).smsDatabase()
+ val database = smsDatabase
val address = fromSerialized(senderPublicKey)
val recipient = Recipient.from(context, address, false)
- val threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient)
+ val threadId = threadDatabase.getOrCreateThreadIdFor(recipient)
val expirationConfig = getExpirationConfiguration(threadId)
val expiryMode = expirationConfig?.expiryMode?.coerceSendToRead() ?: ExpiryMode.NONE
val expiresInMillis = expiryMode.expiryMillis
val expireStartedAt = if (expiryMode is ExpiryMode.AfterSend) sentTimestamp else 0
val callMessage = IncomingTextMessage.fromCallInfo(callMessageType, address, Optional.absent(), sentTimestamp, expiresInMillis, expireStartedAt)
database.insertCallMessage(callMessage)
- SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode)
+ messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode)
}
override fun conversationHasOutgoing(userPublicKey: String): Boolean {
- val database = DatabaseComponent.get(context).threadDatabase()
+ val database = threadDatabase
val threadId = database.getThreadIdIfExistsFor(userPublicKey)
if (threadId == -1L) return false
@@ -1634,27 +1714,27 @@ open class Storage(
}
override fun getLastInboxMessageId(server: String): Long? {
- return DatabaseComponent.get(context).lokiAPIDatabase().getLastInboxMessageId(server)
+ return lokiAPIDatabase.getLastInboxMessageId(server)
}
override fun setLastInboxMessageId(server: String, messageId: Long) {
- DatabaseComponent.get(context).lokiAPIDatabase().setLastInboxMessageId(server, messageId)
+ lokiAPIDatabase.setLastInboxMessageId(server, messageId)
}
override fun removeLastInboxMessageId(server: String) {
- DatabaseComponent.get(context).lokiAPIDatabase().removeLastInboxMessageId(server)
+ lokiAPIDatabase.removeLastInboxMessageId(server)
}
override fun getLastOutboxMessageId(server: String): Long? {
- return DatabaseComponent.get(context).lokiAPIDatabase().getLastOutboxMessageId(server)
+ return lokiAPIDatabase.getLastOutboxMessageId(server)
}
override fun setLastOutboxMessageId(server: String, messageId: Long) {
- DatabaseComponent.get(context).lokiAPIDatabase().setLastOutboxMessageId(server, messageId)
+ lokiAPIDatabase.setLastOutboxMessageId(server, messageId)
}
override fun removeLastOutboxMessageId(server: String) {
- DatabaseComponent.get(context).lokiAPIDatabase().removeLastOutboxMessageId(server)
+ lokiAPIDatabase.removeLastOutboxMessageId(server)
}
override fun getOrCreateBlindedIdMapping(
@@ -1663,7 +1743,7 @@ open class Storage(
serverPublicKey: String,
fromOutbox: Boolean
): BlindedIdMapping {
- val db = DatabaseComponent.get(context).blindedIdMappingDatabase()
+ val db = blindedIdMappingDatabase
val mapping = db.getBlindedIdMapping(blindedId).firstOrNull() ?: BlindedIdMapping(blindedId, null, server, serverPublicKey)
if (mapping.accountId != null) {
return mapping
@@ -1694,19 +1774,16 @@ open class Storage(
val messageId = if (localId != null && localId > 0 && isMms != null) {
// bail early is the message is marked as deleted
- val messagingDatabase: MessagingDatabase = if (isMms == true) DatabaseComponent.get(context).mmsDatabase()
- else DatabaseComponent.get(context).smsDatabase()
+ val messagingDatabase: MessagingDatabase = if (isMms == true) mmsDatabase else smsDatabase
if(messagingDatabase.getMessageRecord(localId)?.isDeleted == true) return
MessageId(localId, isMms)
} else if (timestamp != null && timestamp > 0) {
- val messageRecord = DatabaseComponent.get(context).mmsSmsDatabase().getMessageForTimestamp(timestamp) ?: return
+ val messageRecord = mmsSmsDatabase.getMessageForTimestamp(timestamp) ?: return
if (messageRecord.isDeleted) return
-
MessageId(messageRecord.id, messageRecord.isMms)
} else return
-
- DatabaseComponent.get(context).reactionDatabase().addReaction(
+ reactionDatabase.addReaction(
messageId,
ReactionRecord(
messageId = messageId.id,
@@ -1724,13 +1801,13 @@ open class Storage(
}
override fun removeReaction(emoji: String, messageTimestamp: Long, author: String, notifyUnread: Boolean) {
- val messageRecord = DatabaseComponent.get(context).mmsSmsDatabase().getMessageForTimestamp(messageTimestamp) ?: return
+ val messageRecord = mmsSmsDatabase.getMessageForTimestamp(messageTimestamp) ?: return
val messageId = MessageId(messageRecord.id, messageRecord.isMms)
- DatabaseComponent.get(context).reactionDatabase().deleteReaction(emoji, messageId, author, notifyUnread)
+ reactionDatabase.deleteReaction(emoji, messageId, author, notifyUnread)
}
override fun updateReactionIfNeeded(message: Message, sender: String, openGroupSentTimestamp: Long) {
- val database = DatabaseComponent.get(context).reactionDatabase()
+ val database = reactionDatabase
var reaction = database.getReactionFor(message.sentTimestamp!!, sender) ?: return
if (openGroupSentTimestamp != -1L) {
addReceivedMessageTimestamp(openGroupSentTimestamp)
@@ -1746,81 +1823,100 @@ open class Storage(
}
override fun deleteReactions(messageId: Long, mms: Boolean) {
- DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(MessageId(messageId, mms))
+ reactionDatabase.deleteMessageReactions(MessageId(messageId, mms))
}
override fun deleteReactions(messageIds: List, mms: Boolean) {
- DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(
+ reactionDatabase.deleteMessageReactions(
messageIds.map { MessageId(it, mms) }
)
}
override fun setBlocked(recipients: Iterable, isBlocked: Boolean, fromConfigUpdate: Boolean) {
- val recipientDb = DatabaseComponent.get(context).recipientDatabase()
+ val recipientDb = recipientDatabase
recipientDb.setBlocked(recipients, isBlocked)
- recipients.filter { it.isContactRecipient && !it.isLocalNumber }.forEach { recipient ->
- configFactory.contacts?.upsertContact(recipient.address.serialize()) {
- this.blocked = isBlocked
+ configFactory.withMutableUserConfigs { configs ->
+ recipients.filter { it.isContactRecipient && !it.isLocalNumber }.forEach { recipient ->
+ configs.contacts.upsertContact(recipient.address.serialize()) {
+ this.blocked = isBlocked
+ }
}
}
- val contactsConfig = configFactory.contacts ?: return
- if (contactsConfig.needsPush() && !fromConfigUpdate) {
- ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
- }
}
override fun blockedContacts(): List {
- val recipientDb = DatabaseComponent.get(context).recipientDatabase()
+ val recipientDb = recipientDatabase
return recipientDb.blockedContacts
}
override fun getExpirationConfiguration(threadId: Long): ExpirationConfiguration? {
val recipient = getRecipientForThread(threadId) ?: return null
- val dbExpirationMetadata = DatabaseComponent.get(context).expirationConfigurationDatabase().getExpirationConfiguration(threadId) ?: return null
+ val dbExpirationMetadata = expirationConfigurationDatabase.getExpirationConfiguration(threadId)
return when {
- recipient.isLocalNumber -> configFactory.user?.getNtsExpiry()
+ recipient.isLocalNumber -> configFactory.withUserConfigs { it.userProfile.getNtsExpiry() }
recipient.isContactRecipient -> {
// read it from contacts config if exists
recipient.address.serialize().takeIf { it.startsWith(IdPrefix.STANDARD.value) }
- ?.let { configFactory.contacts?.get(it)?.expiryMode }
+ ?.let { configFactory.withUserConfigs { configs -> configs.contacts.get(it)?.expiryMode } }
+ }
+ recipient.isGroupV2Recipient -> {
+ configFactory.withGroupConfigs(AccountId(recipient.address.serialize())) { configs ->
+ configs.groupInfo.getExpiryTimer()
+ }.let {
+ if (it == 0L) ExpiryMode.NONE else ExpiryMode.AfterSend(it)
+ }
}
- recipient.isClosedGroupRecipient -> {
+ recipient.isLegacyGroupRecipient -> {
// read it from group config if exists
GroupUtil.doubleDecodeGroupId(recipient.address.serialize())
- .let { configFactory.userGroups?.getLegacyGroupInfo(it) }
+ .let { id -> configFactory.withUserConfigs { it.userGroups.getLegacyGroupInfo(id) } }
?.run { disappearingTimer.takeIf { it != 0L }?.let(ExpiryMode::AfterSend) ?: ExpiryMode.NONE }
}
else -> null
- }?.let { ExpirationConfiguration(threadId, it, dbExpirationMetadata.updatedTimestampMs) }
+ }?.let { ExpirationConfiguration(
+ threadId,
+ it,
+ // This will be 0L for new closed groups, apparently we don't need this anymore?
+ dbExpirationMetadata?.updatedTimestampMs ?: 0L
+ ) }
}
override fun setExpirationConfiguration(config: ExpirationConfiguration) {
val recipient = getRecipientForThread(config.threadId) ?: return
- val expirationDb = DatabaseComponent.get(context).expirationConfigurationDatabase()
+ val expirationDb = expirationConfigurationDatabase
val currentConfig = expirationDb.getExpirationConfiguration(config.threadId)
if (currentConfig != null && currentConfig.updatedTimestampMs >= config.updatedTimestampMs) return
val expiryMode = config.expiryMode
if (expiryMode == ExpiryMode.NONE) {
// Clear the legacy recipients on updating config to be none
- DatabaseComponent.get(context).lokiAPIDatabase().setLastLegacySenderAddress(recipient.address.serialize(), null)
+ lokiAPIDatabase.setLastLegacySenderAddress(recipient.address.serialize(), null)
}
- if (recipient.isClosedGroupRecipient) {
- val userGroups = configFactory.userGroups ?: return
+ if (recipient.isLegacyGroupRecipient) {
val groupPublicKey = GroupUtil.addressToGroupAccountId(recipient.address)
- val groupInfo = userGroups.getLegacyGroupInfo(groupPublicKey)
- ?.copy(disappearingTimer = expiryMode.expirySeconds) ?: return
- userGroups.set(groupInfo)
+
+ configFactory.withMutableUserConfigs {
+ val groupInfo = it.userGroups.getLegacyGroupInfo(groupPublicKey)
+ ?.copy(disappearingTimer = expiryMode.expirySeconds) ?: return@withMutableUserConfigs
+ it.userGroups.set(groupInfo)
+ }
+ } else if (recipient.isGroupV2Recipient) {
+ val groupSessionId = AccountId(recipient.address.serialize())
+ configFactory.withMutableGroupConfigs(groupSessionId) { configs ->
+ configs.groupInfo.setExpiryTimer(expiryMode.expirySeconds)
+ }
+
} else if (recipient.isLocalNumber) {
- val user = configFactory.user ?: return
- user.setNtsExpiry(expiryMode)
+ configFactory.withMutableUserConfigs {
+ it.userProfile.setNtsExpiry(expiryMode)
+ }
} else if (recipient.isContactRecipient) {
- val contacts = configFactory.contacts ?: return
-
- val contact = contacts.get(recipient.address.serialize())?.copy(expiryMode = expiryMode) ?: return
- contacts.set(contact)
+ configFactory.withMutableUserConfigs {
+ val contact = it.contacts.get(recipient.address.serialize())?.copy(expiryMode = expiryMode) ?: return@withMutableUserConfigs
+ it.contacts.set(contact)
+ }
}
expirationDb.setExpirationConfiguration(
config.run { copy(expiryMode = expiryMode) }
@@ -1829,7 +1925,7 @@ open class Storage(
override fun getExpiringMessages(messageIds: List): List> {
val expiringMessages = mutableListOf>()
- val smsDb = DatabaseComponent.get(context).smsDatabase()
+ val smsDb = smsDatabase
smsDb.readerFor(smsDb.expirationNotStartedMessages).use { reader ->
while (reader.next != null) {
if (messageIds.isEmpty() || reader.current.id in messageIds) {
@@ -1837,7 +1933,7 @@ open class Storage(
}
}
}
- val mmsDb = DatabaseComponent.get(context).mmsDatabase()
+ val mmsDb = mmsDatabase
mmsDb.expireNotStartedMessages.use { reader ->
while (reader.next != null) {
if (messageIds.isEmpty() || reader.current.id in messageIds) {
@@ -1853,11 +1949,11 @@ open class Storage(
threadID: Long,
disappearingState: Recipient.DisappearingState
) {
- val threadDb = DatabaseComponent.get(context).threadDatabase()
- val lokiDb = DatabaseComponent.get(context).lokiAPIDatabase()
+ val threadDb = threadDatabase
+ val lokiDb = lokiAPIDatabase
val recipient = threadDb.getRecipientForThreadId(threadID) ?: return
val recipientAddress = recipient.address.serialize()
- DatabaseComponent.get(context).recipientDatabase()
+ recipientDatabase
.setDisappearingState(recipient, disappearingState);
val currentLegacyRecipient = lokiDb.getLastLegacySenderAddress(recipientAddress)
val currentExpiry = getExpirationConfiguration(threadID)
@@ -1870,11 +1966,3 @@ open class Storage(
}
}
}
-
-/**
- * Truncate a string to a specified number of bytes
- *
- * This could split multi-byte characters/emojis.
- */
-private fun String.truncate(sizeInBytes: Int): String =
- toByteArray().takeIf { it.size > sizeInBytes }?.take(sizeInBytes)?.toByteArray()?.let(::String) ?: this
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java
index 5cc8bd06f76..f9b204a8368 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java
@@ -17,7 +17,7 @@
*/
package org.thoughtcrime.securesms.database;
-import static org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX;
+import static org.session.libsession.utilities.GroupUtil.LEGACY_CLOSED_GROUP_PREFIX;
import static org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX;
import static org.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID;
@@ -124,10 +124,15 @@ public interface ConversationThreadUpdateListener {
.map(columnName -> TABLE_NAME + "." + columnName)
.toList();
- private static final List COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION = Stream.concat(Stream.concat(Stream.of(TYPED_THREAD_PROJECTION),
- Stream.of(RecipientDatabase.TYPED_RECIPIENT_PROJECTION)),
- Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION))
- .toList();
+ private static final List COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION =
+ // wew
+ Stream.concat(Stream.concat(Stream.concat(
+ Stream.of(TYPED_THREAD_PROJECTION),
+ Stream.of(RecipientDatabase.TYPED_RECIPIENT_PROJECTION)),
+ Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION)),
+ Stream.of(LokiMessageDatabase.groupInviteTable+"."+LokiMessageDatabase.invitingSessionId)
+ )
+ .toList();
public static String getCreatePinnedCommand() {
return "ALTER TABLE "+ TABLE_NAME + " " +
@@ -289,7 +294,7 @@ public void trimThread(long threadId, int length) {
Log.i("ThreadDatabase", "Cut off tweet date: " + lastTweetDate);
DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate);
- DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate);
+ DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate, false);
update(threadId, false);
notifyConversationListeners(threadId);
@@ -303,7 +308,7 @@ public void trimThread(long threadId, int length) {
public void trimThreadBefore(long threadId, long timestamp) {
Log.i("ThreadDatabase", "Trimming thread: " + threadId + " before :"+timestamp);
DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp);
- DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp);
+ DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp, false);
update(threadId, false);
notifyConversationListeners(threadId);
}
@@ -438,32 +443,6 @@ public Cursor getRecentConversationList(int limit) {
return db.rawQuery(query, null);
}
- public int getUnapprovedConversationCount() {
- SQLiteDatabase db = databaseHelper.getReadableDatabase();
- Cursor cursor = null;
-
- try {
- String query = "SELECT COUNT (*) FROM " + TABLE_NAME +
- " LEFT OUTER JOIN " + RecipientDatabase.TABLE_NAME +
- " ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS +
- " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
- " ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID +
- " WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " +
- RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " +
- RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
- GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL";
- cursor = db.rawQuery(query, null);
-
- if (cursor != null && cursor.moveToFirst())
- return cursor.getInt(0);
- } finally {
- if (cursor != null)
- cursor.close();
- }
-
- return 0;
- }
-
public long getLatestUnapprovedConversationTimestamp() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = null;
@@ -502,13 +481,15 @@ public Cursor getBlindedConversationList() {
}
public Cursor getApprovedConversationList() {
- 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 '" + COMMUNITY_PREFIX + "%') " +
+ String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+ LEGACY_CLOSED_GROUP_PREFIX +"%') " +
+ "OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + COMMUNITY_PREFIX + "%') " +
"AND " + ARCHIVED + " = 0 ";
return getConversationList(where);
}
public Cursor getUnapprovedConversationList() {
- String where = MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " +
+ String where = "("+MESSAGE_COUNT + " != 0 OR "+ThreadDatabase.TABLE_NAME+"."+ThreadDatabase.ADDRESS+" LIKE '"+IdPrefix.GROUP.getValue()+"%')" +
+ " AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL";
@@ -684,7 +665,7 @@ public long getOrCreateThreadIdFor(Recipient recipient, int distributionType) {
threadId = cursor.getLong(cursor.getColumnIndexOrThrow(ID));
} else {
DatabaseComponent.get(context).recipientDatabase().setProfileSharing(recipient, true);
- threadId = createThreadForRecipient(recipient.getAddress(), recipient.isGroupRecipient(), distributionType);
+ threadId = createThreadForRecipient(recipient.getAddress(), recipient.isGroupOrCommunityRecipient(), distributionType);
created = true;
}
if (created && updateListener != null) {
@@ -795,7 +776,7 @@ public boolean markAllAsRead(long threadId, boolean isGroupRecipient, long lastS
if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && !force) return false;
List messages = setRead(threadId, lastSeenTime);
MarkReadReceiver.process(context, messages);
- ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, threadId);
+ ApplicationContext.getInstance(context).getMessageNotifier().updateNotification(context, threadId);
return setLastSeen(threadId, lastSeenTime);
}
@@ -834,12 +815,14 @@ public boolean markAllAsRead(long threadId, boolean isGroupRecipient, long lastS
String projection = Util.join(COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION, ",");
String query =
"SELECT " + projection + " FROM " + TABLE_NAME +
- " LEFT OUTER JOIN " + RecipientDatabase.TABLE_NAME +
- " ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS +
- " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
- " ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID +
- " WHERE " + where +
- " ORDER BY " + TABLE_NAME + "." + IS_PINNED + " DESC, " + TABLE_NAME + "." + THREAD_CREATION_DATE + " DESC";
+ " LEFT OUTER JOIN " + RecipientDatabase.TABLE_NAME +
+ " ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS +
+ " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
+ " ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID +
+ " LEFT OUTER JOIN " + LokiMessageDatabase.groupInviteTable +
+ " ON "+ TABLE_NAME + "." + ID + " = " + LokiMessageDatabase.groupInviteTable+"."+LokiMessageDatabase.invitingSessionId +
+ " WHERE " + where +
+ " ORDER BY " + TABLE_NAME + "." + IS_PINNED + " DESC, " + TABLE_NAME + "." + THREAD_CREATION_DATE + " DESC";
if (limit > 0) {
query += " LIMIT " + limit;
@@ -917,6 +900,7 @@ public ThreadRecord getCurrent() {
long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN));
Uri snippetUri = getSnippetUri(cursor);
boolean pinned = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.IS_PINNED)) != 0;
+ String invitingAdmin = cursor.getString(cursor.getColumnIndexOrThrow(LokiMessageDatabase.invitingSessionId));
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
readReceiptCount = 0;
@@ -934,7 +918,7 @@ public ThreadRecord getCurrent() {
return new ThreadRecord(body, snippetUri, lastMessage, recipient, date, count,
unreadCount, unreadMentionCount, threadId, deliveryReceiptCount, status, type,
- distributionType, archived, expiresIn, lastSeen, readReceiptCount, pinned);
+ distributionType, archived, expiresIn, lastSeen, readReceiptCount, pinned, invitingAdmin);
}
private @Nullable Uri getSnippetUri(Cursor cursor) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
index b6ebd6db84e..0a08ccd3571 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
@@ -90,9 +90,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int lokiV44 = 65;
private static final int lokiV45 = 66;
private static final int lokiV46 = 67;
+ private static final int lokiV47 = 68;
+ private static final int lokiV48 = 69;
// Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
- private static final int DATABASE_VERSION = lokiV46;
+ private static final int DATABASE_VERSION = lokiV48;
private static final int MIN_DATABASE_VERSION = lokiV7;
private static final String CIPHER3_DATABASE_NAME = "signal.db";
public static final String DATABASE_NAME = "signal_v4.db";
@@ -362,6 +364,11 @@ public void onCreate(SQLiteDatabase db) {
db.execSQL(RecipientDatabase.getAddWrapperHash());
db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests());
db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE);
+
+ db.execSQL(RecipientDatabase.getCreateAutoDownloadCommand());
+ db.execSQL(RecipientDatabase.getUpdateAutoDownloadValuesCommand());
+ db.execSQL(LokiMessageDatabase.getCreateGroupInviteTableCommand());
+ db.execSQL(LokiMessageDatabase.getCreateThreadDeleteTrigger());
}
@Override
@@ -628,6 +635,16 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE);
}
+ if (oldVersion < lokiV47) {
+ db.execSQL(RecipientDatabase.getCreateAutoDownloadCommand());
+ db.execSQL(RecipientDatabase.getUpdateAutoDownloadValuesCommand());
+ }
+
+ if (oldVersion < lokiV48) {
+ db.execSQL(LokiMessageDatabase.getCreateGroupInviteTableCommand());
+ db.execSQL(LokiMessageDatabase.getCreateThreadDeleteTrigger());
+ }
+
db.setTransactionSuccessful();
} finally {
db.endTransaction();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java
index 5f6257ee924..062b4f1e314 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java
@@ -24,6 +24,7 @@
import androidx.annotation.NonNull;
+import org.session.libsession.messaging.MessagingModuleConfiguration;
import org.session.libsession.messaging.calls.CallMessageType;
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage;
import org.session.libsession.messaging.utilities.UpdateMessageBuilder;
@@ -118,10 +119,19 @@ public boolean isUpdate() {
public CharSequence getDisplayBody(@NonNull Context context) {
if (isGroupUpdateMessage()) {
UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(getBody());
- return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing()));
+ if (updateMessageData == null) {
+ return "";
+ }
+
+ return new SpannableString(UpdateMessageBuilder.buildGroupUpdateMessage(
+ context,
+ updateMessageData,
+ MessagingModuleConfiguration.getShared().getConfigFactory(),
+ isOutgoing())
+ );
} else if (isExpirationTimerUpdate()) {
int seconds = (int) (getExpiresIn() / 1000);
- boolean isGroup = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(getThreadId()).isGroupRecipient();
+ boolean isGroup = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(getThreadId()).isGroupOrCommunityRecipient();
return new SpannableString(UpdateMessageBuilder.INSTANCE.buildExpirationTimerMessage(context, seconds, isGroup, getIndividualRecipient().getAddress().serialize(), isOutgoing(), getTimestamp(), expireStarted));
} else if (isDataExtractionNotification()) {
if (isScreenshotNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.SCREENSHOT, getIndividualRecipient().getAddress().serialize())));
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java
index 019eea64eae..00641c9a8db 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java
@@ -32,6 +32,9 @@
import android.text.style.StyleSpan;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+
+import org.session.libsession.messaging.utilities.UpdateMessageBuilder;
+import org.session.libsession.messaging.utilities.UpdateMessageData;
import com.squareup.phrase.Phrase;
import org.session.libsession.utilities.ExpirationUtil;
import org.session.libsession.utilities.TextSecurePreferences;
@@ -52,39 +55,41 @@
*/
public class ThreadRecord extends DisplayRecord {
- private @Nullable final Uri snippetUri;
- public @Nullable final MessageRecord lastMessage;
- private final long count;
- private final int unreadCount;
- private final int unreadMentionCount;
- private final int distributionType;
- private final boolean archived;
- private final long expiresIn;
- private final long lastSeen;
- private final boolean pinned;
- private final int initialRecipientHash;
- private final long dateSent;
-
- public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri,
- @Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount,
- int unreadMentionCount, long threadId, int deliveryReceiptCount, int status,
- long snippetType, int distributionType, boolean archived, long expiresIn,
- long lastSeen, int readReceiptCount, boolean pinned)
- {
- super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount);
- this.snippetUri = snippetUri;
- this.lastMessage = lastMessage;
- this.count = count;
- this.unreadCount = unreadCount;
- this.unreadMentionCount = unreadMentionCount;
- this.distributionType = distributionType;
- this.archived = archived;
- this.expiresIn = expiresIn;
- this.lastSeen = lastSeen;
- this.pinned = pinned;
- this.initialRecipientHash = recipient.hashCode();
- this.dateSent = date;
- }
+ private @Nullable final Uri snippetUri;
+ public @Nullable final MessageRecord lastMessage;
+ private final long count;
+ private final int unreadCount;
+ private final int unreadMentionCount;
+ private final int distributionType;
+ private final boolean archived;
+ private final long expiresIn;
+ private final long lastSeen;
+ private final boolean pinned;
+ private final int initialRecipientHash;
+ private final String invitingAdminId;
+ private final long dateSent;
+
+ public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri,
+ @Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount,
+ int unreadMentionCount, long threadId, int deliveryReceiptCount, int status,
+ long snippetType, int distributionType, boolean archived, long expiresIn,
+ long lastSeen, int readReceiptCount, boolean pinned, String invitingAdminId)
+ {
+ super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount);
+ this.snippetUri = snippetUri;
+ this.lastMessage = lastMessage;
+ this.count = count;
+ this.unreadCount = unreadCount;
+ this.unreadMentionCount = unreadMentionCount;
+ this.distributionType = distributionType;
+ this.archived = archived;
+ this.expiresIn = expiresIn;
+ this.lastSeen = lastSeen;
+ this.pinned = pinned;
+ this.initialRecipientHash = recipient.hashCode();
+ this.invitingAdminId = invitingAdminId;
+ this.dateSent = date;
+ }
public @Nullable Uri getSnippetUri() {
return snippetUri;
@@ -107,7 +112,7 @@ public CharSequence getDisplayBody(@NonNull Context context) {
return "";
}
else if (isGroupUpdateMessage()) {
- return context.getString(R.string.groupUpdated);
+ return lastMessage.getDisplayBody(context).toString();
} else if (isOpenGroupInvitation()) {
return context.getString(R.string.communityInvitation);
} else if (MmsSmsColumns.Types.isLegacyType(type)) {
@@ -225,4 +230,30 @@ else if(lastMessage != null){
public boolean isPinned() { return pinned; }
public int getInitialRecipientHash() { return initialRecipientHash; }
+
+ public boolean isLeavingGroup() {
+ if (isGroupUpdateMessage()) {
+ String body = getBody();
+ if (!body.isEmpty()) {
+ UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(body);
+ return updateMessageData.isGroupLeavingKind();
+ }
+ }
+ return false;
+ }
+
+ public boolean isErrorLeavingGroup() {
+ if (isGroupUpdateMessage()) {
+ String body = getBody();
+ if (!body.isEmpty()) {
+ UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(body);
+ return updateMessageData.isGroupErrorQuitKind();
+ }
+ }
+ return false;
+ }
+
+ public String getInvitingAdminId() {
+ return invitingAdminId;
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt
index 750b3e20c7e..781676788e2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt
@@ -1,25 +1,17 @@
package org.thoughtcrime.securesms.debugmenu
import android.app.Application
-import android.widget.Toast
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import network.loki.messenger.R
-import org.session.libsession.messaging.open_groups.OpenGroupApi
-import org.session.libsession.snode.SnodeAPI
+import org.session.libsession.utilities.Environment
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.ApplicationContext
-import org.session.libsession.utilities.Environment
-import org.thoughtcrime.securesms.dependencies.DatabaseComponent
-import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import javax.inject.Inject
@HiltViewModel
@@ -75,11 +67,6 @@ class DebugMenuViewModel @Inject constructor(
// clear remote and local data, then restart the app
viewModelScope.launch {
- try {
- ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(application).get()
- } catch (e: Exception) {
- // we can ignore fails here as we might be switching environments before the user gets a public key
- }
ApplicationContext.getInstance(application).clearAllData().let { success ->
if(success){
// save the environment
diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt
index a9a72e76657..a06cd9404fc 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt
@@ -1,24 +1,70 @@
package org.thoughtcrime.securesms.dependencies
+import android.content.Context
+import android.widget.Toast
import dagger.Binds
import dagger.Module
+import dagger.Provides
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
+import org.session.libsession.messaging.groups.GroupManagerV2
+import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier
import org.session.libsession.utilities.AppTextSecurePreferences
+import org.session.libsession.utilities.ConfigFactoryProtocol
+import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.TextSecurePreferences
+import org.session.libsession.utilities.Toaster
+import org.thoughtcrime.securesms.groups.GroupManagerV2Impl
+import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier
+import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier
import org.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.repository.DefaultConversationRepository
+import org.thoughtcrime.securesms.sskenvironment.ProfileManager
+import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
-abstract class AppModule {
+class AppModule {
+
+ @Provides
+ @Singleton
+ fun provideMessageNotifier(): MessageNotifier {
+ return OptimizedMessageNotifier(DefaultMessageNotifier())
+ }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class AppBindings {
@Binds
abstract fun bindTextSecurePreferences(preferences: AppTextSecurePreferences): TextSecurePreferences
@Binds
abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository
+
+ @Binds
+ abstract fun bindGroupManager(groupManager: GroupManagerV2Impl): GroupManagerV2
+
+ @Binds
+ abstract fun bindProfileManager(profileManager: ProfileManager): SSKEnvironment.ProfileManagerProtocol
+
+ @Binds
+ abstract fun bindConfigFactory(configFactory: ConfigFactory): ConfigFactoryProtocol
+
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class ToasterModule {
+ @Provides
+ @Singleton
+ fun provideToaster(@ApplicationContext context: Context) = Toaster { stringRes, toastLength, parameters ->
+ val string = context.getString(stringRes, parameters)
+ Toast.makeText(context, string, toastLength).show()
+ }
}
@EntryPoint
diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallModule.kt
index da15c2f6b45..8e0c7357708 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallModule.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/CallModule.kt
@@ -1,16 +1,12 @@
package org.thoughtcrime.securesms.dependencies
import android.content.Context
-import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
-import dagger.hilt.android.components.ServiceComponent
import dagger.hilt.android.qualifiers.ApplicationContext
-import dagger.hilt.android.scopes.ServiceScoped
import dagger.hilt.components.SingletonComponent
-import org.session.libsession.database.CallDataProvider
-import org.thoughtcrime.securesms.database.Storage
+import org.session.libsession.database.StorageProtocol
import org.thoughtcrime.securesms.webrtc.CallManager
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat
import javax.inject.Singleton
@@ -25,7 +21,7 @@ object CallModule {
@Provides
@Singleton
- fun provideCallManager(@ApplicationContext context: Context, audioManagerCompat: AudioManagerCompat, storage: Storage) =
+ fun provideCallManager(@ApplicationContext context: Context, audioManagerCompat: AudioManagerCompat, storage: StorageProtocol) =
CallManager(context, audioManagerCompat, storage)
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt
index 12b7960595b..f0191615e24 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt
@@ -1,211 +1,374 @@
package org.thoughtcrime.securesms.dependencies
import android.content.Context
-import android.os.Trace
+import dagger.Lazy
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
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.GroupInfoConfig
+import network.loki.messenger.libsession_util.GroupKeysConfig
+import network.loki.messenger.libsession_util.GroupMembersConfig
+import network.loki.messenger.libsession_util.MutableContacts
+import network.loki.messenger.libsession_util.MutableConversationVolatileConfig
+import network.loki.messenger.libsession_util.MutableUserGroupsConfig
+import network.loki.messenger.libsession_util.MutableUserProfile
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.ConfigPush
+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.Sodium
+import network.loki.messenger.libsession_util.util.UserPic
+import org.session.libsession.database.StorageProtocol
+import org.session.libsession.messaging.messages.control.ConfigurationMessage
+import org.session.libsession.snode.OwnedSwarmAuth
+import org.session.libsession.snode.SnodeClock
+import org.session.libsession.snode.SwarmAuth
+import org.session.libsession.utilities.Address
import org.session.libsession.utilities.ConfigFactoryProtocol
-import org.session.libsession.utilities.ConfigFactoryUpdateListener
+import org.session.libsession.utilities.ConfigMessage
+import org.session.libsession.utilities.ConfigPushResult
+import org.session.libsession.utilities.ConfigUpdateNotification
+import org.session.libsession.utilities.GroupConfigs
+import org.session.libsession.utilities.GroupUtil
+import org.session.libsession.utilities.MutableGroupConfigs
+import org.session.libsession.utilities.MutableUserConfigs
import org.session.libsession.utilities.TextSecurePreferences
-import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage
+import org.session.libsession.utilities.UserConfigType
+import org.session.libsession.utilities.UserConfigs
+import org.session.libsession.utilities.getGroup
+import org.session.libsignal.crypto.ecc.DjbECPublicKey
+import org.session.libsignal.utilities.AccountId
+import org.session.libsignal.utilities.Hex
+import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
+import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.database.ConfigDatabase
-import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
+import org.thoughtcrime.securesms.database.ConfigVariant
+import org.thoughtcrime.securesms.database.LokiThreadDatabase
+import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.groups.GroupManager
-import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
+import java.util.concurrent.locks.ReentrantReadWriteLock
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.concurrent.read
+import kotlin.concurrent.write
-class ConfigFactory(
- private val context: Context,
+
+@Singleton
+class ConfigFactory @Inject constructor(
+ @ApplicationContext private val context: Context,
private val configDatabase: ConfigDatabase,
- private val maybeGetUserInfo: () -> Pair?
-) :
- ConfigFactoryProtocol {
+ private val threadDb: ThreadDatabase,
+ private val lokiThreadDatabase: LokiThreadDatabase,
+ private val storage: Lazy,
+ private val textSecurePreferences: TextSecurePreferences,
+ private val clock: SnodeClock,
+) : 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)
+ private const val CONFIG_CHANGE_BUFFER_PERIOD: Long = 2 * 60 * 1000L
}
- fun keyPairChanged() { // this should only happen restoring or clearing data
- _userConfig?.free()
- _contacts?.free()
- _convoVolatileConfig?.free()
- _userConfig = null
- _contacts = null
- _convoVolatileConfig = null
+ init {
+ System.loadLibrary("session_util")
}
- 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 userConfigs = HashMap>()
+ private val groupConfigs = HashMap>()
+
+ private val _configUpdateNotifications = MutableSharedFlow(
+ extraBufferCapacity = 5, // The notifications are normally important so we can afford to buffer a few
+ onBufferOverflow = BufferOverflow.SUSPEND
+ )
+ override val configUpdateNotifications get() = _configUpdateNotifications
+
+ private fun requiresCurrentUserAccountId(): AccountId =
+ AccountId(requireNotNull(textSecurePreferences.getLocalNumber()) {
+ "No logged in user"
+ })
+
+ private fun requiresCurrentUserED25519SecKey(): ByteArray =
+ requireNotNull(storage.get().getUserED25519KeyPair()?.secretKey?.asBytes) {
+ "No logged in user"
+ }
- private val isConfigForcedOn by lazy { TextSecurePreferences.hasForcedNewConfig(context) }
+ private fun ensureUserConfigsInitialized(): Pair {
+ val userAccountId = requiresCurrentUserAccountId()
- private val listeners: MutableList = mutableListOf()
- fun registerListener(listener: ConfigFactoryUpdateListener) {
- listeners += listener
+ return synchronized(userConfigs) {
+ userConfigs.getOrPut(userAccountId) {
+ ReentrantReadWriteLock() to UserConfigsImpl(
+ userEd25519SecKey = requiresCurrentUserED25519SecKey(),
+ userAccountId = userAccountId,
+ threadDb = threadDb,
+ configDatabase = configDatabase,
+ storage = storage.get()
+ )
+ }
+ }
}
- fun unregisterListener(listener: ConfigFactoryUpdateListener) {
- listeners -= listener
+ private fun ensureGroupConfigsInitialized(groupId: AccountId): Pair {
+ val groupAdminKey = getGroup(groupId)?.adminKey
+ return synchronized(groupConfigs) {
+ groupConfigs.getOrPut(groupId) {
+ ReentrantReadWriteLock() to GroupConfigsImpl(
+ userEd25519SecKey = requiresCurrentUserED25519SecKey(),
+ groupAccountId = groupId,
+ groupAdminKey = groupAdminKey,
+ configDatabase = configDatabase
+ )
+ }
+ }
}
- private inline fun synchronizedWithLog(lock: Any, body: ()->T): T {
- Trace.beginSection("synchronizedWithLog")
- val result = synchronized(lock) {
- body()
+ override fun withUserConfigs(cb: (UserConfigs) -> T): T {
+ val (lock, configs) = ensureUserConfigsInitialized()
+ return lock.read {
+ cb(configs)
}
- Trace.endSection()
- return result
}
- override val user: UserProfile?
- get() = synchronizedWithLog(userLock) {
- 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)
+ /**
+ * Perform an operation on the user configs, and notify listeners if the configs were changed.
+ *
+ * @param cb A function that takes a [UserConfigsImpl] and returns a pair of the result of the operation and a boolean indicating if the configs were changed.
+ */
+ private fun doWithMutableUserConfigs(cb: (UserConfigsImpl) -> Pair>): T {
+ val (lock, configs) = ensureUserConfigsInitialized()
+ val (result, changed) = lock.write {
+ cb(configs)
+ }
+
+ if (changed.isNotEmpty()) {
+ for (notification in changed) {
+ if (!_configUpdateNotifications.tryEmit(notification)) {
+ Log.e("ConfigFactory", "Unable to deliver config update notification")
}
}
- _userConfig
}
- override val contacts: Contacts?
- get() = synchronizedWithLog(contactsLock) {
- 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)
- }
+ return result
+ }
+
+ override fun mergeUserConfigs(
+ userConfigType: UserConfigType,
+ messages: List
+ ) {
+ if (messages.isEmpty()) {
+ return
+ }
+
+ val toDump = doWithMutableUserConfigs { configs ->
+ val config = when (userConfigType) {
+ UserConfigType.CONTACTS -> configs.contacts
+ UserConfigType.USER_PROFILE -> configs.userProfile
+ UserConfigType.CONVO_INFO_VOLATILE -> configs.convoInfoVolatile
+ UserConfigType.USER_GROUPS -> configs.userGroups
}
- _contacts
+
+ // Merge the list of config messages, we'll be told which messages have been merged
+ // and we will then find out which message has the max timestamp
+ val maxTimestamp = config.merge(messages.map { it.hash to it.data }.toTypedArray())
+ .asSequence()
+ .mapNotNull { hash -> messages.firstOrNull { it.hash == hash } }
+ .maxOfOrNull { it.timestamp }
+
+ maxTimestamp?.let {
+ (config.dump() to it) to
+ listOf(ConfigUpdateNotification.UserConfigsMerged(userConfigType, it))
+ } ?: (null to emptyList())
}
- override val convoVolatile: ConversationVolatileConfig?
- get() = synchronizedWithLog(convoVolatileLock) {
- 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)
- }
+ // Dump now regardless so we can save the timestamp to the database
+ if (toDump != null) {
+ val (dump, timestamp) = toDump
+ configDatabase.storeConfig(
+ variant = userConfigType.configVariant,
+ publicKey = requiresCurrentUserAccountId().hexString,
+ data = dump,
+ timestamp = timestamp
+ )
+ }
+ }
+
+ override fun withMutableUserConfigs(cb: (MutableUserConfigs) -> T): T {
+ return doWithMutableUserConfigs {
+ val result = cb(it)
+
+ val changed = if (it.userGroups.dirty() ||
+ it.convoInfoVolatile.dirty() ||
+ it.userProfile.dirty() ||
+ it.contacts.dirty()) {
+ listOf(ConfigUpdateNotification.UserConfigsModified)
+ } else {
+ emptyList()
}
- _convoVolatileConfig
+
+ result to changed
}
+ }
- override val userGroups: UserGroupsConfig?
- get() = synchronizedWithLog(userGroupsLock) {
- 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)
- }
+ override fun withGroupConfigs(groupId: AccountId, cb: (GroupConfigs) -> T): T {
+ val (lock, configs) = ensureGroupConfigsInitialized(groupId)
+
+ return lock.read {
+ cb(configs)
+ }
+ }
+
+ private fun doWithMutableGroupConfigs(
+ groupId: AccountId,
+ recreateConfigInstances: Boolean,
+ cb: (GroupConfigsImpl) -> Pair): T {
+ if (recreateConfigInstances) {
+ synchronized(groupConfigs) {
+ groupConfigs.remove(groupId)
}
- _userGroups
}
- override fun getUserConfigs(): List =
- listOfNotNull(user, contacts, convoVolatile, userGroups)
+ val (lock, configs) = ensureGroupConfigsInitialized(groupId)
+ val (result, changed) = lock.write {
+ cb(configs)
+ }
+ if (changed) {
+ if (!_configUpdateNotifications.tryEmit(ConfigUpdateNotification.GroupConfigsUpdated(groupId))) {
+ Log.e("ConfigFactory", "Unable to deliver group update notification")
+ }
+ }
- 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)
+ return result
}
- 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)
+ override fun withMutableGroupConfigs(
+ groupId: AccountId,
+ recreateConfigInstances: Boolean,
+ cb: (MutableGroupConfigs) -> T
+ ): T {
+ return doWithMutableGroupConfigs(recreateConfigInstances = recreateConfigInstances, groupId = groupId) {
+ cb(it) to it.dumpIfNeeded(clock)
+ }
}
- 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
- )
+ override fun removeGroup(groupId: AccountId) {
+ withMutableUserConfigs {
+ it.userGroups.eraseClosedGroup(groupId.hexString)
+ it.convoInfoVolatile.eraseClosedGroup(groupId.hexString)
+ }
+
+ configDatabase.deleteGroupConfigs(groupId)
}
- 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 decryptForUser(
+ encoded: ByteArray,
+ domain: String,
+ closedGroupSessionId: AccountId
+ ): ByteArray? {
+ return Sodium.decryptForMultipleSimple(
+ encoded = encoded,
+ ed25519SecretKey = requireNotNull(storage.get().getUserED25519KeyPair()?.secretKey?.asBytes) {
+ "No logged in user"
+ },
+ domain = domain,
+ senderPubKey = Sodium.ed25519PkToCurve25519(closedGroupSessionId.pubKeyBytes)
+ )
}
- override fun persist(forConfigObject: ConfigBase, timestamp: Long) {
- try {
- listeners.forEach { listener ->
- listener.notifyUpdates(forConfigObject, timestamp)
- }
- 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")
+ override fun mergeGroupConfigMessages(
+ groupId: AccountId,
+ keys: List,
+ info: List,
+ members: List
+ ) {
+ doWithMutableGroupConfigs(groupId, false) { configs ->
+ // Keys must be loaded first as they are used to decrypt the other config messages
+ val keysLoaded = keys.fold(false) { acc, msg ->
+ configs.groupKeys.loadKey(msg.data, msg.hash, msg.timestamp, configs.groupInfo.pointer, configs.groupMembers.pointer) || acc
}
- } catch (e: Exception) {
- Log.e("Loki", "failed to persist ${forConfigObject.javaClass.simpleName}", e)
+
+ val infoMerged = info.isNotEmpty() &&
+ configs.groupInfo.merge(info.map { it.hash to it.data }.toTypedArray()).isNotEmpty()
+
+ val membersMerged = members.isNotEmpty() &&
+ configs.groupMembers.merge(members.map { it.hash to it.data }.toTypedArray()).isNotEmpty()
+
+ configs.dumpIfNeeded(clock)
+
+ Unit to (keysLoaded || infoMerged || membersMerged)
}
}
- override fun getConfigTimestamp(forConfigObject: ConfigBase, publicKey: String): Long {
- val variant = when (forConfigObject) {
- is UserProfile -> SharedConfigMessage.Kind.USER_PROFILE.name
- is Contacts -> SharedConfigMessage.Kind.CONTACTS.name
- is ConversationVolatileConfig -> SharedConfigMessage.Kind.CONVO_INFO_VOLATILE.name
- is UserGroupsConfig -> SharedConfigMessage.Kind.GROUPS.name
- else -> throw UnsupportedOperationException("Can't support type of ${forConfigObject::class.simpleName} yet")
+ override fun confirmUserConfigsPushed(
+ contacts: Pair?,
+ userProfile: Pair?,
+ convoInfoVolatile: Pair?,
+ userGroups: Pair?
+ ) {
+ if (contacts == null && userProfile == null && convoInfoVolatile == null && userGroups == null) {
+ return
+ }
+
+ // Confirm push for the configs and gather the dumped data to be saved into the db.
+ // For this operation, we will no notify the users as there won't be any real change in terms
+ // of the displaying data.
+ val dump = doWithMutableUserConfigs { configs ->
+ sequenceOf(contacts, userProfile, convoInfoVolatile, userGroups)
+ .zip(
+ sequenceOf(
+ UserConfigType.CONTACTS to configs.contacts,
+ UserConfigType.USER_PROFILE to configs.userProfile,
+ UserConfigType.CONVO_INFO_VOLATILE to configs.convoInfoVolatile,
+ UserConfigType.USER_GROUPS to configs.userGroups
+ )
+ )
+ .filter { (push, _) -> push != null }
+ .onEach { (push, config) -> config.second.confirmPushed(push!!.first.seqNo, push.second.hash) }
+ .map { (push, config) ->
+ Triple(config.first.configVariant, config.second.dump(), push!!.second.timestamp)
+ }.toList() to emptyList()
}
- return configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey)
+ // We need to persist the data to the database to save timestamp after the push
+ val userAccountId = requiresCurrentUserAccountId()
+ for ((variant, data, timestamp) in dump) {
+ configDatabase.storeConfig(variant, userAccountId.hexString, data, timestamp)
+ }
+ }
+
+ override fun confirmGroupConfigsPushed(
+ groupId: AccountId,
+ members: Pair?,
+ info: Pair?,
+ keysPush: ConfigPushResult?
+ ) {
+ if (members == null && info == null && keysPush == null) {
+ return
+ }
+
+ doWithMutableGroupConfigs(groupId, false) { configs ->
+ members?.let { (push, result) -> configs.groupMembers.confirmPushed(push.seqNo, result.hash) }
+ info?.let { (push, result) -> configs.groupInfo.confirmPushed(push.seqNo, result.hash) }
+ keysPush?.let { (hash, timestamp) ->
+ val pendingConfig = configs.groupKeys.pendingConfig()
+ if (pendingConfig != null) {
+ configs.groupKeys.loadKey(pendingConfig, hash, timestamp, configs.groupInfo.pointer, configs.groupMembers.pointer)
+ }
+ }
+
+ configs.dumpIfNeeded(clock)
+
+ Unit to true
+ }
}
override fun conversationInConfig(
@@ -214,41 +377,368 @@ class ConfigFactory(
openGroupId: String?,
visibleOnly: Boolean
): Boolean {
- val (_, userPublicKey) = maybeGetUserInfo() ?: return true
+ val userPublicKey = storage.get().getUserPublicKey() ?: return false
if (openGroupId != null) {
- val userGroups = userGroups ?: return false
val threadId = GroupManager.getOpenGroupThreadID(openGroupId, context)
- val openGroup = get(context).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return false
+ val openGroup = 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)
+ return withUserConfigs {
+ it.userGroups.getCommunityInfo(openGroup.server, openGroup.room) != null
+ }
+ } else if (groupPublicKey != null) {
+ // Not handling the `hidden` behaviour for legacy groups so just indicate the existence
+ return withUserConfigs {
+ if (groupPublicKey.startsWith(IdPrefix.GROUP.value)) {
+ it.userGroups.getClosedGroup(groupPublicKey) != null
+ } else {
+ it.userGroups.getLegacyGroupInfo(groupPublicKey) != null
+ }
+ }
+ } else if (publicKey == userPublicKey) {
+ return withUserConfigs {
+ !visibleOnly || it.userProfile.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN
+ }
+ } else if (publicKey != null) {
+ return withUserConfigs {
+ (!visibleOnly || it.contacts.get(publicKey)?.priority != ConfigBase.PRIORITY_HIDDEN)
+ }
+ } else {
+ return false
}
- 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)
+ override fun canPerformChange(
+ variant: String,
+ publicKey: String,
+ changeTimestampMs: Long
+ ): Boolean {
+ val lastUpdateTimestampMs =
+ configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey)
+
+ // Ensure the change occurred after the last config message was handled (minus the buffer period)
+ return (changeTimestampMs >= (lastUpdateTimestampMs - CONFIG_CHANGE_BUFFER_PERIOD))
+ }
+
+ override fun getConfigTimestamp(userConfigType: UserConfigType, publicKey: String): Long {
+ return configDatabase.retrieveConfigLastUpdateTimestamp(userConfigType.configVariant, publicKey)
+ }
+
+ override fun getGroupAuth(groupId: AccountId): SwarmAuth? {
+ val (adminKey, authData) = withUserConfigs {
+ val group = it.userGroups.getClosedGroup(groupId.hexString)
+ group?.adminKey to group?.authData
}
- else if (publicKey == userPublicKey) {
- val user = user ?: return false
- return (!visibleOnly || user.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN)
+ return if (adminKey != null) {
+ OwnedSwarmAuth.ofClosedGroup(groupId, adminKey)
+ } else if (authData != null) {
+ GroupSubAccountSwarmAuth(groupId, this, authData)
+ } else {
+ null
}
- else if (publicKey != null) {
- val contacts = contacts ?: return false
- val targetContact = contacts.get(publicKey) ?: return false
+ }
- return (!visibleOnly || targetContact.priority != ConfigBase.PRIORITY_HIDDEN)
+ fun clearAll() {
+ synchronized(userConfigs) {
+ userConfigs.clear()
}
- return false
+ synchronized(groupConfigs) {
+ groupConfigs.clear()
+ }
}
- override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean {
- val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey)
+ private class GroupSubAccountSwarmAuth(
+ override val accountId: AccountId,
+ val factory: ConfigFactory,
+ val authData: ByteArray,
+ ) : SwarmAuth {
+ override val ed25519PublicKeyHex: String?
+ get() = null
- // Ensure the change occurred after the last config message was handled (minus the buffer period)
- return (changeTimestampMs >= (lastUpdateTimestampMs - ConfigFactory.configChangeBufferPeriod))
+ override fun sign(data: ByteArray): Map {
+ return factory.withGroupConfigs(accountId) {
+ val auth = it.groupKeys.subAccountSign(data, authData)
+ buildMap {
+ put("subaccount", auth.subAccount)
+ put("subaccount_sig", auth.subAccountSig)
+ put("signature", auth.signature)
+ }
+ }
+ }
+
+ override fun signForPushRegistry(data: ByteArray): Map {
+ return factory.withGroupConfigs(accountId) {
+ val auth = it.groupKeys.subAccountSign(data, authData)
+ buildMap {
+ put("subkey_tag", auth.subAccount)
+ put("signature", auth.signature)
+ }
+ }
+ }
+ }
+}
+
+private val UserConfigType.configVariant: ConfigVariant
+ get() = when (this) {
+ UserConfigType.CONTACTS -> ConfigDatabase.CONTACTS_VARIANT
+ UserConfigType.USER_PROFILE -> ConfigDatabase.USER_PROFILE_VARIANT
+ UserConfigType.CONVO_INFO_VOLATILE -> ConfigDatabase.CONVO_INFO_VARIANT
+ UserConfigType.USER_GROUPS -> ConfigDatabase.USER_GROUPS_VARIANT
+ }
+
+/**
+ * Sync group data from our local database
+ */
+private fun MutableUserGroupsConfig.initFrom(storage: StorageProtocol) {
+ storage
+ .getAllOpenGroups()
+ .values
+ .asSequence()
+ .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)
+ }
+ .forEach(this::set)
+
+ storage
+ .getAllGroups(includeInactive = false)
+ .asSequence().filter { it.isLegacyGroup && 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.associate { it.serialize() to true }
+ val members = group.members.filterNot { it.serialize() !in admins.keys }.associate { it.serialize() to false }
+ GroupInfo.LegacyGroupInfo(
+ accountId = 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)
+ )
+ }
+ .forEach(this::set)
+}
+
+private fun MutableConversationVolatileConfig.initFrom(storage: StorageProtocol, threadDb: 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.isCommunityRecipient -> {
+ val openGroup = storage.getOpenGroup(current.threadId) ?: continue
+ val (base, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: continue
+ getOrConstructCommunity(base, room, pubKey)
+ }
+ recipient.isGroupV2Recipient -> {
+ // It's probably safe to assume there will never be a case where new closed groups will ever be there before a dump is created...
+ // but just in case...
+ getOrConstructClosedGroup(recipient.address.serialize())
+ }
+ recipient.isLegacyGroupRecipient -> {
+ val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize())
+ getOrConstructLegacyGroup(groupPublicKey)
+ }
+ recipient.isContactRecipient -> {
+ if (recipient.isLocalNumber) null // this is handled by the user profile NTS data
+ else if (recipient.isCommunityInboxRecipient) null // specifically exclude
+ else if (!recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) null
+ else getOrConstructOneToOne(recipient.address.serialize())
+ }
+ else -> null
+ }
+ if (contact == null) {
+ current = reader.next
+ continue
+ }
+ contact.lastRead = current.lastSeen
+ contact.unread = false
+ set(contact)
+ current = reader.next
+ }
+ }
+}
+
+private fun MutableUserProfile.initFrom(storage: StorageProtocol) {
+ val ownPublicKey = storage.getUserPublicKey() ?: return
+ val config = ConfigurationMessage.getCurrent(listOf()) ?: return
+ setName(config.displayName)
+ val picUrl = config.profilePicture
+ val picKey = config.profileKey
+ if (!picUrl.isNullOrEmpty() && picKey.isNotEmpty()) {
+ setPic(UserPic(picUrl, picKey))
+ }
+ val ownThreadId = storage.getThreadId(Address.fromSerialized(ownPublicKey))
+ setNtsPriority(
+ if (ownThreadId != null)
+ if (storage.isPinned(ownThreadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
+ else ConfigBase.PRIORITY_HIDDEN
+ )
+}
+
+private fun MutableContacts.initFrom(storage: StorageProtocol) {
+ val localUserKey = storage.getUserPublicKey() ?: return
+ val contactsWithSettings = storage.getAllContacts().filter { recipient ->
+ recipient.accountID != localUserKey && recipient.accountID.startsWith(IdPrefix.STANDARD.value)
+ && storage.getThreadId(recipient.accountID) != null
+ }.map { contact ->
+ val address = Address.fromSerialized(contact.accountID)
+ val thread = storage.getThreadId(address)
+ val isPinned = if (thread != null) {
+ storage.isPinned(thread)
+ } else false
+
+ Triple(contact, storage.getRecipientSettings(address)!!, isPinned)
+ }
+ 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.accountID,
+ 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())
+ )
+ set(contactInfo)
+ }
+}
+
+private class UserConfigsImpl(
+ userEd25519SecKey: ByteArray,
+ private val userAccountId: AccountId,
+ private val configDatabase: ConfigDatabase,
+ storage: StorageProtocol,
+ threadDb: ThreadDatabase,
+ contactsDump: ByteArray? = configDatabase.retrieveConfigAndHashes(
+ ConfigDatabase.CONTACTS_VARIANT,
+ userAccountId.hexString
+ ),
+ userGroupsDump: ByteArray? = configDatabase.retrieveConfigAndHashes(
+ ConfigDatabase.USER_GROUPS_VARIANT,
+ userAccountId.hexString
+ ),
+ userProfileDump: ByteArray? = configDatabase.retrieveConfigAndHashes(
+ ConfigDatabase.USER_PROFILE_VARIANT,
+ userAccountId.hexString
+ ),
+ convoInfoDump: ByteArray? = configDatabase.retrieveConfigAndHashes(
+ ConfigDatabase.CONVO_INFO_VARIANT,
+ userAccountId.hexString
+ )
+) : MutableUserConfigs {
+ override val contacts = Contacts(
+ ed25519SecretKey = userEd25519SecKey,
+ initialDump = contactsDump,
+ )
+
+ override val userGroups = UserGroupsConfig(
+ ed25519SecretKey = userEd25519SecKey,
+ initialDump = userGroupsDump
+ )
+ override val userProfile = UserProfile(
+ ed25519SecretKey = userEd25519SecKey,
+ initialDump = userProfileDump
+ )
+ override val convoInfoVolatile = ConversationVolatileConfig(
+ ed25519SecretKey = userEd25519SecKey,
+ initialDump = convoInfoDump,
+ )
+
+ init {
+ if (contactsDump == null) {
+ contacts.initFrom(storage)
+ }
+
+ if (userGroupsDump == null) {
+ userGroups.initFrom(storage)
+ }
+
+ if (userProfileDump == null) {
+ userProfile.initFrom(storage)
+ }
+
+ if (convoInfoDump == null) {
+ convoInfoVolatile.initFrom(storage, threadDb)
+ }
+ }
+}
+
+private class GroupConfigsImpl(
+ userEd25519SecKey: ByteArray,
+ private val groupAccountId: AccountId,
+ groupAdminKey: ByteArray?,
+ private val configDatabase: ConfigDatabase
+) : MutableGroupConfigs {
+ override val groupInfo = GroupInfoConfig(
+ groupPubKey = groupAccountId.pubKeyBytes,
+ groupAdminKey = groupAdminKey,
+ initialDump = configDatabase.retrieveConfigAndHashes(
+ ConfigDatabase.INFO_VARIANT,
+ groupAccountId.hexString
+ )
+ )
+ override val groupMembers = GroupMembersConfig(
+ groupPubKey = groupAccountId.pubKeyBytes,
+ groupAdminKey = groupAdminKey,
+ initialDump = configDatabase.retrieveConfigAndHashes(
+ ConfigDatabase.MEMBER_VARIANT,
+ groupAccountId.hexString
+ )
+ )
+ override val groupKeys = GroupKeysConfig(
+ userSecretKey = userEd25519SecKey,
+ groupPublicKey = groupAccountId.pubKeyBytes,
+ groupAdminKey = groupAdminKey,
+ initialDump = configDatabase.retrieveConfigAndHashes(
+ ConfigDatabase.KEYS_VARIANT,
+ groupAccountId.hexString
+ ),
+ info = groupInfo,
+ members = groupMembers
+ )
+
+ fun dumpIfNeeded(clock: SnodeClock): Boolean {
+ if (groupInfo.needsDump() || groupMembers.needsDump() || groupKeys.needsDump()) {
+ configDatabase.storeGroupConfigs(
+ publicKey = groupAccountId.hexString,
+ keysConfig = groupKeys.dump(),
+ infoConfig = groupInfo.dump(),
+ memberConfig = groupMembers.dump(),
+ timestamp = clock.currentTimeMills()
+ )
+ return true
+ }
+
+ return false
+ }
+
+ override fun rekey() {
+ groupKeys.rekey(groupInfo.pointer, groupMembers.pointer)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt
new file mode 100644
index 00000000000..d0d48b52e68
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt
@@ -0,0 +1,29 @@
+package org.thoughtcrime.securesms.dependencies
+
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.session.libsession.database.StorageProtocol
+import org.session.libsession.utilities.SSKEnvironment
+import org.session.libsignal.database.LokiAPIDatabaseProtocol
+import org.thoughtcrime.securesms.database.LokiAPIDatabase
+import org.thoughtcrime.securesms.database.Storage
+import org.thoughtcrime.securesms.service.ExpiringMessageManager
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class DatabaseBindings {
+
+ @Binds
+ abstract fun bindStorageProtocol(storage: Storage): StorageProtocol
+
+ @Binds
+ abstract fun bindLokiAPIDatabaseProtocol(lokiAPIDatabase: LokiAPIDatabase): LokiAPIDatabaseProtocol
+
+ @Binds
+ abstract fun bindMessageExpirationManagerProtocol(manager: ExpiringMessageManager): SSKEnvironment.MessageExpirationManagerProtocol
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt
index c037f3b27a2..ad5eac883f7 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt
@@ -5,6 +5,7 @@ import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.session.libsession.database.MessageDataProvider
+import org.session.libsession.database.StorageProtocol
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.database.*
import org.thoughtcrime.securesms.database.MmsSmsDatabase
@@ -16,6 +17,7 @@ interface DatabaseComponent {
companion object {
@JvmStatic
+ @Deprecated("Use Hilt to inject your dependencies instead")
fun get(context: Context) = ApplicationContext.getInstance(context).databaseComponent
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt
index 30fb40d89a2..179a463fc8f 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt
@@ -7,14 +7,36 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.session.libsession.database.MessageDataProvider
-import org.session.libsession.utilities.SSKEnvironment
import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider
import org.thoughtcrime.securesms.crypto.AttachmentSecret
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
-import org.thoughtcrime.securesms.database.*
+import org.thoughtcrime.securesms.database.AttachmentDatabase
+import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase
+import org.thoughtcrime.securesms.database.ConfigDatabase
+import org.thoughtcrime.securesms.database.DraftDatabase
+import org.thoughtcrime.securesms.database.EmojiSearchDatabase
+import org.thoughtcrime.securesms.database.ExpirationConfigurationDatabase
+import org.thoughtcrime.securesms.database.GroupDatabase
+import org.thoughtcrime.securesms.database.GroupMemberDatabase
+import org.thoughtcrime.securesms.database.GroupReceiptDatabase
+import org.thoughtcrime.securesms.database.LokiAPIDatabase
+import org.thoughtcrime.securesms.database.LokiBackupFilesDatabase
+import org.thoughtcrime.securesms.database.LokiMessageDatabase
+import org.thoughtcrime.securesms.database.LokiThreadDatabase
+import org.thoughtcrime.securesms.database.LokiUserDatabase
+import org.thoughtcrime.securesms.database.MediaDatabase
+import org.thoughtcrime.securesms.database.MmsDatabase
+import org.thoughtcrime.securesms.database.MmsSmsDatabase
+import org.thoughtcrime.securesms.database.PushDatabase
+import org.thoughtcrime.securesms.database.ReactionDatabase
+import org.thoughtcrime.securesms.database.RecipientDatabase
+import org.thoughtcrime.securesms.database.SearchDatabase
+import org.thoughtcrime.securesms.database.SessionContactDatabase
+import org.thoughtcrime.securesms.database.SessionJobDatabase
+import org.thoughtcrime.securesms.database.SmsDatabase
+import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
-import org.thoughtcrime.securesms.service.ExpiringMessageManager
import javax.inject.Singleton
@Module
@@ -26,10 +48,6 @@ object DatabaseModule {
System.loadLibrary("sqlcipher")
}
- @Provides
- @Singleton
- fun provideMessageExpirationManagerProtocol(@ApplicationContext context: Context): SSKEnvironment.MessageExpirationManagerProtocol = ExpiringMessageManager(context)
-
@Provides
@Singleton
fun provideAttachmentSecret(@ApplicationContext context: Context) = AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret
@@ -139,14 +157,6 @@ object DatabaseModule {
@Singleton
fun provideExpirationConfigurationDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = ExpirationConfigurationDatabase(context, openHelper)
- @Provides
- @Singleton
- fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage {
- val storage = Storage(context,openHelper, configFactory)
- threadDatabase.setUpdateListener(storage)
- return storage
- }
-
@Provides
@Singleton
fun provideAttachmentProvider(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper): MessageDataProvider = DatabaseAttachmentProvider(context, openHelper)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/PollerFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/PollerFactory.kt
new file mode 100644
index 00000000000..305d2980928
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/PollerFactory.kt
@@ -0,0 +1,72 @@
+package org.thoughtcrime.securesms.dependencies
+
+import dagger.Lazy
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import network.loki.messenger.libsession_util.util.GroupInfo
+import org.session.libsession.database.StorageProtocol
+import org.session.libsession.messaging.groups.GroupManagerV2
+import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPoller
+import org.session.libsession.snode.SnodeClock
+import org.session.libsignal.database.LokiAPIDatabaseProtocol
+import org.session.libsignal.utilities.AccountId
+import java.util.concurrent.ConcurrentHashMap
+
+class PollerFactory(
+ private val scope: CoroutineScope,
+ private val executor: CoroutineDispatcher,
+ private val configFactory: ConfigFactory,
+ private val groupManagerV2: Lazy,
+ private val storage: Lazy,
+ private val lokiApiDatabase: LokiAPIDatabaseProtocol,
+ private val clock: SnodeClock,
+ ) {
+
+ private val pollers = ConcurrentHashMap()
+
+ fun pollerFor(sessionId: AccountId): ClosedGroupPoller? {
+ // Check if the group is currently in our config and approved, don't start if it isn't
+ val invited = configFactory.withUserConfigs {
+ it.userGroups.getClosedGroup(sessionId.hexString)?.invited
+ }
+
+ if (invited != false) return null
+
+ return pollers.getOrPut(sessionId) {
+ ClosedGroupPoller(
+ scope = scope,
+ executor = executor,
+ closedGroupSessionId = sessionId,
+ configFactoryProtocol = configFactory,
+ groupManagerV2 = groupManagerV2.get(),
+ storage = storage.get(),
+ lokiApiDatabase = lokiApiDatabase,
+ clock = clock,
+ )
+ }
+ }
+
+ fun startAll() {
+ configFactory
+ .withUserConfigs { it.userGroups.allClosedGroupInfo() }
+ .filterNot(GroupInfo.ClosedGroupInfo::invited)
+ .forEach { pollerFor(it.groupAccountId)?.start() }
+ }
+
+ fun stopAll() {
+ pollers.forEach { (_, poller) ->
+ poller.stop()
+ }
+ }
+
+ fun updatePollers() {
+ val currentGroups = configFactory
+ .withUserConfigs { it.userGroups.allClosedGroupInfo() }.filterNot(GroupInfo.ClosedGroupInfo::invited)
+ val toRemove = pollers.filter { (id, _) -> id !in currentGroups.map { it.groupAccountId } }
+ toRemove.forEach { (id, _) ->
+ pollers.remove(id)?.stop()
+ }
+ startAll()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt
index cd4b0713382..56e0012da87 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt
@@ -1,36 +1,62 @@
package org.thoughtcrime.securesms.dependencies
import android.content.Context
+import dagger.Lazy
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 kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.GlobalScope
+import org.session.libsession.database.StorageProtocol
+import org.session.libsession.messaging.groups.GroupManagerV2
+import org.session.libsession.snode.SnodeClock
+import org.session.libsession.utilities.ConfigFactoryProtocol
+import org.session.libsignal.database.LokiAPIDatabaseProtocol
import org.thoughtcrime.securesms.database.ConfigDatabase
+import org.thoughtcrime.securesms.database.ThreadDatabase
+import javax.inject.Named
import javax.inject.Singleton
+@Suppress("OPT_IN_USAGE")
@Module
@InstallIn(SingletonComponent::class)
object SessionUtilModule {
- private fun maybeUserEdSecretKey(context: Context): ByteArray? {
- val edKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return null
- return edKey.secretKey.asBytes
- }
+ private const val POLLER_SCOPE = "poller_coroutine_scope"
+
+ @Provides
+ @Named(POLLER_SCOPE)
+ fun providePollerScope(): CoroutineScope = GlobalScope
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Provides
+ @Named(POLLER_SCOPE)
+ fun provideExecutor(): CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1)
@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)
- }
+ fun providePollerFactory(@Named(POLLER_SCOPE) coroutineScope: CoroutineScope,
+ @Named(POLLER_SCOPE) dispatcher: CoroutineDispatcher,
+ configFactory: ConfigFactory,
+ storage: Lazy,
+ groupManagerV2: Lazy,
+ lokiApiDatabase: LokiAPIDatabaseProtocol,
+ clock: SnodeClock) = PollerFactory(
+ scope = coroutineScope,
+ executor = dispatcher,
+ configFactory = configFactory,
+ groupManagerV2 = groupManagerV2,
+ storage = storage,
+ lokiApiDatabase = lokiApiDatabase,
+ clock = clock,
+ )
+ @Provides
+ @Singleton
+ fun provideSnodeClock() = SnodeClock()
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt
index adeeeb91fa1..cfc0e87c65b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt
@@ -4,7 +4,7 @@ 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.PushRegistryV1
-import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
+import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.GroupUtil
@@ -25,7 +25,7 @@ object ClosedGroupManager {
// Notify the PN server
PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = groupPublicKey, publicKey = userPublicKey)
// Stop polling
- ClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
+ LegacyClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
storage.cancelPendingMessageSendJobs(threadId)
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)
if (delete) {
@@ -33,30 +33,26 @@ object ClosedGroupManager {
}
}
- 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(group: GroupRecord) {
- val groups = userGroups ?: return
- if (!group.isClosedGroup) return
+ if (!group.isLegacyGroup) 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,
- 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)
- }
+ withMutableUserConfigs {
+ val groups = it.userGroups
+
+ 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,
+ 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)
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt
index 0f562c80b7d..e3b656dc5e0 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt
@@ -1,127 +1,43 @@
package org.thoughtcrime.securesms.groups
-import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import android.widget.Toast
-import androidx.core.content.ContextCompat
-import androidx.core.view.isVisible
+import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.Fragment
-import androidx.fragment.app.viewModels
-import androidx.recyclerview.widget.DividerItemDecoration
-import androidx.recyclerview.widget.RecyclerView
-import dagger.hilt.android.AndroidEntryPoint
-import network.loki.messenger.R
-import network.loki.messenger.databinding.FragmentCreateGroupBinding
-import nl.komponents.kovenant.ui.failUi
-import nl.komponents.kovenant.ui.successUi
-import org.session.libsession.messaging.sending_receiving.MessageSender
-import org.session.libsession.messaging.sending_receiving.groupSizeLimit
-import org.session.libsession.utilities.Address
-import org.session.libsession.utilities.Device
-import org.session.libsession.utilities.TextSecurePreferences
-import org.session.libsession.utilities.recipients.Recipient
-import org.thoughtcrime.securesms.contacts.SelectContactsAdapter
+import org.thoughtcrime.securesms.conversation.start.NullStartConversationDelegate
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
-import org.thoughtcrime.securesms.dependencies.DatabaseComponent
-import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
-import com.bumptech.glide.Glide
-import org.thoughtcrime.securesms.util.fadeIn
-import org.thoughtcrime.securesms.util.fadeOut
-import javax.inject.Inject
+import org.thoughtcrime.securesms.groups.compose.CreateGroupScreen
+import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme
-@AndroidEntryPoint
class CreateGroupFragment : Fragment() {
-
- @Inject
- lateinit var device: Device
-
- private lateinit var binding: FragmentCreateGroupBinding
- private val viewModel: CreateGroupViewModel by viewModels()
-
- lateinit var delegate: StartConversationDelegate
-
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
- binding = FragmentCreateGroupBinding.inflate(inflater)
- return binding.root
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- val adapter = SelectContactsAdapter(requireContext(), Glide.with(requireContext()))
- binding.backButton.setOnClickListener { delegate.onDialogBackPressed() }
- binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() }
- binding.contactSearch.callbacks = object : KeyboardPageSearchView.Callbacks {
- override fun onQueryChanged(query: String) {
- adapter.members = viewModel.filter(query).map { it.address.serialize() }
- }
- }
- binding.createNewPrivateChatButton.setOnClickListener { delegate.onNewMessageSelected() }
- binding.recyclerView.adapter = adapter
- val divider = ContextCompat.getDrawable(requireActivity(), R.drawable.conversation_menu_divider)!!.let {
- DividerItemDecoration(requireActivity(), RecyclerView.VERTICAL).apply {
- setDrawable(it)
+ return ComposeView(requireContext()).apply {
+ val delegate = (parentFragment as? StartConversationDelegate)
+ ?: (activity as? StartConversationDelegate)
+ ?: NullStartConversationDelegate
+
+ setContent {
+ SessionMaterialTheme {
+ CreateGroupScreen(
+ onNavigateToConversationScreen = { threadID ->
+ startActivity(
+ Intent(requireContext(), ConversationActivityV2::class.java)
+ .putExtra(ConversationActivityV2.THREAD_ID, threadID)
+ )
+ },
+ onBack = delegate::onDialogBackPressed,
+ onClose = delegate::onDialogClosePressed
+ )
+ }
}
}
- binding.recyclerView.addItemDecoration(divider)
- var isLoading = false
- binding.createClosedGroupButton.setOnClickListener {
- if (isLoading) return@setOnClickListener
- val name = binding.nameEditText.text.trim()
- if (name.isEmpty()) {
- return@setOnClickListener Toast.makeText(context, R.string.groupNameEnterPlease, Toast.LENGTH_LONG).show()
- }
-
- // Limit the group name length if it exceeds the limit
- if (name.length > resources.getInteger(R.integer.max_group_and_community_name_length_chars)) {
- return@setOnClickListener Toast.makeText(context, R.string.groupNameEnterShorter, Toast.LENGTH_LONG).show()
- }
-
- val selectedMembers = adapter.selectedMembers
- if (selectedMembers.isEmpty()) {
- return@setOnClickListener Toast.makeText(context, R.string.groupCreateErrorNoMembers, Toast.LENGTH_LONG).show()
- }
- if (selectedMembers.count() >= groupSizeLimit) { // Minus one because we're going to include self later
- return@setOnClickListener Toast.makeText(context, R.string.groupAddMemberMaximum, Toast.LENGTH_LONG).show()
- }
- val userPublicKey = TextSecurePreferences.getLocalNumber(requireContext())!!
- isLoading = true
- binding.loaderContainer.fadeIn()
- MessageSender.createClosedGroup(device, name.toString(), selectedMembers + setOf( userPublicKey )).successUi { groupID ->
- binding.loaderContainer.fadeOut()
- isLoading = false
- val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getOrCreateThreadIdFor(Recipient.from(requireContext(), Address.fromSerialized(groupID), false))
- openConversationActivity(
- requireContext(),
- threadID,
- Recipient.from(requireContext(), Address.fromSerialized(groupID), false)
- )
- delegate.onDialogClosePressed()
- }.failUi {
- binding.loaderContainer.fadeOut()
- isLoading = false
- Toast.makeText(context, it.message, Toast.LENGTH_LONG).show()
- }
- }
- binding.mainContentGroup.isVisible = !viewModel.recipients.value.isNullOrEmpty()
- binding.emptyStateGroup.isVisible = viewModel.recipients.value.isNullOrEmpty()
- viewModel.recipients.observe(viewLifecycleOwner) { recipients ->
- adapter.members = recipients.map { it.address.serialize() }
- }
- }
-
- private fun openConversationActivity(context: Context, threadId: Long, recipient: Recipient) {
- val intent = Intent(context, ConversationActivityV2::class.java)
- intent.putExtra(ConversationActivityV2.THREAD_ID, threadId)
- intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address)
- context.startActivity(intent)
}
+}
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt
index b3dbb49384a..5a20d7be9ed 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt
@@ -1,46 +1,109 @@
package org.thoughtcrime.securesms.groups
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.MutableLiveData
+import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-import org.session.libsession.utilities.TextSecurePreferences
-import org.session.libsession.utilities.recipients.Recipient
-import org.thoughtcrime.securesms.database.ThreadDatabase
+import org.session.libsession.database.StorageProtocol
+import org.session.libsession.messaging.groups.GroupManagerV2
+import org.thoughtcrime.securesms.dependencies.ConfigFactory
import javax.inject.Inject
+
@HiltViewModel
class CreateGroupViewModel @Inject constructor(
- private val threadDb: ThreadDatabase,
- private val textSecurePreferences: TextSecurePreferences
-) : ViewModel() {
+ configFactory: ConfigFactory,
+ @ApplicationContext appContext: Context,
+ private val storage: StorageProtocol,
+ private val groupManagerV2: GroupManagerV2,
+): ViewModel() {
+ // Child view model to handle contact selection logic
+ val selectContactsViewModel = SelectContactsViewModel(
+ storage = storage,
+ configFactory = configFactory,
+ excludingAccountIDs = emptySet(),
+ scope = viewModelScope,
+ appContext = appContext,
+ )
+
+ // Input: group name
+ private val mutableGroupName = MutableStateFlow("")
+ private val mutableGroupNameError = MutableStateFlow("")
+
+ // Output: group name
+ val groupName: StateFlow get() = mutableGroupName
+ val groupNameError: StateFlow get() = mutableGroupNameError
- private val _recipients = MutableLiveData>()
- val recipients: LiveData> = _recipients
+ // Output: loading state
+ private val mutableIsLoading = MutableStateFlow(false)
+ val isLoading: StateFlow get() = mutableIsLoading
- init {
+ // Events
+ private val mutableEvents = MutableSharedFlow()
+ val events: SharedFlow get() = mutableEvents
+
+ fun onCreateClicked() {
viewModelScope.launch {
- threadDb.approvedConversationList.use { openCursor ->
- val reader = threadDb.readerFor(openCursor)
- val recipients = mutableListOf()
- while (true) {
- recipients += reader.next?.recipient ?: break
+ val groupName = groupName.value.trim()
+ if (groupName.isBlank()) {
+ mutableGroupNameError.value = "Group name cannot be empty"
+ return@launch
+ }
+
+ val selected = selectContactsViewModel.currentSelected
+ if (selected.isEmpty()) {
+ mutableEvents.emit(CreateGroupEvent.Error("Please select at least one contact"))
+ return@launch
+ }
+
+ mutableIsLoading.value = true
+
+ val createResult = withContext(Dispatchers.Default) {
+ runCatching {
+ groupManagerV2.createGroup(
+ groupName = groupName,
+ groupDescription = "",
+ members = selected
+ )
}
- withContext(Dispatchers.Main) {
- _recipients.value = recipients
- .filter { !it.isGroupRecipient && it.hasApprovedMe() && it.address.serialize() != textSecurePreferences.getLocalNumber() }
+ }
+
+ when (val recipient = createResult.getOrNull()) {
+ null -> {
+ mutableEvents.emit(CreateGroupEvent.Error("Failed to create group"))
+
+ }
+ else -> {
+ val threadId = withContext(Dispatchers.Default) { storage.getOrCreateThreadIdFor(recipient.address) }
+ mutableEvents.emit(CreateGroupEvent.NavigateToConversation(threadId))
}
}
+
+ mutableIsLoading.value = false
}
}
- fun filter(query: String): List {
- return _recipients.value?.filter {
- it.address.serialize().contains(query, ignoreCase = true) || it.name?.contains(query, ignoreCase = true) == true
- } ?: emptyList()
+ fun onGroupNameChanged(name: String) {
+ mutableGroupName.value = if (name.length > MAX_GROUP_NAME_LENGTH) {
+ name.substring(0, MAX_GROUP_NAME_LENGTH)
+ } else {
+ name
+ }
+
+ mutableGroupNameError.value = ""
}
+}
+
+sealed interface CreateGroupEvent {
+ data class NavigateToConversation(val threadID: Long): CreateGroupEvent
+
+ data class Error(val message: String): CreateGroupEvent
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupActivity.kt
new file mode 100644
index 00000000000..c8750c70727
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupActivity.kt
@@ -0,0 +1,36 @@
+package org.thoughtcrime.securesms.groups
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import dagger.hilt.android.AndroidEntryPoint
+import org.session.libsignal.utilities.AccountId
+import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
+import org.thoughtcrime.securesms.groups.compose.EditGroupScreen
+import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme
+
+@AndroidEntryPoint
+class EditGroupActivity: PassphraseRequiredActionBarActivity() {
+
+ companion object {
+ private const val EXTRA_GROUP_ID = "EditClosedGroupActivity_groupID"
+
+ fun createIntent(context: Context, groupSessionId: String): Intent {
+ return Intent(context, EditGroupActivity::class.java).apply {
+ putExtra(EXTRA_GROUP_ID, groupSessionId)
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
+ setContent {
+ SessionMaterialTheme {
+ EditGroupScreen(
+ groupId = AccountId(intent.getStringExtra(EXTRA_GROUP_ID)!!),
+ onFinish = this::finish
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt
new file mode 100644
index 00000000000..b40bff5c87a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt
@@ -0,0 +1,343 @@
+package org.thoughtcrime.securesms.groups
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import network.loki.messenger.R
+import network.loki.messenger.libsession_util.util.GroupDisplayInfo
+import network.loki.messenger.libsession_util.util.GroupMember
+import org.session.libsession.database.StorageProtocol
+import org.session.libsession.messaging.groups.GroupManagerV2
+import org.session.libsession.utilities.ConfigUpdateNotification
+import org.session.libsignal.utilities.AccountId
+import org.thoughtcrime.securesms.dependencies.ConfigFactory
+
+const val MAX_GROUP_NAME_LENGTH = 100
+
+@HiltViewModel(assistedFactory = EditGroupViewModel.Factory::class)
+class EditGroupViewModel @AssistedInject constructor(
+ @Assisted private val groupId: AccountId,
+ @ApplicationContext private val context: Context,
+ private val storage: StorageProtocol,
+ configFactory: ConfigFactory,
+ private val groupManager: GroupManagerV2,
+) : ViewModel() {
+ // Input/Output state
+ private val mutableEditingName = MutableStateFlow(null)
+
+ // Input/Output: the name that has been written and submitted for change to push to the server,
+ // but not yet confirmed by the server. When this state is present, it takes precedence over
+ // the group name in the group info.
+ private val mutablePendingEditedName = MutableStateFlow(null)
+
+ // Input: invite/promote member's intermediate states. This is needed because we don't have
+ // a state that we can map into in the config system. The config system only provides "sent", "failed", etc.
+ // The intermediate states are needed to show the user that the operation is in progress, and the
+ // states are limited to the view model (i.e. lost if the user navigates away). This is a trade-off
+ // between the complexity of the config system and the user experience.
+ private val memberPendingState = MutableStateFlow