diff --git a/.drone.jsonnet b/.drone.jsonnet
index dc81115ce9..fcc0880394 100644
--- a/.drone.jsonnet
+++ b/.drone.jsonnet
@@ -38,7 +38,7 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/
pull: 'always',
environment: { ANDROID_HOME: '/usr/lib/android-sdk' },
commands: [
- 'apt-get install -y ninja-build',
+ 'apt-get install -y ninja-build openjdk-17-jdk-headless',
'./gradlew testPlayDebugUnitTestCoverageReport'
],
}
@@ -78,7 +78,7 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/
pull: 'always',
environment: { SSH_KEY: { from_secret: 'SSH_KEY' }, ANDROID_HOME: '/usr/lib/android-sdk' },
commands: [
- 'apt-get install -y ninja-build',
+ 'apt-get install -y ninja-build openjdk-17-jdk-headless',
'./gradlew assemblePlayDebug',
'./scripts/drone-static-upload.sh'
],
diff --git a/.drone.yml b/.drone.yml
new file mode 100644
index 0000000000..e7f705722f
--- /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 be928b3933..bf9ef62a97 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 a1a6d43ced..c46ec8d78f 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 = 380
@@ -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 true
@@ -64,7 +67,8 @@ android {
}
buildFeatures {
- compose true
+ viewBinding true
+ buildConfig true
}
composeOptions {
@@ -151,7 +155,7 @@ android {
}
}
- applicationVariants.forEach { variant ->
+ applicationVariants.configureEach { variant ->
variant.outputs.each { output ->
def abiName = output.getFilter("ABI") ?: 'universal'
def postFix = abiPostFix.get(abiName, 0)
@@ -169,11 +173,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') {
@@ -226,6 +230,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"
@@ -305,7 +310,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"
@@ -329,7 +333,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'
@@ -348,6 +351,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'
@@ -365,6 +370,11 @@ dependencies {
androidTestImplementation "androidx.compose.ui:ui-test-junit4-android:$composeVersion"
debugImplementation "androidx.compose.ui:ui-test-manifest:$composeVersion"
+ // 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.33.1-alpha"
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 023120e1a8..be464ad75a 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 0000000000..c4b81d8148
--- /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 0000000000..e0799b0a3a
--- /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 43b347ba42..ddb999ab56 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 54470569e1..39e9526aec 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 0000000000..e7a3ce107c
--- /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 0000000000..7b02efad68
--- /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/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index eac4ef3300..9687c01143 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -154,7 +154,13 @@
android:label="@string/conversationsBlockedContacts"
/>
+
+
-
+
+
parameters) {
+ Phrase builder = Phrase.from(this, stringRes);
+ for (Map.Entry entry : parameters.entrySet()) {
+ builder.put(entry.getKey(), entry.getValue());
+ }
+ Toast.makeText(getApplicationContext(), builder.format(), toastLength).show();
+ }
+
@Override
public void onCreate() {
TextSecurePreferences.setPushSuffix(BuildConfig.PUSH_KEY_SUFFIX);
@@ -222,9 +242,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
storage,
device,
messageDataProvider,
- ()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this),
configFactory,
- lastSentTimestampCache
+ lastSentTimestampCache,
+ this,
+ tokenFetcher
);
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
Log.i(TAG, "onCreate()");
@@ -256,6 +277,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
NetworkConstraint networkConstraint = new NetworkConstraint.Factory(this).create();
HTTP.INSTANCE.setConnectedToNetwork(networkConstraint::isMet);
+ pushRegistrationHandler.run();
+
// add our shortcut debug menu if we are not in a release build
if (BuildConfig.BUILD_TYPE != "release") {
// add the config settings shortcut
@@ -308,7 +331,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
if (poller != null) {
poller.stopIfNeeded();
}
- ClosedGroupPollerV2.getShared().stopAll();
+ pollerFactory.stopAll();
+ LegacyClosedGroupPollerV2.getShared().stopAll();
versionDataFetcher.stopTimedVersionCheck();
}
@@ -316,6 +340,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
public void onTerminate() {
stopKovenant(); // Loki
OpenGroupManager.INSTANCE.stopPolling();
+ pollerFactory.stopAll();
versionDataFetcher.stopTimedVersionCheck();
super.onTerminate();
}
@@ -438,7 +463,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
poller.setUserPublicKey(userPublicKey);
return;
}
- poller = new Poller(configFactory, new Timer());
+ poller = new Poller(configFactory);
}
public void startPollingIfNeeded() {
@@ -446,7 +471,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
if (poller != null) {
poller.startIfNeeded();
}
- ClosedGroupPollerV2.getShared().start();
+ pollerFactory.startAll();
+ LegacyClosedGroupPollerV2.getShared().start();
}
public void retrieveUserProfile() {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java
index 4fd99dd6c0..e215ff462e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java
@@ -405,6 +405,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
@SuppressWarnings("CodeBlock2Expr")
@SuppressLint("InlinedApi")
private void saveToDisk() {
+ Log.w("ACL", "Asked to save to disk!");
MediaItem mediaItem = getCurrentMediaItem();
if (mediaItem == null) return;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt
index 69d58411f3..9c6e23c3e0 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt
@@ -142,11 +142,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 6445abed3b..9ced2695df 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt
@@ -215,13 +215,29 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
threadId?.let{ MessagingModuleConfiguration.shared.lastSentTimestampCache.delete(it, messages.map { it.timestamp }) }
}
+ override fun updateMessageAsDeleted(messageId: Long, isSms: Boolean): Long {
+ val messagingDatabase: MessagingDatabase =
+ if (isSms) DatabaseComponent.get(context).smsDatabase()
+ else DatabaseComponent.get(context).mmsDatabase()
+
+ val isOutgoing = messagingDatabase.isOutgoing(messageId)
+ messagingDatabase.markAsDeleted(messageId)
+
+ if (isOutgoing) {
+ messagingDatabase.deleteMessage(messageId)
+ }
+
+ return messageId
+ }
+
override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? {
val database = DatabaseComponent.get(context).mmsSmsDatabase()
val address = Address.fromSerialized(author)
val message = database.getMessageFor(timestamp, address) ?: return null
+ updateMessageAsDeleted(message.id, !message.isMms)
val messagingDatabase: MessagingDatabase = if (message.isMms) DatabaseComponent.get(context).mmsDatabase()
else DatabaseComponent.get(context).smsDatabase()
- messagingDatabase.markAsDeleted(message.id, message.isRead, message.hasMention)
+ messagingDatabase.markAsDeleted(message.id)
if (message.isOutgoing) {
messagingDatabase.deleteMessage(message.id)
}
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 9511bddb6a..b2092be85c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt
@@ -51,19 +51,19 @@ class ProfilePictureView @JvmOverloads constructor(
}
fun update(recipient: Recipient) {
- recipient.run { update(address, isClosedGroupRecipient, isOpenGroupInboxRecipient) }
+ recipient.run { update(address, isLegacyClosedGroupRecipient, isOpenGroupInboxRecipient) }
}
fun update(
address: Address,
- isClosedGroupRecipient: Boolean = false,
+ isLegacyClosedGroupRecipient: Boolean = false,
isOpenGroupInboxRecipient: 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 (isLegacyClosedGroupRecipient) {
val members = DatabaseComponent.get(context).groupDatabase()
.getGroupMemberAddresses(address.toGroupString(), true)
.sorted()
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 7b92e505c6..2e7c027b1e 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/contacts/ContactSelectionListLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt
index 0db2ec8962..8b9dedb6ba 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt
@@ -52,7 +52,7 @@ class ContactSelectionListLoader(context: Context, val mode: Int, val filter: St
private fun getClosedGroups(contacts: List): List {
return getItems(contacts, context.getString(R.string.conversationsGroups)) {
- it.address.isClosedGroup
+ it.address.isLegacyClosedGroup || it.address.isClosedGroupV2
}
}
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 1c6442824d..c9aca24f36 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt
@@ -13,7 +13,6 @@ import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import com.google.android.material.tabs.TabLayoutMediator
import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewConversationActionBarBinding
@@ -31,6 +30,8 @@ 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
class ConversationActionBarView @JvmOverloads constructor(
@@ -42,6 +43,7 @@ class ConversationActionBarView @JvmOverloads constructor(
@Inject lateinit var lokiApiDb: LokiAPIDatabase
@Inject lateinit var groupDb: GroupDatabase
+ @Inject lateinit var storage: Storage
var delegate: ConversationActionBarDelegate? = null
@@ -51,6 +53,9 @@ class ConversationActionBarView @JvmOverloads constructor(
}
}
+ val profilePictureView
+ get() = binding.profilePictureView
+
init {
var previousState: Int
var currentState = 0
@@ -80,7 +85,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.isClosedGroupV2Recipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size
).let { LayoutParams(it, it) }
update(recipient, openGroup, config)
}
@@ -141,7 +146,11 @@ class ConversationActionBarView @JvmOverloads constructor(
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.isClosedGroupV2Recipient) {
+ 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 e086c95924..489d82a390 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
@@ -5,6 +5,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.session.libsession.messaging.MessagingModuleConfiguration
+import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
import org.session.libsession.messaging.sending_receiving.MessageSender
@@ -43,7 +44,11 @@ class DisappearingMessages @Inject constructor(
messageExpirationManager.insertExpirationTimerMessage(message)
MessageSender.send(message, address)
- ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
+ if (address.isClosedGroupV2) {
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(Destination.from(address))
+ } else {
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
+ }
}
fun showFollowSettingDialog(context: Context, message: MessageRecord) = context.showSessionDialog {
@@ -58,9 +63,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.isClosedGroupV2Recipient)
}
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 32e20b73d9..36b0710f88 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
@@ -59,16 +59,28 @@ class DisappearingMessagesViewModel(
init {
viewModelScope.launch {
val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode?.maybeConvertToLegacy(isNewConfigEnabled) ?: ExpiryMode.NONE
- val recipient = threadDb.getRecipientForThreadId(threadId)
- val groupRecord = recipient?.takeIf { it.isClosedGroupRecipient }
+ val recipient = threadDb.getRecipientForThreadId(threadId) ?: return@launch
+ val groupRecord = recipient.takeIf { it.isLegacyClosedGroupRecipient }
?.run { groupDb.getGroup(address.toGroupString()).orNull() }
+ val isAdmin = when {
+ recipient.isClosedGroupV2Recipient -> {
+ // Handle the new closed group functionality
+ storage.getMembers(recipient.address.serialize()).any { it.sessionId == textSecurePreferences.getLocalNumber() && it.admin }
+ }
+ recipient.isLegacyClosedGroupRecipient -> {
+ // Handle as legacy group
+ groupRecord?.admins?.any{ it.serialize() == textSecurePreferences.getLocalNumber() } == true
+ }
+ else -> !recipient.isGroupRecipient
+ }
+
_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
)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivity.kt
new file mode 100644
index 0000000000..49ce126737
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivity.kt
@@ -0,0 +1,59 @@
+package org.thoughtcrime.securesms.conversation.settings
+
+import android.os.Bundle
+import android.view.View
+import dagger.hilt.android.AndroidEntryPoint
+import network.loki.messenger.databinding.ActivityConversationNotificationSettingsBinding
+import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
+import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
+import org.thoughtcrime.securesms.database.RecipientDatabase
+import org.thoughtcrime.securesms.database.ThreadDatabase
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class ConversationNotificationSettingsActivity: PassphraseRequiredActionBarActivity(), View.OnClickListener {
+
+ lateinit var binding: ActivityConversationNotificationSettingsBinding
+ @Inject lateinit var threadDb: ThreadDatabase
+ @Inject lateinit var recipientDb: RecipientDatabase
+ val recipient by lazy {
+ if (threadId == -1L) null
+ else threadDb.getRecipientForThreadId(threadId)
+ }
+ var threadId: Long = -1
+
+ override fun onClick(v: View?) {
+ val recipient = recipient ?: return
+ if (v === binding.notifyAll) {
+ // set notify type
+ recipientDb.setNotifyType(recipient, RecipientDatabase.NOTIFY_TYPE_ALL)
+ } else if (v === binding.notifyMentions) {
+ recipientDb.setNotifyType(recipient, RecipientDatabase.NOTIFY_TYPE_MENTIONS)
+ } else if (v === binding.notifyMute) {
+ recipientDb.setNotifyType(recipient, RecipientDatabase.NOTIFY_TYPE_NONE)
+ }
+ updateValues()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
+ super.onCreate(savedInstanceState, ready)
+ binding = ActivityConversationNotificationSettingsBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ threadId = intent.getLongExtra(ConversationActivityV2.THREAD_ID, -1L)
+ if (threadId == -1L) finish()
+ updateValues()
+ with (binding) {
+ notifyAll.setOnClickListener(this@ConversationNotificationSettingsActivity)
+ notifyMentions.setOnClickListener(this@ConversationNotificationSettingsActivity)
+ notifyMute.setOnClickListener(this@ConversationNotificationSettingsActivity)
+ }
+ }
+
+ private fun updateValues() {
+ val notifyType = recipient?.notifyType ?: return
+ binding.notifyAllButton.isSelected = notifyType == RecipientDatabase.NOTIFY_TYPE_ALL
+ binding.notifyMentionsButton.isSelected = notifyType == RecipientDatabase.NOTIFY_TYPE_MENTIONS
+ binding.notifyMuteButton.isSelected = notifyType == RecipientDatabase.NOTIFY_TYPE_NONE
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivityContract.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivityContract.kt
new file mode 100644
index 0000000000..d55d0b2495
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationNotificationSettingsActivityContract.kt
@@ -0,0 +1,16 @@
+package org.thoughtcrime.securesms.conversation.settings
+
+import android.content.Context
+import android.content.Intent
+import androidx.activity.result.contract.ActivityResultContract
+import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
+
+class ConversationNotificationSettingsActivityContract: ActivityResultContract() {
+
+ override fun createIntent(context: Context, input: Long): Intent =
+ Intent(context, ConversationNotificationSettingsActivity::class.java).apply {
+ putExtra(ConversationActivityV2.THREAD_ID, input)
+ }
+
+ override fun parseResult(resultCode: Int, intent: Intent?) { /* do nothing */ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivity.kt
new file mode 100644
index 0000000000..a44b3fc55d
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivity.kt
@@ -0,0 +1,269 @@
+package org.thoughtcrime.securesms.conversation.settings
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import androidx.activity.viewModels
+import androidx.core.view.isVisible
+import androidx.lifecycle.lifecycleScope
+import com.squareup.phrase.Phrase
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+import network.loki.messenger.R
+import network.loki.messenger.databinding.ActivityConversationSettingsBinding
+import org.session.libsession.messaging.sending_receiving.MessageSender
+import org.session.libsession.utilities.Address
+import org.session.libsession.utilities.GroupUtil
+import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY
+import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
+import org.session.libsession.utilities.TextSecurePreferences
+import org.session.libsignal.utilities.Log
+import org.session.libsignal.utilities.toHexString
+import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
+import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
+import org.thoughtcrime.securesms.database.GroupDatabase
+import org.thoughtcrime.securesms.database.LokiThreadDatabase
+import org.thoughtcrime.securesms.database.ThreadDatabase
+import org.thoughtcrime.securesms.groups.EditGroupActivity
+import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity
+import org.thoughtcrime.securesms.media.MediaOverviewActivity
+import org.thoughtcrime.securesms.showSessionDialog
+import java.io.IOException
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class ConversationSettingsActivity: PassphraseRequiredActionBarActivity(), View.OnClickListener {
+
+ companion object {
+ // used to trigger displaying conversation search in calling parent activity
+ const val RESULT_SEARCH = 22
+ }
+
+ lateinit var binding: ActivityConversationSettingsBinding
+
+ private val groupOptions: List
+ get() = with(binding) {
+ listOf(
+ groupMembers,
+ groupMembersDivider.root,
+ editGroup,
+ editGroupDivider.root,
+ leaveGroup,
+ leaveGroupDivider.root
+ )
+ }
+
+ @Inject lateinit var threadDb: ThreadDatabase
+ @Inject lateinit var groupDb: GroupDatabase
+ @Inject lateinit var lokiThreadDb: LokiThreadDatabase
+ @Inject lateinit var viewModelFactory: ConversationSettingsViewModel.AssistedFactory
+ val viewModel: ConversationSettingsViewModel by viewModels {
+ val threadId = intent.getLongExtra(ConversationActivityV2.THREAD_ID, -1L)
+ if (threadId == -1L) {
+ finish()
+ }
+ viewModelFactory.create(threadId)
+ }
+
+ private val notificationActivityCallback = registerForActivityResult(ConversationNotificationSettingsActivityContract()) {
+ updateRecipientDisplay()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
+ super.onCreate(savedInstanceState, ready)
+ binding = ActivityConversationSettingsBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ updateRecipientDisplay()
+ binding.searchConversation.setOnClickListener(this)
+ binding.clearMessages.setOnClickListener(this)
+ binding.allMedia.setOnClickListener(this)
+ binding.pinConversation.setOnClickListener(this)
+ binding.notificationSettings.setOnClickListener(this)
+ binding.editGroup.setOnClickListener(this)
+ binding.leaveGroup.setOnClickListener(this)
+ binding.back.setOnClickListener(this)
+ binding.autoDownloadMediaSwitch.setOnCheckedChangeListener { _, isChecked ->
+ viewModel.setAutoDownloadAttachments(isChecked)
+ updateRecipientDisplay()
+ }
+ }
+
+ private fun updateRecipientDisplay() {
+ val recipient = viewModel.recipient ?: return
+ // Setup profile image
+ binding.profilePictureView.root.update(recipient)
+ // Setup name
+ binding.conversationName.text = when {
+ recipient.isLocalNumber -> getString(R.string.noteToSelf)
+ else -> recipient.toShortString()
+ }
+ // Setup group description (if group)
+ binding.conversationSubtitle.isVisible = recipient.isClosedGroupV2Recipient.apply {
+ binding.conversationSubtitle.text = viewModel.closedGroupInfo()?.description
+ }
+
+ // Toggle group-specific settings
+ val areGroupOptionsVisible = recipient.isClosedGroupV2Recipient || recipient.isLegacyClosedGroupRecipient
+ groupOptions.forEach { v ->
+ v.isVisible = areGroupOptionsVisible
+ }
+
+ // Group admin settings
+ val isUserGroupAdmin = areGroupOptionsVisible && viewModel.isUserGroupAdmin()
+ with (binding) {
+ groupMembersDivider.root.isVisible = areGroupOptionsVisible && !isUserGroupAdmin
+ groupMembers.isVisible = !isUserGroupAdmin
+ adminControlsGroup.isVisible = isUserGroupAdmin
+ deleteGroup.isVisible = isUserGroupAdmin
+ clearMessages.isVisible = isUserGroupAdmin
+ clearMessagesDivider.root.isVisible = isUserGroupAdmin
+ leaveGroupDivider.root.isVisible = isUserGroupAdmin
+ }
+
+ // Set pinned state
+ binding.pinConversation.setText(
+ if (viewModel.isPinned()) R.string.pinUnpinConversation
+ else R.string.pinConversation
+ )
+
+ // Set auto-download state
+ val trusted = viewModel.autoDownloadAttachments()
+ binding.autoDownloadMediaSwitch.isChecked = trusted
+
+ // Set notification type
+ val notifyTypes = resources.getStringArray(R.array.notify_types)
+ val summary = notifyTypes.getOrNull(recipient.notifyType)
+ binding.notificationsValue.text = summary
+ }
+
+ override fun onClick(v: View?) {
+ val threadRecipient = viewModel.recipient ?: return
+ when {
+ v === binding.searchConversation -> {
+ setResult(RESULT_SEARCH)
+ finish()
+ }
+ v === binding.allMedia -> {
+ startActivity(MediaOverviewActivity.createIntent(this, threadRecipient.address))
+ }
+ v === binding.pinConversation -> {
+ viewModel.togglePin().invokeOnCompletion { e ->
+ if (e != null) {
+ // something happened
+ Log.e("ConversationSettings", "Failed to toggle pin on thread", e)
+ } else {
+ updateRecipientDisplay()
+ }
+ }
+ }
+ v === binding.notificationSettings -> {
+ notificationActivityCallback.launch(viewModel.threadId)
+ }
+ v === binding.back -> onBackPressed()
+ v === binding.clearMessages -> {
+
+ showSessionDialog {
+ title(R.string.clearMessages)
+ text(Phrase.from(this@ConversationSettingsActivity, R.string.clearMessagesChatDescription)
+ .put(NAME_KEY, threadRecipient.name)
+ .format())
+ dangerButton(
+ R.string.clear,
+ R.string.clear) {
+ viewModel.clearMessages(false)
+ }
+ cancelButton()
+ }
+ }
+ v === binding.leaveGroup -> {
+
+ if (threadRecipient.isLegacyClosedGroupRecipient) {
+ // Send a leave group message if this is an active closed group
+ val groupString = threadRecipient.address.toGroupString()
+ val ourId = TextSecurePreferences.getLocalNumber(this)!!
+ if (groupDb.isActive(groupString)) {
+ showSessionDialog {
+
+ title(R.string.groupLeave)
+
+ val name = viewModel.recipient!!.name!!
+ val textWithArgs = if (groupDb.getGroup(groupString).get().admins.map(Address::serialize).contains(ourId)) {
+ Phrase.from(context, R.string.groupLeaveDescriptionAdmin)
+ .put(GROUP_NAME_KEY, name)
+ .format()
+ } else {
+ Phrase.from(context, R.string.groupLeaveDescription)
+ .put(GROUP_NAME_KEY, name)
+ .format()
+ }
+ text(textWithArgs)
+ dangerButton(
+ R.string.groupLeave,
+ R.string.groupLeave
+ ) {
+ lifecycleScope.launch {
+ GroupUtil.doubleDecodeGroupID(threadRecipient.address.toString())
+ .toHexString()
+ .let { MessageSender.explicitLeave(it, true, deleteThread = true) }
+ finish()
+ }
+ }
+ cancelButton()
+ }
+ try {
+
+ } catch (e: IOException) {
+ Log.e("Loki", e)
+ }
+ }
+ } else if (threadRecipient.isClosedGroupV2Recipient) {
+ val groupInfo = viewModel.closedGroupInfo()
+ showSessionDialog {
+
+ title(R.string.groupLeave)
+
+ val name = viewModel.recipient!!.name!!
+ val textWithArgs = if (groupInfo?.isUserAdmin == true) {
+ Phrase.from(context, R.string.groupLeaveDescription)
+ .put(GROUP_NAME_KEY, name)
+ .format()
+ } else {
+ Phrase.from(context, R.string.groupLeaveDescription)
+ .put(GROUP_NAME_KEY, name)
+ .format()
+ }
+ text(textWithArgs)
+ dangerButton(
+ R.string.groupLeave,
+ R.string.groupLeave
+ ) {
+ lifecycleScope.launch {
+ viewModel.leaveGroup()
+ finish()
+ }
+ }
+ cancelButton()
+ }
+ }
+ }
+ v === binding.editGroup -> {
+ val recipient = viewModel.recipient ?: return
+
+ val intent = when {
+ recipient.isLegacyClosedGroupRecipient -> Intent(this, EditLegacyGroupActivity::class.java).apply {
+ val groupID: String = recipient.address.toGroupString()
+ putExtra(EditLegacyGroupActivity.groupIDKey, groupID)
+ }
+
+ recipient.isClosedGroupV2Recipient -> EditGroupActivity.createIntent(
+ context = this,
+ groupSessionId = recipient.address.serialize()
+ )
+
+ else -> return
+ }
+ startActivity(intent)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivityContract.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivityContract.kt
new file mode 100644
index 0000000000..a79d94b3ae
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsActivityContract.kt
@@ -0,0 +1,24 @@
+package org.thoughtcrime.securesms.conversation.settings
+
+import android.content.Context
+import android.content.Intent
+import androidx.activity.result.contract.ActivityResultContract
+import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
+
+sealed class ConversationSettingsActivityResult {
+ object Finished: ConversationSettingsActivityResult()
+ object SearchConversation: ConversationSettingsActivityResult()
+}
+
+class ConversationSettingsActivityContract: ActivityResultContract() {
+
+ override fun createIntent(context: Context, input: Long) = Intent(context, ConversationSettingsActivity::class.java).apply {
+ putExtra(ConversationActivityV2.THREAD_ID, input ?: -1L)
+ }
+
+ override fun parseResult(resultCode: Int, intent: Intent?): ConversationSettingsActivityResult =
+ when (resultCode) {
+ ConversationSettingsActivity.RESULT_SEARCH -> ConversationSettingsActivityResult.SearchConversation
+ else -> ConversationSettingsActivityResult.Finished
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsViewModel.kt
new file mode 100644
index 0000000000..17411b2211
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/settings/ConversationSettingsViewModel.kt
@@ -0,0 +1,104 @@
+package org.thoughtcrime.securesms.conversation.settings
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import network.loki.messenger.libsession_util.util.GroupDisplayInfo
+import org.session.libsession.database.StorageProtocol
+import org.session.libsession.messaging.jobs.JobQueue
+import org.session.libsession.messaging.jobs.LibSessionGroupLeavingJob
+import org.session.libsession.utilities.Address
+import org.session.libsession.utilities.TextSecurePreferences
+import org.session.libsignal.utilities.AccountId
+
+class ConversationSettingsViewModel(
+ val threadId: Long,
+ private val storage: StorageProtocol,
+ private val prefs: TextSecurePreferences
+): ViewModel() {
+
+ val recipient get() = storage.getRecipientForThread(threadId)
+
+ fun isPinned() = storage.isPinned(threadId)
+
+ fun togglePin() = viewModelScope.launch {
+ val isPinned = storage.isPinned(threadId)
+ storage.setPinned(threadId, !isPinned)
+ }
+
+ fun autoDownloadAttachments() = recipient?.let { recipient -> storage.shouldAutoDownloadAttachments(recipient) } ?: false
+
+ fun setAutoDownloadAttachments(shouldDownload: Boolean) {
+ recipient?.let { recipient -> storage.setAutoDownloadAttachments(recipient, shouldDownload) }
+ }
+
+ fun isUserGroupAdmin(): Boolean = recipient?.let { recipient ->
+ when {
+ recipient.isLegacyClosedGroupRecipient -> {
+ val localUserAddress = prefs.getLocalNumber() ?: return@let false
+ val group = storage.getGroup(recipient.address.toGroupString())
+ group?.admins?.contains(Address.fromSerialized(localUserAddress)) ?: false // this will have to be replaced for new closed groups
+ }
+ recipient.isClosedGroupV2Recipient -> {
+ val group = storage.getLibSessionClosedGroup(recipient.address.serialize()) ?: return@let false
+ group.adminKey != null
+ }
+ else -> false
+ }
+ } ?: false
+
+ fun clearMessages(forAll: Boolean) {
+ if (forAll && !isUserGroupAdmin()) return
+
+ if (!forAll) {
+ viewModelScope.launch {
+ storage.clearMessages(threadId)
+ }
+ } else {
+ // do a send message here and on success do a clear messages
+ viewModelScope.launch {
+ storage.clearMessages(threadId)
+ }
+ }
+ }
+
+ fun closedGroupInfo(): GroupDisplayInfo? = recipient
+ ?.address
+ ?.takeIf { it.isClosedGroupV2 }
+ ?.serialize()
+ ?.let(storage::getClosedGroupDisplayInfo)
+
+ // Assume that user has verified they don't want to add a new admin etc
+ suspend fun leaveGroup() {
+ val recipient = recipient ?: return
+ return withContext(Dispatchers.IO) {
+ val groupLeave = LibSessionGroupLeavingJob(
+ AccountId(recipient.address.serialize()),
+ true
+ )
+ JobQueue.shared.add(groupLeave)
+ }
+ }
+
+ // DI-related
+ @dagger.assisted.AssistedFactory
+ interface AssistedFactory {
+ fun create(threadId: Long): Factory
+ }
+ class Factory @AssistedInject constructor(
+ @Assisted private val threadId: Long,
+ private val storage: StorageProtocol,
+ private val prefs: TextSecurePreferences
+ ) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return ConversationSettingsViewModel(threadId, storage, prefs) as T
+ }
+ }
+
+}
\ No newline at end of file
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 8dffb1fd9b..f01eeaea2d 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 bc298c5bd3..bae328c78e 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 0a40c6ee39..2316c775cb 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
@@ -37,6 +37,8 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
@@ -142,8 +144,10 @@ private fun EnterAccountId(
SessionOutlinedTextField(
text = state.newMessageIdOrOns,
modifier = Modifier
- .padding(horizontal = LocalDimensions.current.spacing),
- contentDescription = "Session id input box",
+ .padding(horizontal = LocalDimensions.current.spacing)
+ .semantics {
+ contentDescription = "Session id input box"
+ },
placeholder = stringResource(R.string.accountIdOrOnsEnter),
onChange = callbacks::onChange,
onContinue = callbacks::onContinue,
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 51ca3bf685..7f0d67ace4 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
@@ -59,14 +59,23 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.collect
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.onCompletion
import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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.database.StorageProtocol
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.messages.applyExpiryMode
@@ -80,7 +89,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
@@ -100,6 +108,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
@@ -111,6 +120,8 @@ import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey
import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity
+import org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivityContract
+import org.thoughtcrime.securesms.conversation.settings.ConversationSettingsActivityResult
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP
@@ -147,7 +158,6 @@ 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
@@ -155,6 +165,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.giph.ui.GiphyActivity
import org.thoughtcrime.securesms.groups.OpenGroupManager
+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
@@ -175,7 +186,6 @@ import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.OpenURLAlertDialog
import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme
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
@@ -211,7 +221,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener,
SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks, ConversationActionBarDelegate,
OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback,
- ConversationMenuHelper.ConversationMenuListener {
+ ConversationMenuHelper.ConversationMenuListener, View.OnClickListener {
private lateinit var binding: ActivityConversationV2Binding
@@ -224,7 +234,7 @@ 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
@@ -236,6 +246,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
}
+ private val conversationSettingsCallback = registerForActivityResult(ConversationSettingsActivityContract()) { result ->
+ if (result is ConversationSettingsActivityResult.SearchConversation) {
+ // open search
+ binding?.toolbar?.menu?.findItem(R.id.menu_search)?.expandActionView()
+ }
+ }
+
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
private val linkPreviewViewModel: LinkPreviewViewModel by lazy {
ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository()))
@@ -269,7 +286,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
private val viewModel: ConversationViewModel by viewModels {
- viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
+ viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair())
}
private var actionMode: ActionMode? = null
private var unreadCount = Int.MAX_VALUE
@@ -404,6 +421,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
@@ -478,11 +496,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
updatePlaceholder()
setUpBlockedBanner()
binding.searchBottomBar.setEventListener(this)
+ binding.toolbarContent.profilePictureView.setOnClickListener(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)
@@ -506,6 +522,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpSearchResultObserver()
scrollToFirstUnreadMessageIfNeeded()
setUpOutdatedClientBanner()
+ setUpLegacyGroupBanner()
if (author != null && messageTimestamp >= 0 && targetPosition >= 0) {
binding.conversationRecyclerView.scrollToPosition(targetPosition)
@@ -574,6 +591,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
}
+ override fun finish() {
+ super.finish()
+ }
+
override fun onPause() {
super.onPause()
ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(-1)
@@ -809,13 +830,31 @@ 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)
.put(NAME_KEY, legacyRecipient!!.name)
.format()
- binding?.outdatedBannerTextView?.text = txt
+ binding.outdatedBannerTextView.text = txt
+ }
+ }
+
+ private fun setUpLegacyGroupBanner() {
+ val shouldDisplayBanner = viewModel.recipient?.isLegacyClosedGroupRecipient ?: return
+
+ with(binding) {
+ outdatedGroupBanner.isVisible = shouldDisplayBanner
+ outdatedGroupBanner.setOnClickListener {
+ showSessionDialog {
+ title(R.string.urlOpenBrowser)
+ text(R.string.urlOpenDescription)
+ cancelButton()
+ dangerButton(R.string.open) {
+ // open the URL (tbc)
+ }
+ }
+ }
}
}
@@ -840,21 +879,45 @@ 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, just go back
+ // 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()
}
}
}
+
+ // 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
+ }
+ }
+ }
+ }
}
private fun scrollToFirstUnreadMessageIfNeeded(isFirstLoad: Boolean = false, shouldHighlight: Boolean = false): Int {
@@ -914,11 +977,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (threadRecipient.isContactRecipient) {
binding.blockedBanner.isVisible = threadRecipient.isBlocked
}
- setUpMessageRequestsBar()
invalidateOptionsMenu()
updateSendAfterApprovalText()
- showOrHideInputIfNeeded()
-
maybeUpdateToolbar(threadRecipient)
}
}
@@ -931,49 +991,37 @@ 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 {
viewModel.declineMessageRequest()
- lifecycleScope.launch(Dispatchers.IO) {
- ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2)
+ }
+
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.uiState
+ .map { it.messageRequestState }
+ .distinctUntilChanged()
+ .collectLatest { state ->
+ binding.messageRequestBar.isVisible = state != MessageRequestUiState.Invisible
+
+ if (state is MessageRequestUiState.Visible) {
+ binding.sendAcceptsTextView.setText(state.acceptButtonText)
+ binding.messageRequestBlock.isVisible = state.showBlockButton
+ binding.declineMessageRequestButton.setText(state.declineButtonText)
+ }
+ }
}
- finish()
}
}
- 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()) {
@@ -1174,20 +1222,35 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} ?: false
}
+ override fun onClick(v: View?) {
+ if (v === binding?.toolbarContent?.profilePictureView) {
+ // open conversation settings
+ conversationSettingsCallback.launch(viewModel.threadId)
+ }
+ }
+
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.isClosedGroupV2Recipient && invitingAdmin != null) {
+ invitingAdmin.getSearchName()
+ } else {
+ recipient.name
+ }
+
showSessionDialog {
title(R.string.block)
text(
Phrase.from(context, R.string.blockDescription)
- .put(NAME_KEY, recipient.name)
+ .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.name).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) {
@@ -1218,8 +1281,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.isLegacyClosedGroupRecipient) {
groupDb.getGroup(thread.address.toGroupString()).orNull()?.run { if (!isActive) return }
}
Intent(this, DisappearingMessagesActivity::class.java)
@@ -1687,19 +1751,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)
@@ -1743,7 +1798,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
@@ -2088,7 +2143,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
cancelButton { endActionMode() }
}
// Otherwise if this is a 1-on-1 conversation we may decided to delete just for ourselves or delete for everyone
- } else if (allSentByCurrentUser && allHasHash) {
+ } else if ((allSentByCurrentUser || viewModel.isClosedGroupAdmin) && allHasHash) {
val bottomSheet = DeleteOptionsBottomSheet()
bottomSheet.recipient = recipient
bottomSheet.onDeleteForMeTapped = {
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 b0a541a9e8..44c31b4ec9 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
@@ -1,51 +1,60 @@
package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
+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.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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.GroupMember
import org.session.libsession.database.MessageDataProvider
+import org.session.libsession.database.StorageProtocol
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.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
-import org.thoughtcrime.securesms.database.MmsDatabase
+import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.audio.AudioSlidePlayer
-import org.thoughtcrime.securesms.database.Storage
+import org.thoughtcrime.securesms.database.GroupDatabase
+import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository
+import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import java.util.UUID
class ConversationViewModel(
val threadId: Long,
val edKeyPair: KeyPair?,
private val repository: ConversationRepository,
- private val storage: Storage,
+ private val storage: StorageProtocol,
private val messageDataProvider: MessageDataProvider,
- database: MmsDatabase,
+ private val groupDb: GroupDatabase,
+ private val threadDb: ThreadDatabase,
+ private val appContext: Context,
) : 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 var _recipient: RetrieveOnce = RetrieveOnce {
repository.maybeGetRecipientForThreadId(threadId)
@@ -65,12 +74,39 @@ class ConversationViewModel(
}
}
+ /**
+ * 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.isClosedGroupV2Recipient) return null
+
+ return repository.getInvitingAdmin(threadId)
+ }
+
private var _openGroup: RetrieveOnce = RetrieveOnce {
storage.getOpenGroup(threadId)
}
val openGroup: OpenGroup?
get() = _openGroup.value
+ private val closedGroupMembers: List
+ get() {
+ val recipient = recipient ?: return emptyList()
+ if (!recipient.isClosedGroupV2Recipient) return emptyList()
+ return storage.getMembers(recipient.address.serialize())
+ }
+
+ val isClosedGroupAdmin: Boolean
+ get() {
+ val recipient = recipient ?: return false
+ return !recipient.isClosedGroupV2Recipient ||
+ (closedGroupMembers.firstOrNull { it.sessionId == storage.getUserPublicKey() }?.admin ?: false)
+ }
+
val serverCapabilities: List
get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf()
@@ -83,7 +119,7 @@ class ConversationViewModel(
val isMessageRequestThread : Boolean
get() {
val recipient = recipient ?: return false
- return !recipient.isLocalNumber && !recipient.isGroupRecipient && !recipient.isApproved
+ return !recipient.isLocalNumber && !recipient.isLegacyClosedGroupRecipient && !recipient.isCommunityRecipient && !recipient.isApproved
}
val canReactToMessages: Boolean
@@ -97,16 +133,99 @@ 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),
+ )
}
}
}
}
+ /**
+ * 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?.isClosedGroupV2Recipient == true -> !repository.isKicked(recipient)
+ recipient?.isLegacyClosedGroupRecipient == 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.isClosedGroupV2Recipient) &&
+
+ // 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.isGroupRecipient) {
+ R.string.messageRequestGroupInviteDescription
+ } else {
+ R.string.messageRequestsAcceptDescription
+ },
+ // You can block a 1to1 conversation, or a normal groups v2 conversation
+ showBlockButton = recipient.is1on1 || recipient.isClosedGroupV2Recipient,
+ declineButtonText = if (recipient.isClosedGroupV2Recipient) {
+ R.string.delete
+ } else {
+ R.string.decline
+ }
+ )
+ }
+
+ return MessageRequestUiState.Invisible
+ }
+
override fun onCleared() {
super.onCleared()
@@ -135,16 +254,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.isClosedGroupV2Recipient) {
+ 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)
}
}
@@ -167,11 +287,6 @@ class ConversationViewModel(
AudioSlidePlayer.getInstance()?.takeIf { it.audioSlide == audioSlide }?.stop()
}
- fun setRecipientApproved() {
- val recipient = recipient ?: return Log.w("Loki", "Recipient was null for set approved action")
- repository.setApproved(recipient, true)
- }
-
fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch {
val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.")
stopPlayingAudioMessage(message)
@@ -221,19 +336,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)
+ }
+
+ withContext(Dispatchers.IO) {
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(appContext)
}
}
.onFailure {
showMessage("Couldn't accept message request due to error: $it")
+
+ _uiState.update { state ->
+ state.copy(messageRequestState = currentState)
+ }
}
}
fun declineMessageRequest() {
- repository.declineMessageRequest(threadId)
+ repository.declineMessageRequest(threadId, recipient!!)
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(appContext)
+ _uiState.update { it.copy(shouldExit = true) }
}
private fun showMessage(message: String) {
@@ -278,6 +410,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)
+ }
+ }
+
@dagger.assisted.AssistedFactory
interface AssistedFactory {
fun create(threadId: Long, edKeyPair: KeyPair?): Factory
@@ -288,9 +439,12 @@ class ConversationViewModel(
@Assisted private val threadId: Long,
@Assisted private val edKeyPair: KeyPair?,
private val repository: ConversationRepository,
- private val storage: Storage,
- private val mmsDatabase: MmsDatabase,
+ private val storage: StorageProtocol,
private val messageDataProvider: MessageDataProvider,
+ private val groupDb: GroupDatabase,
+ private val threadDb: ThreadDatabase,
+ @ApplicationContext
+ private val context: Context,
) : ViewModelProvider.Factory {
override fun create(modelClass: Class): T {
@@ -300,7 +454,9 @@ class ConversationViewModel(
repository = repository,
storage = storage,
messageDataProvider = messageDataProvider,
- database = mmsDatabase
+ groupDb = groupDb,
+ threadDb = threadDb,
+ appContext = context,
) as T
}
}
@@ -310,10 +466,24 @@ data class UiMessage(val id: Long, val message: String)
data class ConversationUiState(
val uiMessages: List = emptyList(),
- val isMessageRequestAccepted: Boolean? = null,
- val conversationExists: Boolean
+ val messageRequestState: MessageRequestUiState = MessageRequestUiState.Invisible,
+ val shouldExit: Boolean = false,
+ val showInput: Boolean = true,
+ val enableInputMediaControls: Boolean = true,
)
+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
index 58c5536248..404f091922 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt
@@ -56,11 +56,14 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen
if (!this::recipient.isInitialized) {
return dismiss()
}
- if (!recipient.isGroupRecipient && !contact.isNullOrEmpty()) {
+ if (recipient.isLocalNumber) {
binding.deleteForEveryoneTextView.text =
- resources.getString(R.string.clearMessagesForEveryone, contact)
+ getString(R.string.clearMessagesForMe)
+ } else if (!recipient.isGroupRecipient && !contact.isNullOrEmpty()) {
+ binding.deleteForEveryoneTextView.text =
+ resources.getString(R.string.clearMessagesForEveryone)
}
- binding.deleteForEveryoneTextView.isVisible = !recipient.isClosedGroupRecipient
+ binding.deleteForEveryoneTextView.isVisible = !recipient.isLegacyClosedGroupRecipient
binding.deleteForMeTextView.setOnClickListener(this)
binding.deleteForEveryoneTextView.setOnClickListener(this)
binding.cancelTextView.setOnClickListener(this)
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 a830543ec1..a609f7f5b9 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
@@ -54,6 +54,7 @@ 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
@@ -84,7 +85,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 d3e1de9912..7ee19c53fd 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
@@ -10,49 +10,58 @@ import androidx.fragment.app.DialogFragment
import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
+import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.contacts.Contact
-import org.session.libsession.messaging.jobs.AttachmentDownloadJob
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
+ val threadId = storage.getThreadId(threadRecipient) ?: run {
+ dismiss()
+ return@createSessionDialog
+ }
+ val displayName = when {
+ threadRecipient.isCommunityRecipient -> storage.getOpenGroup(threadId)?.name ?: "UNKNOWN"
+ threadRecipient.isLegacyClosedGroupRecipient -> storage.getGroup(threadRecipient.address.toGroupString())?.title ?: "UNKNOWN"
+ threadRecipient.isClosedGroupV2Recipient -> threadRecipient.name ?: "UNKNOWN"
+ else -> storage.getContactWithAccountID(threadRecipient.address.serialize())?.displayName(Contact.ContactContext.REGULAR) ?: "UNKNOWN"
+ }
title(getString(R.string.attachmentsAutoDownloadModalTitle))
val explanation = Phrase.from(context, R.string.attachmentsAutoDownloadModalDescription)
- .put(CONVERSATION_NAME_KEY, recipient.name)
+ .put(CONVERSATION_NAME_KEY, displayName)
.format()
val spannable = SpannableStringBuilder(explanation)
-
- val startIndex = explanation.indexOf(name)
- spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ val startIndex = explanation.indexOf(displayName)
+ spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + displayName.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
text(spannable)
- 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/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt
index cd911b2ace..fe86f8d382 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 d4068a3e6c..d4489dae3c 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.isLegacyClosedGroupRecipient -> {
groupDatabase.getGroupMemberAddresses(recipient.address.toGroupString(), false)
.map { it.serialize() }
}
+ recipient.isClosedGroupV2Recipient -> {
+ 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 720310fa5e..90335dd44e 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,10 +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.AccountId
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
@@ -37,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
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 090bf47574..3778eecc52 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
@@ -34,8 +34,8 @@ 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.DatabaseComponent
-import org.thoughtcrime.securesms.groups.EditClosedGroupActivity
-import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey
+import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity
+import org.thoughtcrime.securesms.groups.EditLegacyGroupActivity.Companion.groupIDKey
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.service.WebRtcCallService
import org.thoughtcrime.securesms.showMuteDialog
@@ -56,7 +56,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.isLegacyClosedGroupRecipient || thread.isLocalNumber)) {
inflater.inflate(R.menu.menu_conversation_expiration, menu)
}
// One-on-one chat menu allows copying the account id
@@ -72,7 +72,7 @@ object ConversationMenuHelper {
}
}
// Closed group menu (options that should only be present in closed groups)
- if (thread.isClosedGroupRecipient) {
+ if (thread.isLegacyClosedGroupRecipient) {
inflater.inflate(R.menu.menu_conversation_closed_group, menu)
}
// Open group menu
@@ -258,15 +258,15 @@ object ConversationMenuHelper {
}
private fun editClosedGroup(context: Context, thread: Recipient) {
- if (!thread.isClosedGroupRecipient) { return }
- val intent = Intent(context, EditClosedGroupActivity::class.java)
+ if (!thread.isLegacyClosedGroupRecipient) { return }
+ 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 }
+ if (!thread.isLegacyClosedGroupRecipient) { return }
val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull()
val admins = group.admins
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 ab8498acf4..1c7e19ad95 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
@@ -16,6 +16,8 @@ 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.getColorFromAttr
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages
import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode
@@ -45,6 +47,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 {
@@ -54,7 +57,7 @@ class ControlMessageView : LinearLayout {
val threadRecipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(message.threadId)
- if (threadRecipient?.isClosedGroupRecipient == true) {
+ if (threadRecipient?.isClosedGroupV2Recipient == true) {
expirationTimerView.setTimerIcon()
} else {
expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
@@ -98,6 +101,12 @@ class ControlMessageView : LinearLayout {
binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
}
}
+ 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 0000000000..72e37b5ddd
--- /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 7d1dc625f6..0000000000
--- 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 d62cc532c4..4c274f5ed1 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,8 +70,9 @@ class VisibleMessageContentView : ConstraintLayout {
binding.contentParent.mainColor = color
binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius)
- val onlyBodyMessage = message is SmsMessageRecord
- 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 +85,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 +99,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 +139,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 +147,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 +160,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 +209,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 +261,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 205f28a078..3b74cbf4fb 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
@@ -259,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/crypto/IdentityKeyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java
index 4db46a3abc..e2fe41b625 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 org.session.libsignal.utilities.Base64;
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 class IdentityKeyUtil {
}
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 19a511bfd6..71aca6c948 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt
@@ -4,6 +4,9 @@ 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
class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) {
@@ -20,6 +23,11 @@ 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 KEYS_VARIANT = SharedConfigMessage.Kind.ENCRYPTION_KEYS.name
+ val INFO_VARIANT = SharedConfigMessage.Kind.CLOSED_GROUP_INFO.name
+ val MEMBER_VARIANT = SharedConfigMessage.Kind.CLOSED_GROUP_MEMBERS.name
}
fun storeConfig(variant: String, publicKey: String, data: ByteArray, timestamp: Long) {
@@ -33,6 +41,49 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co
db.insertOrUpdate(TABLE_NAME, contentValues, VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey))
}
+ 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: String, publicKey: String): ByteArray? {
val db = readableDatabase
val query = db.query(TABLE_NAME, arrayOf(DATA), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null)
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 013bbf5cb5..6af3048e65 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/LokiMessageDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt
index 18dd42818d..3fa1dc6093 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import net.zetetic.database.sqlcipher.SQLiteDatabase.CONFLICT_REPLACE
+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
@@ -16,6 +17,10 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
private val messageHashTable = "loki_message_hash_database"
private val smsHashTable = "loki_sms_hash_database"
private val mmsHashTable = "loki_mms_hash_database"
+ const val groupInviteTable = "loki_group_invites"
+
+ private val groupInviteDeleteTrigger = "group_invite_delete_trigger"
+
private val messageID = "message_id"
private val serverID = "server_id"
private val friendRequestStatus = "friend_request_status"
@@ -23,6 +28,8 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
private val errorMessage = "error_message"
private val messageType = "message_type"
private 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 +46,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 +235,49 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
}
}
+ fun getSendersForHashes(threadId: Long, hashes: Set): List {
+ val smsQuery = "SELECT ${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.ADDRESS}, $smsHashTable.$serverHash, " +
+ "${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} FROM $smsHashTable LEFT OUTER JOIN ${SmsDatabase.TABLE_NAME} " +
+ "ON ${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} = $smsHashTable.$messageID WHERE ${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.THREAD_ID} = ?;"
+ val mmsQuery = "SELECT ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ADDRESS}, $mmsHashTable.$serverHash, " +
+ "${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} FROM $mmsHashTable LEFT OUTER JOIN ${MmsDatabase.TABLE_NAME} " +
+ "ON ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} = $mmsHashTable.$messageID WHERE ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.THREAD_ID} = ?;"
+ val smsCursor = databaseHelper.readableDatabase.query(smsQuery, arrayOf(threadId))
+ val mmsCursor = databaseHelper.readableDatabase.query(mmsQuery, arrayOf(threadId))
+
+ val serverHashToMessageIds = mutableListOf()
+
+ smsCursor.use { cursor ->
+ while (cursor.moveToNext()) {
+ val hash = cursor.getString(1)
+ if (hash in hashes) {
+ serverHashToMessageIds += ServerHashToMessageId(
+ serverHash = hash,
+ isSms = true,
+ sender = cursor.getString(0),
+ messageId = cursor.getLong(2)
+ )
+ }
+ }
+ }
+
+ mmsCursor.use { cursor ->
+ while (cursor.moveToNext()) {
+ val hash = cursor.getString(1)
+ if (hash in hashes) {
+ serverHashToMessageIds += ServerHashToMessageId(
+ serverHash = hash,
+ isSms = false,
+ sender = cursor.getString(0),
+ messageId = cursor.getLong(2)
+ )
+ }
+ }
+ }
+
+ return serverHashToMessageIds
+ }
+
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 +309,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 63db0c66ba..3f44588393 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 bc74496dda..baa78abad1 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.libsession.utilities.IdentityKeyMismatchList;
import org.session.libsignal.crypto.IdentityKey;
import org.session.libsignal.utilities.JsonUtil;
import org.session.libsignal.utilities.Log;
-import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.util.SqlUtil;
@@ -46,7 +45,7 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
public abstract void markUnidentified(long messageId, boolean unidentified);
- public abstract void markAsDeleted(long messageId, boolean read, boolean hasMention);
+ public abstract void markAsDeleted(long messageId);
public abstract boolean deleteMessage(long messageId);
public abstract boolean deleteMessages(long[] messageId, long threadId);
@@ -55,6 +54,8 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
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 abstract class MessagingDatabase extends Database implements MmsSmsColumn
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 5a2a9155de..984c050792 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt
@@ -158,7 +158,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
)
get(context).groupReceiptDatabase()
.update(ourAddress, id, status, timestamp)
- get(context).threadDatabase().update(threadId, false, true)
+ get(context).threadDatabase().update(threadId, false)
notifyConversationListeners(threadId)
}
}
@@ -178,6 +178,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(
@@ -257,7 +273,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
" WHERE " + ID + " = ?", arrayOf(id.toString() + "")
)
if (threadId.isPresent) {
- get(context).threadDatabase().update(threadId.get(), false, true)
+ get(context).threadDatabase().update(threadId.get(), false)
}
}
@@ -304,7 +320,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
db.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString()))
}
- override fun markAsDeleted(messageId: Long, read: Boolean, hasMention: Boolean) {
+ override fun markAsDeleted(messageId: Long) {
val database = databaseHelper.writableDatabase
val contentValues = ContentValues()
contentValues.put(READ, 1)
@@ -626,7 +642,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
)
if (!MmsSmsColumns.Types.isExpirationTimerUpdate(mailbox)) {
if (runThreadUpdate) {
- get(context).threadDatabase().update(threadId, true, true)
+ get(context).threadDatabase().update(threadId, true)
}
}
notifyConversationListeners(threadId)
@@ -771,7 +787,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
setHasSent(threadId, true)
if (runThreadUpdate) {
- update(threadId, true, true)
+ update(threadId, true)
}
}
return messageId
@@ -851,23 +867,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
@@ -900,6 +899,8 @@ 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 {
@@ -909,8 +910,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val groupReceiptDatabase = get(context).groupReceiptDatabase()
groupReceiptDatabase.deleteRowsForMessage(messageId)
val database = databaseHelper.writableDatabase
- database!!.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString()))
- val threadDeleted = get(context).threadDatabase().update(threadId, false, true)
+ database.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString()))
+ val threadDeleted = get(context).threadDatabase().update(threadId, false)
notifyConversationListeners(threadId)
notifyStickerListeners()
notifyStickerPackListeners()
@@ -921,6 +922,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(Runnable { attachmentDatabase.deleteAttachmentsForMessages(messageIds) })
+ groupReceiptDatabase.deleteRowsForMessages(messageIds)
+
val db = databaseHelper.writableDatabase
db.delete(
TABLE_NAME,
@@ -928,7 +935,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
argValues
)
- val threadDeleted = get(context).threadDatabase().update(threadId, false, true)
+ val threadDeleted = get(context).threadDatabase().update(threadId, false)
notifyConversationListeners(threadId)
notifyStickerListeners()
notifyStickerPackListeners()
@@ -956,6 +963,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
@@ -1099,7 +1162,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
return false
}
- /*package*/
private fun deleteThreads(threadIds: Set) {
val db = databaseHelper.writableDatabase
val where = StringBuilder()
@@ -1125,7 +1187,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
val threadDb = get(context).threadDatabase()
for (threadId in threadIds) {
- val threadDeleted = threadDb.update(threadId, false, true)
+ val threadDeleted = threadDb.update(threadId, false)
notifyConversationListeners(threadId)
}
notifyStickerListeners()
@@ -1133,17 +1195,13 @@ 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
- var where =
- THREAD_ID + " = ? AND (CASE (" + MESSAGE_BOX + " & " + MmsSmsColumns.Types.BASE_TYPE_MASK + ") "
- for (outgoingType in MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES) {
- where += " WHEN $outgoingType THEN $DATE_SENT < $date"
- }
- where += " ELSE $DATE_RECEIVED < $date END)"
- cursor = db!!.query(
+ var where = "$THREAD_ID = ? AND $DATE_SENT < $date"
+ 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 b737be855e..f22f50ff57 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java
@@ -37,7 +37,9 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
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;
@@ -261,6 +263,23 @@ public class MmsSmsDatabase extends Database {
}
}
+ public List getUserMessages(long threadId, String sender) {
+
+ 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 idList;
+ }
+
// 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.
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 dcd7778c9a..fb32fad978 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 class RecipientDatabase extends Database {
"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 class RecipientDatabase extends Database {
}
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 @@ public class RecipientDatabase extends Database {
}
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 @@ public class RecipientDatabase extends Database {
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 class RecipientDatabase extends Database {
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 27b3e73397..d885225feb 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,21 @@ 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 sessionID = cursor.getString(accountID)
+ val contact = Contact(sessionID)
+ 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/SessionJobDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt
index e83c464c7d..bbc3374072 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt
@@ -8,6 +8,7 @@ import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.AttachmentUploadJob
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob
+import org.session.libsession.messaging.jobs.InviteContactsJob
import org.session.libsession.messaging.jobs.Job
import org.session.libsession.messaging.jobs.MessageReceiveJob
import org.session.libsession.messaging.jobs.MessageSendJob
@@ -78,6 +79,13 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
return result.firstOrNull { job -> job.attachmentID == attachmentID }
}
+ fun getGroupInviteJob(groupSessionId: String, memberSessionId: String): InviteContactsJob? {
+ val database = databaseHelper.readableDatabase
+ return database.getAll(sessionJobTable, "$jobType = ?", arrayOf(InviteContactsJob.KEY)) { cursor ->
+ jobFromCursor(cursor) as? InviteContactsJob
+ }.firstOrNull { it != null && it.groupSessionId == groupSessionId && it.memberSessionIds.contains(memberSessionId) }
+ }
+
fun getMessageSendJob(messageSendJobID: String): MessageSendJob? {
val database = databaseHelper.readableDatabase
return database.get(sessionJobTable, "$jobID = ? AND $jobType = ?", arrayOf( messageSendJobID, MessageSendJob.KEY )) { cursor ->
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 f02498112f..adf79fb2ed 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
@@ -158,7 +158,7 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(id);
- DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
+ DatabaseComponent.get(context).threadDatabase().update(threadId, false);
notifyConversationListeners(threadId);
}
@@ -237,7 +237,7 @@ public class SmsDatabase extends MessagingDatabase {
}
@Override
- public void markAsDeleted(long messageId, boolean read, boolean hasMention) {
+ public void markAsDeleted(long messageId) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
ContentValues contentValues = new ContentValues();
contentValues.put(READ, 1);
@@ -257,7 +257,7 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(id);
- DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
+ DatabaseComponent.get(context).threadDatabase().update(threadId, false);
notifyConversationListeners(threadId);
}
@@ -296,6 +296,11 @@ public class SmsDatabase extends MessagingDatabase {
return isOutgoing;
}
+ @Override
+ public String getTypeColumn() {
+ return TYPE;
+ }
+
public void incrementReceiptCount(SyncMessageId messageId, boolean deliveryReceipt, boolean readReceipt) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
Cursor cursor = null;
@@ -320,7 +325,7 @@ public class SmsDatabase extends MessagingDatabase {
ID + " = ?",
new String[] {String.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))});
- DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
+ DatabaseComponent.get(context).threadDatabase().update(threadId, false);
notifyConversationListeners(threadId);
foundMessage = true;
}
@@ -403,7 +408,7 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(messageId);
- DatabaseComponent.get(context).threadDatabase().update(threadId, true, true);
+ DatabaseComponent.get(context).threadDatabase().update(threadId, true);
notifyConversationListeners(threadId);
notifyConversationListListeners();
@@ -478,7 +483,7 @@ public class SmsDatabase extends MessagingDatabase {
long messageId = db.insert(TABLE_NAME, null, values);
if (runThreadUpdate) {
- DatabaseComponent.get(context).threadDatabase().update(threadId, true, true);
+ DatabaseComponent.get(context).threadDatabase().update(threadId, true);
}
if (message.getSubscriptionId() != -1) {
@@ -570,7 +575,7 @@ public class SmsDatabase extends MessagingDatabase {
}
if (runThreadUpdate) {
- DatabaseComponent.get(context).threadDatabase().update(threadId, true, true);
+ DatabaseComponent.get(context).threadDatabase().update(threadId, true);
}
long lastSeen = DatabaseComponent.get(context).threadDatabase().getLastSeenAndHasSent(threadId).first();
if (lastSeen < message.getSentTimestampMillis()) {
@@ -630,7 +635,7 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(messageId);
db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""});
notifyConversationListeners(threadId);
- boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, false);
+ boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false);
return threadDeleted;
}
@@ -650,7 +655,7 @@ public class SmsDatabase extends MessagingDatabase {
ID + " IN (" + StringUtils.join(argsArray, ',') + ")",
argValues
);
- boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
+ boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false);
notifyConversationListeners(threadId);
return threadDeleted;
}
@@ -697,15 +702,14 @@ public class SmsDatabase extends MessagingDatabase {
}
}
+ void deleteMessagesFrom(long threadId, String fromUser) {
+ SQLiteDatabase db = databaseHelper.getWritableDatabase();
+ db.delete(TABLE_NAME, THREAD_ID+" = ? AND "+ADDRESS+" = ?", new String[]{threadId+"", fromUser});
+ }
+
void deleteMessagesInThreadBeforeDate(long threadId, long date) {
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;
- }
-
- where += (" ELSE " + DATE_RECEIVED + " < " + date + " END)");
+ 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 a39598c55b..693758bf27 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt
@@ -2,31 +2,44 @@ package org.thoughtcrime.securesms.database
import android.content.Context
import android.net.Uri
+import com.google.protobuf.ByteString
+import com.goterl.lazysodium.utils.KeyPair
+import network.loki.messenger.libsession_util.Config
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.GroupInfoConfig
+import network.loki.messenger.libsession_util.GroupKeysConfig
+import network.loki.messenger.libsession_util.GroupMembersConfig
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.Sodium
import network.loki.messenger.libsession_util.util.UserPic
import network.loki.messenger.libsession_util.util.afterSend
+import nl.komponents.kovenant.Promise
+import nl.komponents.kovenant.functional.bind
+import nl.komponents.kovenant.functional.map
import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.database.StorageProtocol
+import org.session.libsession.database.userAuth
import org.session.libsession.messaging.BlindedIdMapping
+import org.session.libsession.messaging.MessagingModuleConfiguration
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.ConfigurationSyncJob.Companion.messageInformation
import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob
+import org.session.libsession.messaging.jobs.InviteContactsJob
import org.session.libsession.messaging.jobs.Job
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageReceiveJob
@@ -36,6 +49,7 @@ 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
@@ -51,18 +65,25 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.GroupMember
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi
+import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
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.pollers.LegacyClosedGroupPollerV2
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.GroupSubAccountSwarmAuth
import org.session.libsession.snode.OnionRequestAPI
+import org.session.libsession.snode.OwnedSwarmAuth
+import org.session.libsession.snode.RawResponse
import org.session.libsession.snode.SnodeAPI
+import org.session.libsession.snode.SnodeAPI.buildAuthenticatedDeleteBatchInfo
+import org.session.libsession.snode.SnodeAPI.buildAuthenticatedStoreBatchInfo
+import org.session.libsession.snode.SnodeMessage
+import org.session.libsession.snode.utilities.await
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.GroupRecord
@@ -73,36 +94,56 @@ import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol.Co
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.recipients.Recipient.DisappearingState
+import org.session.libsession.utilities.withGroupConfigsOrNull
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
import org.session.libsignal.messages.SignalServiceGroup
+import org.session.libsignal.protos.SignalServiceProtos.DataMessage
+import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage
+import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInfoChangeMessage
+import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInviteResponseMessage
+import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMemberChangeMessage
+import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage
import org.session.libsignal.utilities.Base64
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.Namespace
+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.session.libsession.messaging.utilities.MessageAuthentication.buildDeleteMemberContentSignature
+import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeVerifier
+import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature
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.dependencies.PollerFactory
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 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,
helper: SQLCipherOpenHelper,
- val configFactory: ConfigFactory
-) : Database(context, helper), StorageProtocol, ThreadDatabase.ConversationThreadUpdateListener {
+ private val configFactory: ConfigFactory,
+ private val pollerFactory: PollerFactory,
+) : Database(context, helper), StorageProtocol,
+ ThreadDatabase.ConversationThreadUpdateListener {
override fun threadCreated(address: Address, threadId: Long) {
val localUserAddress = getUserPublicKey() ?: return
@@ -111,20 +152,31 @@ open class Storage(
val volatile = configFactory.convoVolatile ?: return
if (address.isGroup) {
val groups = configFactory.userGroups ?: return
- if (address.isClosedGroup) {
- 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)
+ when {
+ address.isLegacyClosedGroup -> {
+ 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)
+ }
+ }
+ address.isClosedGroupV2 -> {
+ val AccountId = address.serialize()
+ groups.getClosedGroup(AccountId) ?: return Log.d("Closed group doesn't exist locally", NullPointerException())
+ val conversation = Conversation.ClosedGroup(
+ AccountId, 0, false
+ )
+ volatile.set(conversation)
+ }
+ 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.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
@@ -149,13 +201,15 @@ open class Storage(
val volatile = configFactory.convoVolatile ?: return
if (address.isGroup) {
val groups = configFactory.userGroups ?: return
- if (address.isClosedGroup) {
+ if (address.isLegacyClosedGroup) {
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 if (address.isClosedGroupV2) {
+ Log.w("Loki", "Thread delete called for closed group address, expecting to be handled elsewhere")
}
} else {
// non-standard contact prefixes: 15, 00 etc shouldn't be stored in config
@@ -182,6 +236,10 @@ open class Storage(
return DatabaseComponent.get(context).lokiAPIDatabase().getUserX25519KeyPair()
}
+ override fun getUserED25519KeyPair(): KeyPair? {
+ return KeyPairUtilities.getUserED25519KeyPair(context)
+ }
+
override fun getUserProfile(): Profile {
val displayName = TextSecurePreferences.getProfileName(context)
val profileKey = ProfileKeyUtil.getProfileKey(context)
@@ -243,6 +301,42 @@ open class Storage(
return threadDb.getLastSeenAndHasSent(threadId)?.first() ?: 0L
}
+ override fun ensureMessageHashesAreSender(
+ hashes: Set,
+ sender: String,
+ closedGroupId: String
+ ): Boolean {
+ val dbComponent = DatabaseComponent.get(context)
+ val lokiMessageDatabase = dbComponent.lokiMessageDatabase()
+ val threadId = getThreadId(fromSerialized(closedGroupId))!!
+ val info = lokiMessageDatabase.getSendersForHashes(threadId, hashes)
+ return info.all { it.sender == sender }
+ }
+
+ override fun deleteMessagesByHash(threadId: Long, hashes: List) {
+ val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider
+ val lokiMessageDatabase = DatabaseComponent.get(context).lokiMessageDatabase()
+ val info = lokiMessageDatabase.getSendersForHashes(threadId, hashes.toSet())
+ // TODO: no idea if we need to server delete this
+ for ((serverHash, sender, messageIdToDelete, isSms) in info) {
+ messageDataProvider.updateMessageAsDeleted(messageIdToDelete, isSms)
+ if (!messageDataProvider.isOutgoingMessage(messageIdToDelete)) {
+ SSKEnvironment.shared.notificationManager.updateNotification(context)
+ }
+ }
+ }
+ override fun deleteMessagesByUser(threadId: Long, userSessionId: String) {
+ val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider
+ val userMessages = DatabaseComponent.get(context).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()
getRecipientForThread(threadId)?.let { recipient ->
@@ -256,7 +350,8 @@ open class Storage(
configFactory.convoVolatile?.let { config ->
val convo = when {
// recipient closed group
- recipient.isClosedGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize()))
+ recipient.isLegacyClosedGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize()))
+ recipient.isClosedGroupV2Recipient -> config.getOrConstructClosedGroup(recipient.address.serialize())
// recipient is open group
recipient.isCommunityRecipient -> {
val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return
@@ -285,7 +380,7 @@ open class Storage(
override fun updateThread(threadId: Long, unarchive: Boolean) {
val threadDb = DatabaseComponent.get(context).threadDatabase()
- threadDb.update(threadId, unarchive, false)
+ threadDb.update(threadId, unarchive)
}
override fun persist(message: VisibleMessage,
@@ -302,6 +397,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))
@@ -314,7 +412,14 @@ 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
}
@@ -442,7 +547,7 @@ open class Storage(
return DatabaseComponent.get(context).lokiAPIDatabase().getAuthToken(id)
}
- override fun notifyConfigUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) {
+ override fun notifyConfigUpdates(forConfigObject: Config, messageTimestamp: Long) {
notifyUpdates(forConfigObject, messageTimestamp)
}
@@ -458,12 +563,15 @@ open class Storage(
return configFactory.user?.getCommunityMessageRequests() == true
}
- private fun notifyUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) {
+ private fun notifyUpdates(forConfigObject: Config, messageTimestamp: Long) {
when (forConfigObject) {
is UserProfile -> updateUser(forConfigObject, messageTimestamp)
is Contacts -> updateContacts(forConfigObject, messageTimestamp)
is ConversationVolatileConfig -> updateConvoVolatile(forConfigObject, messageTimestamp)
is UserGroupsConfig -> updateUserGroups(forConfigObject, messageTimestamp)
+ is GroupInfoConfig -> updateGroupInfo(forConfigObject, messageTimestamp)
+ is GroupKeysConfig -> updateGroupKeys(forConfigObject)
+ is GroupMembersConfig -> updateGroupMembers(forConfigObject)
}
}
@@ -487,7 +595,8 @@ open class Storage(
if (userPic == UserPic.DEFAULT) {
clearUserPic()
} else if (userPic.key.isNotEmpty() && userPic.url.isNotEmpty()
- && TextSecurePreferences.getProfilePictureURL(context) != userPic.url) {
+ && TextSecurePreferences.getProfilePictureURL(context) != userPic.url
+ ) {
setUserProfilePicture(userPic.url, userPic.key)
}
@@ -508,11 +617,35 @@ open class Storage(
// 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)
+ getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp } ?:
+ ExpirationConfiguration(it, userProfile.getNtsExpiry(), messageTimestamp)
)
}
}
+ private fun updateGroupInfo(groupInfoConfig: GroupInfoConfig, messageTimestamp: Long) {
+ val threadId = getThreadId(fromSerialized(groupInfoConfig.id().hexString)) ?: return
+ val recipient = getRecipientForThread(threadId) ?: return
+ val db = DatabaseComponent.get(context).recipientDatabase()
+ db.setProfileName(recipient, groupInfoConfig.getName())
+ groupInfoConfig.getDeleteBefore()?.let { removeBefore ->
+ trimThreadBefore(threadId, removeBefore)
+ }
+ groupInfoConfig.getDeleteAttachmentsBefore()?.let { removeAttachmentsBefore ->
+ val mmsDb = DatabaseComponent.get(context).mmsDatabase()
+ mmsDb.deleteMessagesInThreadBeforeDate(threadId, removeAttachmentsBefore, onlyMedia = true)
+ }
+ // TODO: handle deleted group, handle delete attachment / message before a certain time
+ }
+
+ private fun updateGroupKeys(groupKeys: GroupKeysConfig) {
+ // TODO: update something here?
+ }
+
+ private fun updateGroupMembers(groupMembers: GroupMembersConfig) {
+ // TODO: maybe clear out some contacts or something?
+ }
+
private fun updateContacts(contacts: Contacts, messageTimestamp: Long) {
val extracted = contacts.all().toList()
addLibSessionContacts(extracted, messageTimestamp)
@@ -542,6 +675,7 @@ open class Storage(
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)
+ is Conversation.ClosedGroup -> getThreadIdFor(conversation.accountId, null, null, createThread = false) // New groups will be managed bia libsession
}
if (threadId != null) {
if (conversation.lastRead > getLastSeen(threadId)) {
@@ -569,9 +703,9 @@ open class Storage(
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 existingLegacyClosedGroups = getAllGroups(includeInactive = true).filter { it.isLegacyClosedGroup }
val lgcIds = lgc.map { it.accountId }
- val toDeleteClosedGroups = existingClosedGroups.filter { group ->
+ val toDeleteClosedGroups = existingLegacyClosedGroups.filter { group ->
GroupUtil.doubleDecodeGroupId(group.encodedId) !in lgcIds
}
@@ -603,9 +737,21 @@ open class Storage(
}
}
+ val newClosedGroups = userGroups.allClosedGroupInfo()
+ for (closedGroup in newClosedGroups) {
+ val recipient = Recipient.from(context, fromSerialized(closedGroup.groupAccountId.hexString), false)
+ setRecipientApprovedMe(recipient, true)
+ setRecipientApproved(recipient, !closedGroup.invited)
+ val threadId = getOrCreateThreadIdFor(recipient.address)
+ setPinned(threadId, closedGroup.priority == PRIORITY_PINNED)
+ if (!closedGroup.invited) {
+ pollerFactory.pollerFor(closedGroup.groupAccountId)?.start()
+ }
+ }
+
for (group in lgc) {
val groupId = GroupUtil.doubleEncodeGroupID(group.accountId)
- val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.accountId }
+ val existingGroup = existingLegacyClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.accountId }
val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) }
if (existingGroup != null) {
if (group.priority == PRIORITY_HIDDEN && existingThread != null) {
@@ -617,12 +763,12 @@ open class Storage(
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 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)
createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp)
- setProfileSharing(Address.fromSerialized(groupId), true)
+ setProfileSharing(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
@@ -631,7 +777,7 @@ open class Storage(
// Notify the PN server
PushRegistryV1.subscribeGroup(group.accountId, publicKey = localUserPublicKey)
// Notify the user
- val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId))
+ val threadID = getOrCreateThreadIdFor(fromSerialized(groupId))
threadDb.setDate(threadID, formationTimestamp)
// Note: Commenting out this line prevents the timestamp of room creation being added to a new closed group,
@@ -640,9 +786,9 @@ open class Storage(
// Don't create config group here, it's from a config update
// Start polling
- ClosedGroupPollerV2.shared.startPolling(group.accountId)
+ LegacyClosedGroupPollerV2.shared.startPolling(group.accountId)
}
- getThreadId(Address.fromSerialized(groupId))?.let {
+ getThreadId(fromSerialized(groupId))?.let {
setExpirationConfiguration(
getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp }
?: ExpirationConfiguration(it, afterSend(group.disappearingTimer), messageTimestamp)
@@ -933,6 +1079,137 @@ open class Storage(
DatabaseComponent.get(context).groupDatabase().create(groupId, title, members, avatar, relay, admins, formationTimestamp)
}
+ override fun createNewGroup(groupName: String, groupDescription: String, members: Set): Optional {
+ val userGroups = configFactory.userGroups ?: return Optional.absent()
+ val convoVolatile = configFactory.convoVolatile ?: return Optional.absent()
+ val ourSessionId = getUserPublicKey() ?: return Optional.absent()
+
+ val groupCreationTimestamp = SnodeAPI.nowWithOffset
+
+ val group = userGroups.createGroup()
+ val adminKey = checkNotNull(group.adminKey) {
+ "Admin key is null for new group creation."
+ }
+
+ userGroups.set(group)
+ val groupInfo = configFactory.getGroupInfoConfig(group.groupAccountId) ?: return Optional.absent()
+ val groupMembers = configFactory.getGroupMemberConfig(group.groupAccountId) ?: return Optional.absent()
+
+ with (groupInfo) {
+ setName(groupName)
+ setDescription(groupDescription)
+ }
+
+ groupMembers.set(
+ LibSessionGroupMember(ourSessionId, getUserProfile().displayName, admin = true)
+ )
+
+ members.forEach { groupMembers.set(LibSessionGroupMember(it.accountID, it.name).setInvited()) }
+
+ val groupKeys = configFactory.constructGroupKeysConfig(group.groupAccountId,
+ info = groupInfo,
+ members = groupMembers) ?: return Optional.absent()
+
+ // Manually re-key to prevent issue with linked admin devices
+ groupKeys.rekey(groupInfo, groupMembers)
+
+ val newGroupRecipient = group.groupAccountId.hexString
+ val configTtl = 14 * 24 * 60 * 60 * 1000L
+ // Test the sending
+ val keyPush = groupKeys.pendingConfig() ?: return Optional.absent()
+
+ val groupAdminSigner = OwnedSwarmAuth.ofClosedGroup(group.groupAccountId, adminKey)
+
+ val keysSnodeMessage = SnodeMessage(
+ newGroupRecipient,
+ Base64.encodeBytes(keyPush),
+ configTtl,
+ groupCreationTimestamp
+ )
+ val keysBatchInfo = SnodeAPI.buildAuthenticatedStoreBatchInfo(
+ groupKeys.namespace(),
+ keysSnodeMessage,
+ groupAdminSigner
+ )
+
+ val (infoPush, infoSeqNo) = groupInfo.push()
+ val infoSnodeMessage = SnodeMessage(
+ newGroupRecipient,
+ Base64.encodeBytes(infoPush),
+ configTtl,
+ groupCreationTimestamp
+ )
+ val infoBatchInfo = SnodeAPI.buildAuthenticatedStoreBatchInfo(
+ groupInfo.namespace(),
+ infoSnodeMessage,
+ groupAdminSigner
+ )
+
+ val (memberPush, memberSeqNo) = groupMembers.push()
+ val memberSnodeMessage = SnodeMessage(
+ newGroupRecipient,
+ Base64.encodeBytes(memberPush),
+ configTtl,
+ groupCreationTimestamp
+ )
+ val memberBatchInfo = SnodeAPI.buildAuthenticatedStoreBatchInfo(
+ groupMembers.namespace(),
+ memberSnodeMessage,
+ groupAdminSigner
+ )
+
+ try {
+ val snode = SnodeAPI.getSingleTargetSnode(newGroupRecipient).get()
+ val response = SnodeAPI.getRawBatchResponse(
+ snode,
+ newGroupRecipient,
+ listOf(keysBatchInfo, infoBatchInfo, memberBatchInfo),
+ true
+ ).get()
+
+ @Suppress("UNCHECKED_CAST")
+ val responseList = (response["results"] as List)
+
+ val keyResponse = responseList[0]
+ val keyHash = (keyResponse["body"] as Map)["hash"] as String
+ val keyTimestamp = (keyResponse["body"] as Map)["t"] as Long
+ val infoResponse = responseList[1]
+ val infoHash = (infoResponse["body"] as Map)["hash"] as String
+ val memberResponse = responseList[2]
+ val memberHash = (memberResponse["body"] as Map)["hash"] as String
+ // TODO: check response success
+ groupKeys.loadKey(keyPush, keyHash, keyTimestamp, groupInfo, groupMembers)
+ groupInfo.confirmPushed(infoSeqNo, infoHash)
+ groupMembers.confirmPushed(memberSeqNo, memberHash)
+
+ configFactory.saveGroupConfigs(groupKeys, groupInfo, groupMembers) // now check poller to be all
+ convoVolatile.set(Conversation.ClosedGroup(newGroupRecipient, groupCreationTimestamp, false))
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
+ val groupRecipient = Recipient.from(context, fromSerialized(newGroupRecipient), false)
+ SSKEnvironment.shared.profileManager.setName(context, groupRecipient, groupInfo.getName())
+ setRecipientApprovedMe(groupRecipient, true)
+ setRecipientApproved(groupRecipient, true)
+ Log.d("Group Config", "Saved group config for $newGroupRecipient")
+ pollerFactory.updatePollers()
+
+ val memberArray = members.map(Contact::accountID).toTypedArray()
+ val job = InviteContactsJob(group.groupAccountId.hexString, memberArray)
+ JobQueue.shared.add(job)
+ return Optional.of(groupRecipient)
+ } catch (e: Exception) {
+ Log.e("Group Config", e)
+ Log.e("Group Config", "Deleting group from our group")
+ // delete the group from user groups
+ userGroups.erase(group)
+ } finally {
+ groupKeys.free()
+ groupInfo.free()
+ groupMembers.free()
+ }
+
+ return Optional.absent()
+ }
+
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
@@ -1009,16 +1286,22 @@ open class Storage(
DatabaseComponent.get(context).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 infoMessage = IncomingGroupMessage(m, updateData, true)
val smsDB = DatabaseComponent.get(context).smsDatabase()
- smsDB.insertMessageInbox(infoMessage, true)
+ return smsDB.insertMessageInbox(infoMessage, true).orNull().messageId
}
- override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection, admins: Collection, threadID: Long, sentTimestamp: Long) {
+ override fun updateInfoMessage(context: Context, messageId: Long, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection) {
+ val mmsDB = DatabaseComponent.get(context).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): Long? {
val userPublicKey = getUserPublicKey()!!
val recipient = Recipient.from(context, fromSerialized(groupID), false)
val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() ?: ""
@@ -1027,16 +1310,15 @@ open class Storage(
val mmsSmsDB = DatabaseComponent.get(context).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 DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(publicKey)
}
override fun getClosedGroupEncryptionKeyPairs(groupPublicKey: String): MutableList {
@@ -1073,6 +1355,10 @@ open class Storage(
DatabaseComponent.get(context).lokiAPIDatabase().removeAllClosedGroupEncryptionKeyPairs(groupPublicKey)
}
+ override fun removeClosedGroupThread(threadID: Long) {
+ DatabaseComponent.get(context).threadDatabase().deleteConversation(threadID)
+ }
+
override fun updateFormationTimestamp(groupID: String, formationTimestamp: Long) {
DatabaseComponent.get(context).groupDatabase()
.updateFormationTimestamp(groupID, formationTimestamp)
@@ -1083,6 +1369,788 @@ open class Storage(
.updateTimestampUpdated(groupID, updatedTimestamp)
}
+ /**
+ * For new closed groups
+ */
+ override fun getMembers(groupPublicKey: String): List =
+ configFactory.getGroupMemberConfig(AccountId(groupPublicKey))?.use { it.all() }?.toList() ?: emptyList()
+
+ private fun approveGroupInvite(threadId: Long, groupSessionId: AccountId) {
+ val groups = configFactory.userGroups ?: return
+ val group = groups.getClosedGroup(groupSessionId.hexString) ?: return
+
+ configFactory.persist(
+ forConfigObject = groups.apply { set(group.copy(invited = false)) },
+ timestamp = SnodeAPI.nowWithOffset
+ )
+
+ // Send invite response if we aren't admin. If we already have admin access,
+ // the group configs are already up-to-date (hence no need to reponse to the invite)
+ if (group.adminKey == null) {
+ val inviteResponse = GroupUpdateInviteResponseMessage.newBuilder()
+ .setIsApproved(true)
+ val responseData = GroupUpdateMessage.newBuilder()
+ .setInviteResponse(inviteResponse)
+ val responseMessage = GroupUpdated(responseData.build())
+ clearMessages(threadId)
+ // this will fail the first couple of times :)
+ MessageSender.send(responseMessage, fromSerialized(groupSessionId.hexString))
+ } else {
+ // Update our on member state
+ configFactory.getGroupMemberConfig(groupSessionId)?.use { members ->
+ configFactory.getGroupInfoConfig(groupSessionId)?.use { info ->
+ configFactory.getGroupKeysConfig(groupSessionId, info)?.use { keys ->
+ members.get(getUserPublicKey().orEmpty())?.let { member ->
+ members.set(member.setPromoteSuccess().setInvited())
+ }
+
+ configFactory.saveGroupConfigs(keys, info, members)
+ }
+ }
+ }
+ }
+
+ configFactory.persist(groups, SnodeAPI.nowWithOffset)
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
+ pollerFactory.pollerFor(groupSessionId)?.start()
+
+ // clear any group invites for this session ID (just in case there's a re-invite from an approved member after an invite from non-approved)
+ DatabaseComponent.get(context).lokiMessageDatabase().deleteGroupInviteReferrer(threadId)
+ }
+
+ override fun respondToClosedGroupInvitation(
+ threadId: Long,
+ groupRecipient: Recipient,
+ approved: Boolean
+ ) {
+ val groups = configFactory.userGroups ?: return
+ val groupSessionId = AccountId(groupRecipient.address.serialize())
+ // Whether approved or not, delete the invite
+ DatabaseComponent.get(context).lokiMessageDatabase().deleteGroupInviteReferrer(threadId)
+ if (!approved) {
+ groups.eraseClosedGroup(groupSessionId.hexString)
+ configFactory.persist(groups, SnodeAPI.nowWithOffset)
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
+ deleteConversation(threadId)
+ return
+ } else {
+ approveGroupInvite(threadId, groupSessionId)
+ }
+
+ }
+
+ override fun addClosedGroupInvite(
+ groupId: AccountId,
+ name: String,
+ authData: ByteArray?,
+ adminKey: ByteArray?,
+ invitingAdmin: AccountId,
+ invitingMessageHash: String?,
+ ) {
+ require(authData != null || adminKey != null) {
+ "Must provide either authData or adminKey"
+ }
+
+ val recipient = Recipient.from(context, fromSerialized(groupId.hexString), false)
+ val profileManager = SSKEnvironment.shared.profileManager
+ val groups = configFactory.userGroups ?: return
+ val inviteDb = DatabaseComponent.get(context).lokiMessageDatabase()
+ val shouldAutoApprove = getRecipientApproved(fromSerialized(invitingAdmin.hexString))
+ val closedGroupInfo = GroupInfo.ClosedGroupInfo(
+ groupAccountId = groupId,
+ adminKey = adminKey,
+ authData = authData,
+ priority = PRIORITY_VISIBLE,
+ invited = !shouldAutoApprove,
+ name = name,
+ )
+ groups.set(closedGroupInfo)
+
+ configFactory.persist(groups, SnodeAPI.nowWithOffset)
+ profileManager.setName(context, recipient, name)
+ val groupThreadId = getOrCreateThreadIdFor(recipient.address)
+ setRecipientApprovedMe(recipient, true)
+ setRecipientApproved(recipient, shouldAutoApprove)
+ if (shouldAutoApprove) {
+ approveGroupInvite(groupThreadId, groupId)
+ } else {
+ inviteDb.addGroupInviteReferrer(groupThreadId, invitingAdmin.hexString)
+ insertGroupInviteControlMessage(SnodeAPI.nowWithOffset, invitingAdmin.hexString, groupId, name)
+ }
+
+ val userAuth = this.userAuth
+ if (invitingMessageHash != null && userAuth != null) {
+ val batch = SnodeAPI.buildAuthenticatedDeleteBatchInfo(
+ auth = userAuth,
+ listOf(invitingMessageHash)
+ )
+
+ SnodeAPI.getSingleTargetSnode(userAuth.accountId.hexString).map { snode ->
+ SnodeAPI.getRawBatchResponse(snode, userAuth.accountId.hexString, listOf(batch))
+ }.success {
+ Log.d(TAG, "Successfully deleted invite message")
+ }.fail { e ->
+ Log.e(TAG, "Error deleting invite message", e)
+ }
+ }
+ }
+
+ override fun setGroupInviteCompleteIfNeeded(approved: Boolean, invitee: String, closedGroup: AccountId) {
+ // don't try to process invitee acceptance if we aren't admin
+ if (configFactory.userGroups?.getClosedGroup(closedGroup.hexString)?.hasAdminKey() != true) return
+
+ configFactory.getGroupMemberConfig(closedGroup)?.use { groupMembers ->
+ val member = groupMembers.get(invitee) ?: run {
+ Log.e("ClosedGroup", "User wasn't in the group membership to add!")
+ return
+ }
+ if (!member.invitePending) return groupMembers.close()
+ if (approved) {
+ groupMembers.set(member.setAccepted())
+ } else {
+ groupMembers.erase(member)
+ }
+ configFactory.persistGroupConfigDump(groupMembers, closedGroup, SnodeAPI.nowWithOffset)
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(Destination.ClosedGroup(closedGroup.hexString))
+ }
+ }
+
+ override fun getLibSessionClosedGroup(groupSessionId: String): GroupInfo.ClosedGroupInfo? {
+ return configFactory.userGroups?.getClosedGroup(groupSessionId)
+ }
+
+ override fun getClosedGroupDisplayInfo(groupSessionId: String): GroupDisplayInfo? {
+ val infoConfig = configFactory.getGroupInfoConfig(AccountId(groupSessionId)) ?: return null
+ val isAdmin = configFactory.userGroups?.getClosedGroup(groupSessionId)?.hasAdminKey() ?: return null
+
+ return infoConfig.use { info ->
+ GroupDisplayInfo(
+ id = info.id(),
+ name = info.getName(),
+ profilePic = info.getProfilePic(),
+ expiryTimer = info.getExpiryTimer(),
+ destroyed = false,
+ created = info.getCreated(),
+ description = info.getDescription(),
+ isUserAdmin = isAdmin
+ )
+ }
+ }
+
+ override fun inviteClosedGroupMembers(groupSessionId: String, invitees: List) {
+ // don't try to process invitee acceptance if we aren't admin
+ if (configFactory.userGroups?.getClosedGroup(groupSessionId)?.hasAdminKey() != true) return
+ val adminKey = configFactory.userGroups?.getClosedGroup(groupSessionId)?.adminKey ?: return
+ val accountId = AccountId(groupSessionId)
+ val membersConfig = configFactory.getGroupMemberConfig(accountId) ?: return
+ val infoConfig = configFactory.getGroupInfoConfig(accountId) ?: return
+ val groupAuth = OwnedSwarmAuth.ofClosedGroup(accountId, adminKey)
+
+ // Filter out people who aren't already invited
+ val filteredMembers = invitees.filter {
+ membersConfig.get(it) == null
+ }
+ // Create each member's contact info if we have it
+ filteredMembers.forEach { memberSessionId ->
+ val contact = getContactWithAccountID(memberSessionId)
+ val name = contact?.name
+ val url = contact?.profilePictureURL
+ val key = contact?.profilePictureEncryptionKey
+ val userPic = if (url != null && key != null) {
+ UserPic(url, key)
+ } else UserPic.DEFAULT
+ val member = membersConfig.getOrConstruct(memberSessionId).copy(
+ name = name,
+ profilePicture = userPic,
+ ).setInvited()
+ membersConfig.set(member)
+ }
+
+ // Persist the config changes now, so we can show the invite status immediately
+ configFactory.persistGroupConfigDump(membersConfig, accountId, SnodeAPI.nowWithOffset)
+
+ // re-key for new members
+ val keysConfig = configFactory.getGroupKeysConfig(
+ accountId,
+ info = infoConfig,
+ members = membersConfig,
+ free = false
+ ) ?: return
+
+ keysConfig.rekey(infoConfig, membersConfig)
+
+ // build unrevocation, in case of re-adding members
+ val membersToUnrevoke = filteredMembers.map { keysConfig.getSubAccountToken(AccountId(it)) }
+ val unrevocation = if (membersToUnrevoke.isNotEmpty()) {
+ SnodeAPI.buildAuthenticatedUnrevokeSubKeyBatchRequest(
+ groupAdminAuth = groupAuth,
+ subAccountTokens = membersToUnrevoke
+ ) ?: return Log.e("ClosedGroup", "Failed to build revocation update")
+ } else {
+ null
+ }
+
+ // Build and store the key update in group swarm
+ val toDelete = mutableListOf()
+
+ val keyMessage = keysConfig.messageInformation(groupAuth)
+ val infoMessage = infoConfig.messageInformation(toDelete, groupAuth)
+ val membersMessage = membersConfig.messageInformation(toDelete, groupAuth)
+
+ val delete = SnodeAPI.buildAuthenticatedDeleteBatchInfo(
+ auth = groupAuth,
+ messageHashes = toDelete,
+ )
+
+ val requests = buildList {
+ add(keyMessage.batch)
+ add(infoMessage.batch)
+ add(membersMessage.batch)
+
+ if (unrevocation != null) {
+ add(unrevocation)
+ }
+
+ add(delete)
+ }
+
+ val response = SnodeAPI.getSingleTargetSnode(groupSessionId).bind { snode ->
+ SnodeAPI.getRawBatchResponse(
+ snode,
+ groupSessionId,
+ requests,
+ sequence = true
+ )
+ }
+
+ try {
+ val rawResponse = response.get()
+ val results = (rawResponse["results"] as ArrayList).first() as Map
+ if (results["code"] as Int != 200) {
+ throw Exception("Response wasn't successful for unrevoke and key update: ${results["body"] as? String}")
+ }
+
+ configFactory.saveGroupConfigs(keysConfig, infoConfig, membersConfig)
+
+ val job = InviteContactsJob(groupSessionId, filteredMembers.toTypedArray())
+ JobQueue.shared.add(job)
+
+ val timestamp = SnodeAPI.nowWithOffset
+ val signature = SodiumUtilities.sign(
+ buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.ADDED, timestamp),
+ adminKey
+ )
+ val updatedMessage = GroupUpdated(
+ GroupUpdateMessage.newBuilder()
+ .setMemberChangeMessage(
+ GroupUpdateMemberChangeMessage.newBuilder()
+ .addAllMemberSessionIds(filteredMembers)
+ .setType(GroupUpdateMemberChangeMessage.Type.ADDED)
+ .setAdminSignature(ByteString.copyFrom(signature))
+ )
+ .build()
+ ).apply { this.sentTimestamp = timestamp }
+ MessageSender.send(updatedMessage, fromSerialized(groupSessionId))
+ insertGroupInfoChange(updatedMessage, accountId)
+ infoConfig.free()
+ membersConfig.free()
+ keysConfig.free()
+ } catch (e: Exception) {
+ Log.e("ClosedGroup", "Failed to store new key", e)
+ infoConfig.free()
+ membersConfig.free()
+ keysConfig.free()
+ // toaster toast here
+ return
+ }
+
+ }
+
+ override fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId): Long? {
+ val sentTimestamp = message.sentTimestamp ?: SnodeAPI.nowWithOffset
+ val senderPublicKey = message.sender
+ val groupName = configFactory.getGroupInfoConfig(closedGroup)?.use { it.getName() }.orEmpty()
+
+ val updateData = UpdateMessageData.buildGroupUpdate(message, groupName) ?: return null
+
+ return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup)
+ }
+
+ override fun insertGroupInfoLeaving(closedGroup: AccountId): Long? {
+ val sentTimestamp = SnodeAPI.nowWithOffset
+ 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 = DatabaseComponent.get(context).mmsDatabase()
+ val newMessage = UpdateMessageData.buildGroupLeaveUpdate(newType)
+ mmsDB.updateInfoMessage(messageId, newMessage.toJSON())
+ }
+
+ private fun insertGroupInviteControlMessage(sentTimestamp: Long, senderPublicKey: String, closedGroup: AccountId, groupName: String): Long? {
+ val updateData = UpdateMessageData(UpdateMessageData.Kind.GroupInvitation(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 = DatabaseComponent.get(context).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 = DatabaseComponent.get(context).mmsDatabase()
+ val mmsSmsDB = DatabaseComponent.get(context).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 = DatabaseComponent.get(context).smsDatabase()
+ val insertResult = smsDB.insertMessageInbox(infoMessage, true)
+ return insertResult.orNull()?.messageId
+ }
+ }
+
+ override fun promoteMember(groupAccountId: AccountId, promotions: List) {
+ val adminKey = configFactory.userGroups?.getClosedGroup(groupAccountId.hexString)?.adminKey ?: return
+ if (adminKey.isEmpty()) {
+ return Log.e("ClosedGroup", "No admin key for group")
+ }
+
+ configFactory.withGroupConfigsOrNull(groupAccountId) { info, members, keys ->
+ promotions.forEach { accountId ->
+ val promoted = members.get(accountId.hexString)?.setPromoteSent() ?: return@forEach
+ members.set(promoted)
+
+ val message = GroupUpdated(
+ GroupUpdateMessage.newBuilder()
+ .setPromoteMessage(
+ DataMessage.GroupUpdatePromoteMessage.newBuilder()
+ .setGroupIdentitySeed(ByteString.copyFrom(adminKey))
+ .setName(info.getName())
+ )
+ .build()
+ )
+ MessageSender.send(message, fromSerialized(accountId.hexString))
+ }
+
+ configFactory.saveGroupConfigs(keys, info, members)
+ }
+
+
+ val groupDestination = Destination.ClosedGroup(groupAccountId.hexString)
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(groupDestination)
+ val timestamp = SnodeAPI.nowWithOffset
+ val signature = SodiumUtilities.sign(
+ buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.PROMOTED, timestamp),
+ adminKey
+ )
+ val message = GroupUpdated(
+ GroupUpdateMessage.newBuilder()
+ .setMemberChangeMessage(
+ GroupUpdateMemberChangeMessage.newBuilder()
+ .addAllMemberSessionIds(promotions.map { it.hexString })
+ .setType(GroupUpdateMemberChangeMessage.Type.PROMOTED)
+ .setAdminSignature(ByteString.copyFrom(signature))
+ )
+ .build()
+ ).apply {
+ sentTimestamp = timestamp
+ }
+
+ MessageSender.send(message, fromSerialized(groupDestination.publicKey))
+ insertGroupInfoChange(message, groupAccountId)
+ }
+
+ private suspend fun doRemoveMember(
+ groupSessionId: AccountId,
+ removedMembers: List,
+ sendRemovedMessage: Boolean,
+ removeMemberMessages: Boolean,
+ ) {
+ val adminKey = configFactory.userGroups?.getClosedGroup(groupSessionId.hexString)?.adminKey
+ if (adminKey == null || adminKey.isEmpty()) {
+ return Log.e("ClosedGroup", "No admin key for group")
+ }
+
+ val groupAuth = OwnedSwarmAuth.ofClosedGroup(groupSessionId, adminKey)
+
+ configFactory.withGroupConfigsOrNull(groupSessionId) { info, members, keys ->
+ // To remove a member from a group, we need to first:
+ // 1. Notify the swarm that this member's key has bene revoked
+ // 2. Send a "kicked" message to a special namespace that the kicked member can still read
+ // 3. Optionally, send "delete member messages" to the group. (So that every device in the group
+ // delete this member's messages locally.)
+ // These three steps will be included in a sequential call as they all need to be done in order.
+ // After these steps are all done, we will do the following:
+ // Update the group configs to remove the member, sync if needed, then
+ // delete the member's messages locally and remotely.
+ val messageSendTimestamp = SnodeAPI.nowWithOffset
+
+ val essentialRequests = buildList {
+ this += SnodeAPI.buildAuthenticatedRevokeSubKeyBatchRequest(
+ groupAdminAuth = groupAuth,
+ subAccountTokens = removedMembers.map(keys::getSubAccountToken)
+ )
+
+ this += Sodium.encryptForMultipleSimple(
+ messages = removedMembers.map{"${it.hexString}-${keys.currentGeneration()}".encodeToByteArray()}.toTypedArray(),
+ recipients = removedMembers.map { it.pubKeyBytes }.toTypedArray(),
+ ed25519SecretKey = adminKey,
+ domain = Sodium.KICKED_DOMAIN
+ ).let { encryptedForMembers ->
+ buildAuthenticatedStoreBatchInfo(
+ namespace = Namespace.REVOKED_GROUP_MESSAGES(),
+ message = SnodeMessage(
+ recipient = groupSessionId.hexString,
+ data = Base64.encodeBytes(encryptedForMembers),
+ ttl = SnodeMessage.CONFIG_TTL,
+ timestamp = messageSendTimestamp
+ ),
+ auth = groupAuth
+ )
+ }
+
+ if (removeMemberMessages) {
+ val adminSignature =
+ SodiumUtilities.sign(buildDeleteMemberContentSignature(
+ memberIds = removedMembers,
+ messageHashes = emptyList(),
+ timestamp = messageSendTimestamp
+ ), adminKey)
+
+ this += buildAuthenticatedStoreBatchInfo(
+ namespace = Namespace.CLOSED_GROUP_MESSAGES(),
+ message = MessageSender.buildWrappedMessageToSnode(
+ destination = Destination.ClosedGroup(groupSessionId.hexString),
+ message = GroupUpdated(GroupUpdateMessage.newBuilder()
+ .setDeleteMemberContent(
+ GroupUpdateDeleteMemberContentMessage.newBuilder()
+ .addAllMemberSessionIds(removedMembers.map { it.hexString })
+ .setAdminSignature(ByteString.copyFrom(adminSignature))
+ )
+ .build()
+ ).apply { sentTimestamp = messageSendTimestamp },
+ isSyncMessage = false
+ ),
+ auth = groupAuth
+ )
+ }
+ }
+
+ val snode = SnodeAPI.getSingleTargetSnode(groupSessionId.hexString).await()
+ val responses = SnodeAPI.getBatchResponse(snode, groupSessionId.hexString, essentialRequests, sequence = true)
+
+ require(responses.results.all { it.code == 200 }) {
+ "Failed to execute essential steps for removing member"
+ }
+
+ // Next step: update group configs, rekey, remove member messages if required
+ val messagesToDelete = mutableListOf()
+ for (member in removedMembers) {
+ members.erase(member.hexString)
+ }
+
+ keys.rekey(info, members)
+
+ if (removeMemberMessages) {
+ val threadId = getThreadId(fromSerialized(groupSessionId.hexString))
+ if (threadId != null) {
+ val component = DatabaseComponent.get(context)
+ val mmsSmsDatabase = component.mmsSmsDatabase()
+ val lokiDb = component.lokiMessageDatabase()
+ for (member in removedMembers) {
+ for (msg in mmsSmsDatabase.getUserMessages(threadId, member.hexString)) {
+ val serverHash = lokiDb.getMessageServerHash(msg.id, msg.isMms)
+ if (serverHash != null) {
+ messagesToDelete.add(serverHash)
+ }
+ }
+
+ deleteMessagesByUser(threadId, member.hexString)
+ }
+ }
+ }
+
+ val requests = buildList {
+ this += "Sync keys config messages" to keys.messageInformation(groupAuth).batch
+ this += "Sync info config messages" to info.messageInformation(messagesToDelete, groupAuth).batch
+ this += "Sync member config messages" to members.messageInformation(messagesToDelete, groupAuth).batch
+ this += "Delete outdated config and member messages" to buildAuthenticatedDeleteBatchInfo(groupAuth, messagesToDelete)
+ }
+
+ val response = SnodeAPI.getBatchResponse(
+ snode = snode,
+ publicKey = groupSessionId.hexString,
+ requests = requests.map { it.second }
+ )
+
+ if (responses.results.any { it.code != 200 }) {
+ val errors = responses.results.mapIndexedNotNull { index, item ->
+ if (item.code != 200) {
+ requests[index].first
+ } else {
+ null
+ }
+ }
+
+ Log.e(TAG, "Failed to execute some steps for removing member: $errors")
+ }
+
+ // Persist the changes
+ configFactory.saveGroupConfigs(keys, info, members)
+
+ if (sendRemovedMessage) {
+ val timestamp = messageSendTimestamp
+ val signature = SodiumUtilities.sign(
+ buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.REMOVED, timestamp),
+ adminKey
+ )
+
+ val updateMessage = GroupUpdateMessage.newBuilder()
+ .setMemberChangeMessage(
+ GroupUpdateMemberChangeMessage.newBuilder()
+ .addAllMemberSessionIds(removedMembers.map { it.hexString })
+ .setType(GroupUpdateMemberChangeMessage.Type.REMOVED)
+ .setAdminSignature(ByteString.copyFrom(signature))
+ )
+ .build()
+ val message = GroupUpdated(
+ updateMessage
+ ).apply { sentTimestamp = timestamp }
+ MessageSender.send(message, Destination.ClosedGroup(groupSessionId.hexString), false)
+ insertGroupInfoChange(message, groupSessionId)
+ }
+ }
+
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(
+ Destination.ClosedGroup(groupSessionId.hexString)
+ )
+ }
+
+ override suspend fun removeMember(
+ groupAccountId: AccountId,
+ removedMembers: List,
+ removeMessages: Boolean
+ ) {
+ doRemoveMember(
+ groupAccountId,
+ removedMembers,
+ sendRemovedMessage = true,
+ removeMemberMessages = removeMessages
+ )
+ }
+
+ override suspend fun handleMemberLeft(message: GroupUpdated, closedGroupId: AccountId) {
+ val userGroups = configFactory.userGroups ?: return
+ val closedGroupHexString = closedGroupId.hexString
+ val closedGroup = userGroups.getClosedGroup(closedGroupId.hexString) ?: return
+ if (closedGroup.hasAdminKey()) {
+ // re-key and do a new config removing the previous member
+ doRemoveMember(
+ closedGroupId,
+ listOf(AccountId(message.sender!!)),
+ sendRemovedMessage = false,
+ removeMemberMessages = false
+ )
+ } else {
+ configFactory.getGroupMemberConfig(closedGroupId)?.use { memberConfig ->
+ // if the leaving member is an admin, disable the group and remove it
+ // This is just to emulate the "existing" group behaviour, this will need to be removed in future
+ if (memberConfig.get(message.sender!!)?.admin == true) {
+ pollerFactory.pollerFor(closedGroupId)?.stop()
+ getThreadId(fromSerialized(closedGroupHexString))?.let { threadId ->
+ deleteConversation(threadId)
+ }
+ configFactory.removeGroup(closedGroupId)
+ }
+ }
+ }
+ }
+
+ override fun handleMemberLeftNotification(message: GroupUpdated, closedGroupId: AccountId) {
+ insertGroupInfoChange(message, closedGroupId)
+ }
+
+ override fun handleKicked(groupAccountId: AccountId) {
+ pollerFactory.pollerFor(groupAccountId)?.stop()
+ }
+
+ override fun leaveGroup(groupSessionId: String, deleteOnLeave: Boolean): Boolean {
+ val closedGroupId = AccountId(groupSessionId)
+ val canSendGroupMessage = configFactory.userGroups?.getClosedGroup(groupSessionId)?.kicked != true
+
+ try {
+ if (canSendGroupMessage) {
+ // throws on unsuccessful send
+ MessageSender.sendNonDurably(
+ message = GroupUpdated(
+ GroupUpdateMessage.newBuilder()
+ .setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance())
+ .build()
+ ),
+ address = fromSerialized(groupSessionId),
+ isSyncMessage = false
+ ).get()
+
+ MessageSender.sendNonDurably(
+ message = GroupUpdated(
+ GroupUpdateMessage.newBuilder()
+ .setMemberLeftNotificationMessage(DataMessage.GroupUpdateMemberLeftNotificationMessage.getDefaultInstance())
+ .build()
+ ),
+ address = fromSerialized(groupSessionId),
+ isSyncMessage = false
+ ).get()
+ }
+
+ pollerFactory.pollerFor(closedGroupId)?.stop()
+ // TODO: set "deleted" and post to -10 group namespace?
+ if (deleteOnLeave) {
+ getThreadId(fromSerialized(groupSessionId))?.let { threadId ->
+ deleteConversation(threadId)
+ }
+ configFactory.removeGroup(closedGroupId)
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
+ }
+ } catch (e: Exception) {
+ Log.e("ClosedGroup", "Failed to send leave group message", e)
+ return false
+ }
+ return true
+ }
+
+ override fun setName(groupSessionId: String, newName: String) {
+ val closedGroupId = AccountId(groupSessionId)
+ val adminKey = configFactory.userGroups?.getClosedGroup(groupSessionId)?.adminKey ?: return
+ if (adminKey.isEmpty()) {
+ return Log.e("ClosedGroup", "No admin key for group")
+ }
+
+ configFactory.withGroupConfigsOrNull(closedGroupId) { info, members, keys ->
+ info.setName(newName)
+ configFactory.saveGroupConfigs(keys, info, members)
+ }
+
+ val groupDestination = Destination.ClosedGroup(groupSessionId)
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(groupDestination)
+ val timestamp = SnodeAPI.nowWithOffset
+ val signature = SodiumUtilities.sign(
+ buildInfoChangeVerifier(GroupUpdateInfoChangeMessage.Type.NAME, timestamp),
+ adminKey
+ )
+
+ val message = GroupUpdated(
+ GroupUpdateMessage.newBuilder()
+ .setInfoChangeMessage(
+ GroupUpdateInfoChangeMessage.newBuilder()
+ .setUpdatedName(newName)
+ .setType(GroupUpdateInfoChangeMessage.Type.NAME)
+ .setAdminSignature(ByteString.copyFrom(signature))
+ )
+ .build()
+ ).apply {
+ sentTimestamp = timestamp
+ }
+ MessageSender.send(message, fromSerialized(groupSessionId))
+ insertGroupInfoChange(message, closedGroupId)
+ }
+
+ override fun sendGroupUpdateDeleteMessage(groupSessionId: String, messageHashes: List): Promise {
+ val closedGroup = configFactory.userGroups?.getClosedGroup(groupSessionId)
+ ?: return Promise.ofFail(NullPointerException("No group found"))
+
+ val keys = configFactory.getGroupKeysConfig(AccountId(groupSessionId))
+ ?: return Promise.ofFail(NullPointerException("No group keys found"))
+
+ val adminKey = if (closedGroup.hasAdminKey()) closedGroup.adminKey else null
+ val authData = closedGroup.authData
+ val auth = if (adminKey != null) {
+ OwnedSwarmAuth.ofClosedGroup(AccountId(groupSessionId), adminKey)
+ } else if (authData != null) {
+ GroupSubAccountSwarmAuth(keys, AccountId(groupSessionId), authData)
+ } else {
+ return Promise.ofFail(IllegalStateException("No auth data nor admin key found"))
+ }
+
+ val groupDestination = Destination.ClosedGroup(groupSessionId)
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(groupDestination)
+ val timestamp = SnodeAPI.nowWithOffset
+ val signature = adminKey?.let { key ->
+ SodiumUtilities.sign(
+ buildDeleteMemberContentSignature(memberIds = emptyList(), messageHashes, timestamp),
+ key
+ )
+ }
+ val message = GroupUpdated(
+ GroupUpdateMessage.newBuilder()
+ .setDeleteMemberContent(
+ GroupUpdateDeleteMemberContentMessage.newBuilder()
+ .addAllMessageHashes(messageHashes)
+ .let {
+ if (signature != null) it.setAdminSignature(ByteString.copyFrom(signature))
+ else it
+ }
+ )
+ .build()
+ ).apply {
+ sentTimestamp = timestamp
+ }
+
+ // Delete might need fake hash?
+ val authenticatedDelete = if (adminKey == null) null else buildAuthenticatedDeleteBatchInfo(auth, messageHashes, required = true)
+ val authenticatedStore = buildAuthenticatedStoreBatchInfo(
+ namespace = Namespace.CLOSED_GROUP_MESSAGES(),
+ message = MessageSender.buildWrappedMessageToSnode(Destination.ClosedGroup(groupSessionId), message, false),
+ auth = auth
+ )
+
+ keys.free()
+
+ // delete only present when admin
+ val storeIndex = if (adminKey != null) 1 else 0
+ return SnodeAPI.getSingleTargetSnode(groupSessionId).bind { snode ->
+ SnodeAPI.getRawBatchResponse(
+ snode,
+ groupSessionId,
+ listOfNotNull(authenticatedDelete, authenticatedStore),
+ sequence = true
+ )
+ }.map { rawResponse ->
+ val results = (rawResponse["results"] as ArrayList)[storeIndex] as Map
+ val hash = results["hash"] as? String
+ message.serverHash = hash
+ MessageSender.handleSuccessfulMessageSend(message, groupDestination, false)
+ }
+ }
+
override fun setServerCapabilities(server: String, capabilities: List) {
return DatabaseComponent.get(context).lokiAPIDatabase().setServerCapabilities(server, capabilities)
}
@@ -1144,10 +2212,14 @@ open class Storage(
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)
@@ -1208,6 +2280,10 @@ open class Storage(
return DatabaseComponent.get(context).recipientDatabase().getRecipientSettings(address).orNull()
}
+ override fun hasAutoDownloadFlagBeenSet(recipient: Recipient): Boolean {
+ return DatabaseComponent.get(context).recipientDatabase().isAutoDownloadFlagSet(recipient)
+ }
+
override fun addLibSessionContacts(contacts: List, timestamp: Long) {
val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
val moreContacts = contacts.filter { contact ->
@@ -1301,6 +2377,18 @@ open class Storage(
}
}
+ override fun shouldAutoDownloadAttachments(recipient: Recipient): Boolean {
+ return recipient.autoDownloadAttachments
+ }
+
+ override fun setAutoDownloadAttachments(
+ recipient: Recipient,
+ shouldAutoDownloadAttachments: Boolean
+ ) {
+ val recipientDb = DatabaseComponent.get(context).recipientDatabase()
+ recipientDb.setAutoDownloadAttachments(recipient, shouldAutoDownloadAttachments)
+ }
+
override fun setRecipientHash(recipient: Recipient, recipientHash: String?) {
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
recipientDb.setRecipientHash(recipient, recipientHash)
@@ -1340,19 +2428,28 @@ open class Storage(
}
} 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 (
- priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE
- )
- groups.set(newGroupInfo)
+ when {
+ threadRecipient.isLegacyClosedGroupRecipient -> {
+ threadRecipient.address.serialize()
+ .let(GroupUtil::doubleDecodeGroupId)
+ .let(groups::getOrConstructLegacyGroupInfo)
+ .copy (priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE)
+ .let(groups::set)
+ }
+ threadRecipient.isClosedGroupV2Recipient -> {
+ val newGroupInfo = groups.getOrConstructClosedGroup(threadRecipient.address.serialize()).copy (
+ priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE
+ )
+ groups.set(newGroupInfo)
+ }
+ 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 (
+ priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE
+ )
+ groups.set(newGroupInfo)
+ }
}
}
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
@@ -1405,6 +2502,29 @@ open class Storage(
}
}
+ override fun clearMessages(threadID: Long, fromUser: Address?): Boolean {
+ val smsDb = DatabaseComponent.get(context).smsDatabase()
+ val mmsDb = DatabaseComponent.get(context).mmsDatabase()
+ val threadDb = DatabaseComponent.get(context).threadDatabase()
+ if (fromUser == null) {
+ // this deletes all *from* thread, not deleting the actual thread
+ smsDb.deleteThread(threadID)
+ mmsDb.deleteThread(threadID) // threadDB update called from within
+ } else {
+ // this deletes all *from* thread, not deleting the actual thread
+ smsDb.deleteMessagesFrom(threadID, fromUser.serialize())
+ mmsDb.deleteMessagesFrom(threadID, fromUser.serialize())
+ threadDb.update(threadID, false)
+ }
+ return true
+ }
+
+ override fun clearMedia(threadID: Long, fromUser: Address?): Boolean {
+ val mmsDb = DatabaseComponent.get(context).mmsDatabase()
+ mmsDb.deleteMediaFor(threadID, fromUser?.serialize())
+ return true
+ }
+
override fun getAttachmentDataUri(attachmentId: AttachmentId): Uri {
return PartAuthority.getAttachmentDataUri(attachmentId)
}
@@ -1520,6 +2640,12 @@ open class Storage(
}
setRecipientApproved(sender, true)
setRecipientApprovedMe(sender, true)
+
+ // Also update the config about this contact
+ configFactory.contacts?.upsertContact(sender.address.serialize()) {
+ approved = true
+ approvedMe = true
+ }
val message = IncomingMediaMessage(
sender.address,
response.sentTimestamp!!,
@@ -1543,7 +2669,7 @@ open class Storage(
}
override fun getRecipientApproved(address: Address): Boolean {
- return DatabaseComponent.get(context).recipientDatabase().getApproved(address)
+ return address.isClosedGroupV2 || DatabaseComponent.get(context).recipientDatabase().getApproved(address)
}
override fun setRecipientApproved(recipient: Recipient, approved: Boolean) {
@@ -1713,7 +2839,7 @@ open class Storage(
override fun getExpirationConfiguration(threadId: Long): ExpirationConfiguration? {
val recipient = getRecipientForThread(threadId) ?: return null
- val dbExpirationMetadata = DatabaseComponent.get(context).expirationConfigurationDatabase().getExpirationConfiguration(threadId) ?: return null
+ val dbExpirationMetadata = DatabaseComponent.get(context).expirationConfigurationDatabase().getExpirationConfiguration(threadId)
return when {
recipient.isLocalNumber -> configFactory.user?.getNtsExpiry()
recipient.isContactRecipient -> {
@@ -1721,14 +2847,24 @@ open class Storage(
recipient.address.serialize().takeIf { it.startsWith(IdPrefix.STANDARD.value) }
?.let { configFactory.contacts?.get(it)?.expiryMode }
}
- recipient.isClosedGroupRecipient -> {
+ recipient.isClosedGroupV2Recipient -> {
+ configFactory.getGroupInfoConfig(AccountId(recipient.address.serialize()))?.getExpiryTimer()?.let {
+ if (it == 0L) ExpiryMode.NONE else ExpiryMode.AfterSend(it)
+ }
+ }
+ recipient.isLegacyClosedGroupRecipient -> {
// read it from group config if exists
GroupUtil.doubleDecodeGroupId(recipient.address.serialize())
.let { configFactory.userGroups?.getLegacyGroupInfo(it) }
?.run { disappearingTimer.takeIf { it != 0L }?.let(ExpiryMode::AfterSend) ?: ExpiryMode.NONE }
}
else -> null
- }?.let { ExpirationConfiguration(threadId, it, dbExpirationMetadata.updatedTimestampMs) }
+ }?.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) {
@@ -1744,12 +2880,17 @@ open class Storage(
DatabaseComponent.get(context).lokiAPIDatabase().setLastLegacySenderAddress(recipient.address.serialize(), null)
}
- if (recipient.isClosedGroupRecipient) {
+ if (recipient.isLegacyClosedGroupRecipient) {
val userGroups = configFactory.userGroups ?: return
val groupPublicKey = GroupUtil.addressToGroupAccountId(recipient.address)
val groupInfo = userGroups.getLegacyGroupInfo(groupPublicKey)
?.copy(disappearingTimer = expiryMode.expirySeconds) ?: return
userGroups.set(groupInfo)
+ } else if (recipient.isClosedGroupV2Recipient) {
+ val groupSessionId = AccountId(recipient.address.serialize())
+ val groupInfo = configFactory.getGroupInfoConfig(groupSessionId) ?: return
+ groupInfo.setExpiryTimer(expiryMode.expirySeconds)
+ configFactory.persist(groupInfo, SnodeAPI.nowWithOffset, groupSessionId.hexString)
} else if (recipient.isLocalNumber) {
val user = configFactory.user ?: return
user.setNtsExpiry(expiryMode)
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 f48686aded..bd38a59a51 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 class ThreadDatabase extends Database {
.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 + " " +
@@ -279,9 +284,9 @@ public class ThreadDatabase extends Database {
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, true);
+ update(threadId, false);
notifyConversationListeners(threadId);
}
} finally {
@@ -293,8 +298,8 @@ public class ThreadDatabase extends Database {
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);
- update(threadId, false, true);
+ DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp, false);
+ update(threadId, false);
notifyConversationListeners(threadId);
}
@@ -428,32 +433,6 @@ public class ThreadDatabase extends Database {
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;
@@ -492,13 +471,15 @@ public class ThreadDatabase extends Database {
}
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";
@@ -722,19 +703,14 @@ public class ThreadDatabase extends Database {
notifyConversationListListeners();
}
- public boolean update(long threadId, boolean unarchive, boolean shouldDeleteOnEmpty) {
+ public boolean update(long threadId, boolean unarchive) {
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
long count = mmsSmsDatabase.getConversationCount(threadId);
- boolean shouldDeleteEmptyThread = shouldDeleteOnEmpty && possibleToDeleteThreadOnEmpty(threadId);
+ MmsSmsDatabase.Reader reader = null;
- if (count == 0 && shouldDeleteEmptyThread) {
- deleteThread(threadId);
- notifyConversationListListeners();
- return true;
- }
-
- try (MmsSmsDatabase.Reader reader = mmsSmsDatabase.readerFor(mmsSmsDatabase.getConversationSnippet(threadId))) {
+ try {
+ reader = mmsSmsDatabase.readerFor(mmsSmsDatabase.getConversationSnippet(threadId));
MessageRecord record = null;
if (reader != null) {
record = reader.getNext();
@@ -748,11 +724,7 @@ public class ThreadDatabase extends Database {
record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount());
return false;
} else {
- if (shouldDeleteEmptyThread) {
- deleteThread(threadId);
- return true;
- }
- // todo: add empty snippet that clears existing data
+ updateThread(threadId, 0, "", null, System.currentTimeMillis(), 0, 0, 0, false, 0, 0);
return false;
}
} finally {
@@ -800,9 +772,8 @@ public class ThreadDatabase extends Database {
return setLastSeen(threadId, lastSeenTime);
}
- private boolean possibleToDeleteThreadOnEmpty(long threadId) {
- Recipient threadRecipient = getRecipientForThreadId(threadId);
- return threadRecipient != null && !threadRecipient.isCommunityRecipient();
+ private boolean deleteThreadOnEmpty(long threadId) {
+ return false;
}
private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) {
@@ -840,12 +811,14 @@ public class ThreadDatabase extends Database {
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;
@@ -923,6 +896,7 @@ public class ThreadDatabase extends Database {
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;
@@ -940,7 +914,7 @@ public class ThreadDatabase extends Database {
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 b6ebd6db84..0a08ccd357 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 class SQLCipherOpenHelper extends SQLiteOpenHelper {
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 class SQLCipherOpenHelper extends SQLiteOpenHelper {
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 a61b78b4b6..67382e9851 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
@@ -118,7 +118,7 @@ public abstract class MessageRecord extends DisplayRecord {
public SpannableString getDisplayBody(@NonNull Context context) {
if (isGroupUpdateMessage()) {
UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(getBody());
- return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing()));
+ return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing(), true));
} else if (isExpirationTimerUpdate()) {
int seconds = (int) (getExpiresIn() / 1000);
boolean isGroup = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(getThreadId()).isGroupRecipient();
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 96cd5e8881..ae11c1c0df 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
@@ -30,6 +30,9 @@ import android.text.TextUtils;
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.recipients.Recipient;
@@ -46,39 +49,41 @@ import network.loki.messenger.R;
*/
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;
+ 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)
- {
- 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;
- }
+ 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;
@@ -115,6 +120,18 @@ public class ThreadRecord extends DisplayRecord {
@Override
public SpannableString getDisplayBody(@NonNull Context context) {
if (isGroupUpdateMessage()) {
+ String body = getBody();
+ if (!body.isEmpty()) {
+ UpdateMessageData updateMessageData = UpdateMessageData.fromJSON(body);
+ if (updateMessageData != null) {
+ return emphasisAdded(
+ UpdateMessageBuilder.buildGroupUpdateMessage(context, updateMessageData, null, isOutgoing(), false)
+ .toString()
+ );
+ } else {
+ return null;
+ }
+ }
return emphasisAdded(context.getString(R.string.groupUpdated));
} else if (isOpenGroupInvitation()) {
return emphasisAdded(context.getString(R.string.communityInvitation));
@@ -221,4 +238,30 @@ public class ThreadRecord extends DisplayRecord {
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 750b3e20c7..7cfa1a6f9d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt
@@ -76,7 +76,7 @@ class DebugMenuViewModel @Inject constructor(
// clear remote and local data, then restart the app
viewModelScope.launch {
try {
- ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(application).get()
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(application)
} catch (e: Exception) {
// we can ignore fails here as we might be switching environments before the user gets a public key
}
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 a9a72e7665..ea81223a33 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt
@@ -1,14 +1,20 @@
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.utilities.AppTextSecurePreferences
import org.session.libsession.utilities.TextSecurePreferences
+import org.session.libsession.utilities.Toaster
import org.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.repository.DefaultConversationRepository
+import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@@ -21,6 +27,17 @@ abstract class AppModule {
abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository
}
+@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
@InstallIn(SingletonComponent::class)
interface AppComponent {
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 da15c2f6b4..8e0c735770 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 505a7939a8..058e276d8c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt
@@ -2,16 +2,27 @@ package org.thoughtcrime.securesms.dependencies
import android.content.Context
import android.os.Trace
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import network.loki.messenger.libsession_util.Config
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.UserGroupsConfig
import network.loki.messenger.libsession_util.UserProfile
+import network.loki.messenger.libsession_util.util.Sodium
+import org.session.libsession.messaging.messages.Destination
+import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsession.utilities.ConfigFactoryUpdateListener
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage
+import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
+import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.database.ConfigDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
import org.thoughtcrime.securesms.groups.GroupManager
@@ -20,6 +31,7 @@ import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
class ConfigFactory(
private val context: Context,
private val configDatabase: ConfigDatabase,
+ /** */
private val maybeGetUserInfo: () -> Pair?
) :
ConfigFactoryProtocol {
@@ -28,10 +40,10 @@ class ConfigFactory(
// 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)
+ const val configChangeBufferPeriod: Long = (2 * 60 * 1000)
}
- fun keyPairChanged() { // this should only happen restoring or clearing data
+ fun keyPairChanged() { // this should only happen restoring or clearing datac
_userConfig?.free()
_contacts?.free()
_convoVolatileConfig?.free()
@@ -52,6 +64,13 @@ class ConfigFactory(
private val isConfigForcedOn by lazy { TextSecurePreferences.hasForcedNewConfig(context) }
private val listeners: MutableList = mutableListOf()
+
+ private val _configUpdateNotifications = MutableSharedFlow(
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ override val configUpdateNotifications get() = _configUpdateNotifications
+
fun registerListener(listener: ConfigFactoryUpdateListener) {
listeners += listener
}
@@ -60,7 +79,7 @@ class ConfigFactory(
listeners -= listener
}
- private inline fun synchronizedWithLog(lock: Any, body: ()->T): T {
+ private inline fun synchronizedWithLog(lock: Any, body: () -> T): T {
Trace.beginSection("synchronizedWithLog")
val result = synchronized(lock) {
body()
@@ -146,6 +165,101 @@ class ConfigFactory(
_userGroups
}
+ private fun getGroupInfo(groupSessionId: AccountId) = userGroups?.getClosedGroup(groupSessionId.hexString)
+
+ override fun getGroupInfoConfig(groupSessionId: AccountId): GroupInfoConfig? = getGroupInfo(groupSessionId)?.let { groupInfo ->
+ // get any potential initial dumps
+ val dump = configDatabase.retrieveConfigAndHashes(
+ ConfigDatabase.INFO_VARIANT,
+ groupSessionId.hexString
+ ) ?: byteArrayOf()
+
+ GroupInfoConfig.newInstance(groupSessionId.pubKeyBytes, groupInfo.adminKey, dump)
+ }
+
+ override fun getGroupKeysConfig(groupSessionId: AccountId,
+ info: GroupInfoConfig?,
+ members: GroupMembersConfig?,
+ free: Boolean): GroupKeysConfig? = getGroupInfo(groupSessionId)?.let { groupInfo ->
+ // Get the user info or return early
+ val (userSk, _) = maybeGetUserInfo() ?: return@let null
+
+ // Get the group info or return early
+ val usedInfo = info ?: getGroupInfoConfig(groupSessionId) ?: return@let null
+
+ // Get the group members or return early
+ val usedMembers = members ?: getGroupMemberConfig(groupSessionId) ?: return@let null
+
+ // Get the dump or empty
+ val dump = configDatabase.retrieveConfigAndHashes(
+ ConfigDatabase.KEYS_VARIANT,
+ groupSessionId.hexString
+ ) ?: byteArrayOf()
+
+ // Put it all together
+ val keys = GroupKeysConfig.newInstance(
+ userSk,
+ groupSessionId.pubKeyBytes,
+ groupInfo.adminKey,
+ dump,
+ usedInfo,
+ usedMembers
+ )
+ if (free) {
+ info?.free()
+ members?.free()
+ }
+ if (usedInfo !== info) usedInfo.free()
+ if (usedMembers !== members) usedMembers.free()
+ keys
+ }
+
+ override fun getGroupMemberConfig(groupSessionId: AccountId): GroupMembersConfig? = getGroupInfo(groupSessionId)?.let { groupInfo ->
+ // Get initial dump if we have one
+ val dump = configDatabase.retrieveConfigAndHashes(
+ ConfigDatabase.MEMBER_VARIANT,
+ groupSessionId.hexString
+ ) ?: byteArrayOf()
+
+ GroupMembersConfig.newInstance(
+ groupSessionId.pubKeyBytes,
+ groupInfo.adminKey,
+ dump
+ )
+ }
+
+ override fun constructGroupKeysConfig(
+ groupSessionId: AccountId,
+ info: GroupInfoConfig,
+ members: GroupMembersConfig
+ ): GroupKeysConfig? = getGroupInfo(groupSessionId)?.let { groupInfo ->
+ val (userSk, _) = maybeGetUserInfo() ?: return null
+ GroupKeysConfig.newInstance(
+ userSk,
+ groupSessionId.pubKeyBytes,
+ groupInfo.adminKey,
+ info = info,
+ members = members
+ )
+ }
+
+ override fun userSessionId(): AccountId? {
+ return maybeGetUserInfo()?.second?.let(::AccountId)
+ }
+
+ override fun maybeDecryptForUser(encoded: ByteArray, domain: String, closedGroupSessionId: AccountId): ByteArray? {
+ val secret = maybeGetUserInfo()?.first ?: run {
+ Log.e("ConfigFactory", "No user ed25519 secret key decrypting a message for us")
+ return null
+ }
+ return Sodium.decryptForMultipleSimple(
+ encoded = encoded,
+ ed25519SecretKey = secret,
+ domain = domain,
+ senderPubKey = Sodium.ed25519PkToCurve25519(closedGroupSessionId.pubKeyBytes)
+ )
+ }
+
override fun getUserConfigs(): List =
listOfNotNull(user, contacts, convoVolatile, userGroups)
@@ -153,13 +267,23 @@ class ConfigFactory(
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)
+ configDatabase.storeConfig(
+ SharedConfigMessage.Kind.USER_PROFILE.name,
+ publicKey,
+ dumped,
+ timestamp
+ )
}
private fun persistContactsConfigDump(timestamp: Long) = synchronized(contactsLock) {
val dumped = contacts?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return
- configDatabase.storeConfig(SharedConfigMessage.Kind.CONTACTS.name, publicKey, dumped, timestamp)
+ configDatabase.storeConfig(
+ SharedConfigMessage.Kind.CONTACTS.name,
+ publicKey,
+ dumped,
+ timestamp
+ )
}
private fun persistConvoVolatileConfigDump(timestamp: Long) = synchronized(convoVolatileLock) {
@@ -176,21 +300,52 @@ class ConfigFactory(
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)
+ configDatabase.storeConfig(
+ SharedConfigMessage.Kind.GROUPS.name,
+ publicKey,
+ dumped,
+ timestamp
+ )
}
- override fun persist(forConfigObject: ConfigBase, timestamp: Long) {
+ fun persistGroupConfigDump(forConfigObject: ConfigBase, groupSessionId: AccountId, timestamp: Long) = synchronized(userGroupsLock) {
+ val dumped = forConfigObject.dump()
+ val variant = when (forConfigObject) {
+ is GroupMembersConfig -> ConfigDatabase.MEMBER_VARIANT
+ is GroupInfoConfig -> ConfigDatabase.INFO_VARIANT
+ else -> throw Exception("Shouldn't be called")
+ }
+ configDatabase.storeConfig(
+ variant,
+ groupSessionId.hexString,
+ dumped,
+ timestamp
+ )
+ _configUpdateNotifications.tryEmit(Unit)
+ }
+
+ override fun persist(forConfigObject: Config, timestamp: Long, forPublicKey: String?) {
try {
+ if (forConfigObject is ConfigBase && !forConfigObject.needsDump() || forConfigObject is GroupKeysConfig && !forConfigObject.needsDump()) {
+ Log.d("ConfigFactory", "Don't need to persist ${forConfigObject.javaClass} for $forPublicKey pubkey")
+ return
+ }
+
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)
+ is GroupMembersConfig -> persistGroupConfigDump(forConfigObject, AccountId(forPublicKey!!), timestamp)
+ is GroupInfoConfig -> persistGroupConfigDump(forConfigObject, AccountId(forPublicKey!!), timestamp)
else -> throw UnsupportedOperationException("Can't support type of ${forConfigObject::class.simpleName} yet")
}
+
+ _configUpdateNotifications.tryEmit(Unit)
} catch (e: Exception) {
Log.e("Loki", "failed to persist ${forConfigObject.javaClass.simpleName}", e)
}
@@ -207,23 +362,25 @@ class ConfigFactory(
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 =
+ get(context).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return false
// Not handling the `hidden` behaviour for communities so just indicate the existence
return (userGroups.getCommunityInfo(openGroup.server, openGroup.room) != null)
- }
- else if (groupPublicKey != null) {
+ } else if (groupPublicKey != null) {
val userGroups = userGroups ?: return false
// Not handling the `hidden` behaviour for legacy groups so just indicate the existence
- return (userGroups.getLegacyGroupInfo(groupPublicKey) != null)
- }
- else if (publicKey == userPublicKey) {
+ return if (groupPublicKey.startsWith(IdPrefix.GROUP.value)) {
+ userGroups.getClosedGroup(groupPublicKey) != null
+ } else {
+ userGroups.getLegacyGroupInfo(groupPublicKey) != null
+ }
+ } else if (publicKey == userPublicKey) {
val user = user ?: return false
return (!visibleOnly || user.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN)
- }
- else if (publicKey != null) {
+ } else if (publicKey != null) {
val contacts = contacts ?: return false
val targetContact = contacts.get(publicKey) ?: return false
@@ -233,10 +390,44 @@ class ConfigFactory(
return false
}
- override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean {
- val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey)
+ 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 - ConfigFactory.configChangeBufferPeriod))
+ return (changeTimestampMs >= (lastUpdateTimestampMs - configChangeBufferPeriod))
+ }
+
+ override fun saveGroupConfigs(
+ groupKeys: GroupKeysConfig,
+ groupInfo: GroupInfoConfig,
+ groupMembers: GroupMembersConfig
+ ) {
+ val pubKey = groupInfo.id().hexString
+ val timestamp = SnodeAPI.nowWithOffset
+
+ // this would be nicer with a .any iteration or something but the base types don't line up
+ val anyNeedDump = groupKeys.needsDump() || groupInfo.needsDump() || groupMembers.needsDump()
+ if (!anyNeedDump) return Log.d("ConfigFactory", "Group config doesn't need dump, skipping")
+ else Log.d("ConfigFactory", "Group config needs dump, storing and notifying")
+
+ configDatabase.storeGroupConfigs(pubKey, groupKeys.dump(), groupInfo.dump(), groupMembers.dump(), timestamp)
+ _configUpdateNotifications.tryEmit(Unit)
+ }
+
+ override fun removeGroup(closedGroupId: AccountId) {
+ val groups = userGroups ?: return
+ groups.eraseClosedGroup(closedGroupId.hexString)
+ persist(groups, SnodeAPI.nowWithOffset)
+ configDatabase.deleteGroupConfigs(closedGroupId)
+ }
+
+ override fun scheduleUpdate(destination: Destination) {
+ // there's probably a better way to do this
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(destination)
}
}
\ 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 0000000000..5a1dee052a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseBindings.kt
@@ -0,0 +1,17 @@
+package org.thoughtcrime.securesms.dependencies
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.session.libsession.database.StorageProtocol
+import org.thoughtcrime.securesms.database.Storage
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class DatabaseBindings {
+
+ @Binds
+ abstract fun bindStorageProtocol(storage: Storage): StorageProtocol
+
+}
\ 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 c037f3b27a..82c4925b97 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
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 30fb40d89a..85d2bc27d2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt
@@ -141,8 +141,13 @@ object DatabaseModule {
@Provides
@Singleton
- fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage {
- val storage = Storage(context,openHelper, configFactory)
+ fun provideStorage(@ApplicationContext context: Context,
+ openHelper: SQLCipherOpenHelper,
+ configFactory: ConfigFactory,
+ threadDatabase: ThreadDatabase,
+ pollerFactory: PollerFactory,
+ ): Storage {
+ val storage = Storage(context, openHelper, configFactory, pollerFactory)
threadDatabase.setUpdateListener(storage)
return storage
}
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 0000000000..27dbdec92c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/PollerFactory.kt
@@ -0,0 +1,48 @@
+package org.thoughtcrime.securesms.dependencies
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.plus
+import network.loki.messenger.libsession_util.util.GroupInfo
+import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPoller
+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 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
+ if (configFactory.userGroups?.getClosedGroup(sessionId.hexString)?.invited != false) return null
+
+ return pollers.getOrPut(sessionId) {
+ ClosedGroupPoller(scope + SupervisorJob(), executor, sessionId, configFactory)
+ }
+ }
+
+ fun startAll() {
+ configFactory.userGroups?.allClosedGroupInfo()?.filterNot(GroupInfo.ClosedGroupInfo::invited)?.forEach {
+ pollerFor(it.groupAccountId)?.start()
+ }
+ }
+
+ fun stopAll() {
+ pollers.forEach { (_, poller) ->
+ poller.stop()
+ }
+ }
+
+ fun updatePollers() {
+ val currentGroups = configFactory.userGroups?.allClosedGroupInfo()?.filterNot(GroupInfo.ClosedGroupInfo::invited) ?: return
+ 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 cd4b071338..7681536af3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt
@@ -6,16 +6,24 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.GlobalScope
import org.session.libsession.utilities.ConfigFactoryUpdateListener
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.database.ConfigDatabase
+import javax.inject.Named
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object SessionUtilModule {
+ const val POLLER_SCOPE = "poller_coroutine_scope"
+
private fun maybeUserEdSecretKey(context: Context): ByteArray? {
val edKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return null
return edKey.secretKey.asBytes
@@ -33,4 +41,19 @@ object SessionUtilModule {
registerListener(context as ConfigFactoryUpdateListener)
}
+ @Provides
+ @Named(POLLER_SCOPE)
+ fun providePollerScope(@ApplicationContext applicationContext: Context): CoroutineScope = GlobalScope
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Provides
+ @Named(POLLER_SCOPE)
+ fun provideExecutor(): CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1)
+
+ @Provides
+ @Singleton
+ fun providePollerFactory(@Named(POLLER_SCOPE) coroutineScope: CoroutineScope,
+ @Named(POLLER_SCOPE) dispatcher: CoroutineDispatcher,
+ configFactory: ConfigFactory) = PollerFactory(coroutineScope, dispatcher, configFactory)
+
}
\ 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 adeeeb91fa..96c0c7c882 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,16 +33,9 @@ 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.isLegacyClosedGroup) return
val storage = MessagingModuleConfiguration.shared.storage
val threadId = storage.getThreadId(group.encodedId) ?: return
val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId())
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 0f562c80b7..348c9caa57 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,44 @@
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
- }
+ return ComposeView(requireContext()).apply {
+ val delegate = (parentFragment as? StartConversationDelegate)
+ ?: (activity as? StartConversationDelegate)
+ ?: NullStartConversationDelegate
- 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() }
+ setContent {
+ SessionMaterialTheme {
+ CreateGroupScreen(
+ onNavigateToConversationScreen = { threadID ->
+ startActivity(
+ Intent(requireContext(), ConversationActivityV2::class.java)
+ .putExtra(ConversationActivityV2.THREAD_ID, threadID)
+ )
+ },
+ onBack = delegate::onDialogBackPressed,
+ onClose = delegate::onDialogClosePressed
+ )
+ }
}
}
- 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)
- }
- }
- 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 b3dbb49384..04967fe91b 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,93 @@
package org.thoughtcrime.securesms.groups
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
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.thoughtcrime.securesms.dependencies.ConfigFactory
import javax.inject.Inject
+
@HiltViewModel
class CreateGroupViewModel @Inject constructor(
- private val threadDb: ThreadDatabase,
- private val textSecurePreferences: TextSecurePreferences
-) : ViewModel() {
+ configFactory: ConfigFactory,
+ private val storage: StorageProtocol,
+): ViewModel() {
+ // Child view model to handle contact selection logic
+ val selectContactsViewModel = SelectContactsViewModel(
+ storage = storage,
+ configFactory = configFactory,
+ excludingAccountIDs = emptySet(),
+ scope = viewModelScope,
+ )
- private val _recipients = MutableLiveData>()
- val recipients: LiveData> = _recipients
+ // Input: group name
+ private val mutableGroupName = MutableStateFlow("")
+ private val mutableGroupNameError = MutableStateFlow("")
- init {
+ // Output: group name
+ val groupName: StateFlow get() = mutableGroupName
+ val groupNameError: StateFlow get() = mutableGroupNameError
+
+ // Output: loading state
+ private val mutableIsLoading = MutableStateFlow(false)
+ val isLoading: StateFlow get() = mutableIsLoading
+
+ // 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
- }
- withContext(Dispatchers.Main) {
- _recipients.value = recipients
- .filter { !it.isGroupRecipient && it.hasApprovedMe() && it.address.serialize() != textSecurePreferences.getLocalNumber() }
- }
+ 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 recipient = withContext(Dispatchers.Default) {
+ storage.createNewGroup(groupName, "", selected)
+ }
+
+ if (recipient.isPresent) {
+ val threadId = withContext(Dispatchers.Default) { storage.getOrCreateThreadIdFor(recipient.get().address) }
+ mutableEvents.emit(CreateGroupEvent.NavigateToConversation(threadId))
+ } else {
+ mutableEvents.emit(CreateGroupEvent.Error("Failed to create group"))
+ }
+
+ 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 0000000000..7a86f2b553
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupActivity.kt
@@ -0,0 +1,35 @@
+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.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(
+ groupSessionId = 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/EditGroupInviteViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupInviteViewModel.kt
new file mode 100644
index 0000000000..c997575656
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupInviteViewModel.kt
@@ -0,0 +1,31 @@
+package org.thoughtcrime.securesms.groups
+
+import androidx.lifecycle.ViewModel
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.android.lifecycle.HiltViewModel
+import org.session.libsession.database.StorageProtocol
+import org.session.libsession.messaging.contacts.Contact
+
+@HiltViewModel(assistedFactory = EditGroupInviteViewModel.Factory::class)
+class EditGroupInviteViewModel @AssistedInject constructor(
+ @Assisted private val groupSessionId: String,
+ private val storage: StorageProtocol
+): ViewModel() {
+
+ @AssistedFactory
+ interface Factory {
+ fun create(groupSessionId: String): EditGroupInviteViewModel
+ }
+
+}
+
+data class EditGroupInviteState(
+ val viewState: EditGroupInviteViewState,
+)
+
+data class EditGroupInviteViewState(
+ val currentMembers: List,
+ val allContacts: Set
+)
\ 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 0000000000..dbc22057e9
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt
@@ -0,0 +1,260 @@
+package org.thoughtcrime.securesms.groups
+
+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 kotlinx.coroutines.DelicateCoroutinesApi
+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.filter
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+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.contacts.Contact
+import org.session.libsession.messaging.jobs.InviteContactsJob
+import org.session.libsession.messaging.jobs.JobQueue
+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 groupSessionId: String,
+ private val storage: StorageProtocol,
+ configFactory: ConfigFactory
+) : ViewModel() {
+ // Input/Output state
+ private val mutableEditingName = MutableStateFlow(null)
+
+ // Output: The name of the group being edited. Null if it's not in edit mode, not to be confused
+ // with empty string, where it's a valid editing state.
+ val editingName: StateFlow get() = mutableEditingName
+
+ // Output: the source-of-truth group information. Other states are derived from this.
+ private val groupInfo: StateFlow>?> =
+ configFactory.configUpdateNotifications
+ .onStart { emit(Unit) }
+ .map {
+ withContext(Dispatchers.Default) {
+ val currentUserId = checkNotNull(storage.getUserPublicKey()) {
+ "User public key is null"
+ }
+
+ val displayInfo = storage.getClosedGroupDisplayInfo(groupSessionId)
+ ?: return@withContext null
+
+ val members = storage.getMembers(groupSessionId)
+ .asSequence()
+ .filter { !it.removed }
+ .mapTo(mutableListOf()) { member ->
+ createGroupMember(
+ member = member,
+ myAccountId = currentUserId,
+ amIAdmin = displayInfo.isUserAdmin,
+ )
+ }
+
+ sortMembers(members, currentUserId)
+
+ displayInfo to members
+ }
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, null)
+
+ // Output: whether the group name can be edited. This is true if the group is loaded successfully.
+ val canEditGroupName: StateFlow = groupInfo
+ .map { it != null }
+ .stateIn(viewModelScope, SharingStarted.Eagerly, false)
+
+ // Output: The name of the group. This is the current name of the group, not the name being edited.
+ val groupName: StateFlow = groupInfo
+ .map { it?.first?.name.orEmpty() }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
+
+ // Output: the list of the members and their state in the group.
+ val members: StateFlow> = groupInfo
+ .map { it?.second.orEmpty() }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
+
+ // Output: whether we should show the "add members" button
+ val showAddMembers: StateFlow = groupInfo
+ .map { it?.first?.isUserAdmin == true }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
+
+ // Output: Intermediate states
+ private val mutableInProgress = MutableStateFlow(false)
+ val inProgress: StateFlow get() = mutableInProgress
+
+ // Output: errors
+ private val mutableError = MutableStateFlow(null)
+ val error: StateFlow get() = mutableError
+
+ // Output:
+ val excludingAccountIDsFromContactSelection: Set
+ get() = groupInfo.value?.second?.mapTo(hashSetOf()) { it.accountId }.orEmpty()
+
+ private fun createGroupMember(
+ member: GroupMember,
+ myAccountId: String,
+ amIAdmin: Boolean,
+ ): GroupMemberState {
+ var status = ""
+ var highlightStatus = false
+ var name = member.name.orEmpty()
+
+ when {
+ member.sessionId == myAccountId -> {
+ name = "You"
+ }
+
+ member.promotionPending -> {
+ status = "Promotion sent"
+ }
+
+ member.invitePending -> {
+ status = "Invite Sent"
+ }
+
+ member.inviteFailed -> {
+ status = "Invite Failed"
+ highlightStatus = true
+ }
+
+ member.promotionFailed -> {
+ status = "Promotion Failed"
+ highlightStatus = true
+ }
+ }
+
+ return GroupMemberState(
+ accountId = member.sessionId,
+ name = name,
+ canRemove = amIAdmin && member.sessionId != myAccountId && !member.isAdminOrBeingPromoted,
+ canPromote = amIAdmin && member.sessionId != myAccountId && !member.isAdminOrBeingPromoted,
+ canResendPromotion = amIAdmin && member.sessionId != myAccountId && member.promotionFailed,
+ canResendInvite = amIAdmin && member.sessionId != myAccountId && member.inviteFailed,
+ status = status,
+ highlightStatus = highlightStatus
+ )
+ }
+
+ private fun sortMembers(members: MutableList, currentUserId: String) {
+ // Order or members:
+ // 1. Current user always comes first
+ // 2. Then sort by name
+ // 3. Then sort by account ID
+ members.sortWith(
+ compareBy(
+ { it.accountId != currentUserId },
+ { it.name },
+ { it.accountId }
+ )
+ )
+ }
+
+ fun onContactSelected(contacts: Set) {
+ viewModelScope.launch(Dispatchers.Default) {
+ storage.inviteClosedGroupMembers(groupSessionId, contacts.map { it.accountID })
+ }
+ }
+
+ fun onResendInviteClicked(contactSessionId: String) {
+ viewModelScope.launch(Dispatchers.Default) {
+ JobQueue.shared.add(InviteContactsJob(groupSessionId, arrayOf(contactSessionId)))
+ }
+ }
+
+ fun onPromoteContact(memberSessionId: String) {
+ viewModelScope.launch(Dispatchers.Default) {
+ storage.promoteMember(AccountId(groupSessionId), listOf(AccountId(memberSessionId)))
+ }
+ }
+
+ fun onRemoveContact(contactSessionId: String, removeMessages: Boolean) {
+ viewModelScope.launch {
+ mutableInProgress.value = true
+
+ // We need to use GlobalScope here because we don't want
+ // "removeMember" to be cancelled when the view model is cleared. This operation
+ // is expected to complete even if the view model is cleared.
+ val task = GlobalScope.launch {
+ storage.removeMember(
+ groupAccountId = AccountId(groupSessionId),
+ removedMembers = listOf(AccountId(contactSessionId)),
+ removeMessages = removeMessages
+ )
+ }
+
+ try {
+ task.join()
+ } catch (e: Exception) {
+ mutableError.value = e.localizedMessage.orEmpty()
+ } finally {
+ mutableInProgress.value = false
+ }
+ }
+ }
+
+ fun onResendPromotionClicked(memberSessionId: String) {
+ onPromoteContact(memberSessionId)
+ }
+
+ fun onEditNameClicked() {
+ mutableEditingName.value = groupInfo.value?.first?.name.orEmpty()
+ }
+
+ fun onCancelEditingNameClicked() {
+ mutableEditingName.value = null
+ }
+
+ fun onEditingNameChanged(value: String) {
+ // Cut off the group name so we don't exceed max length
+ if (value.length > MAX_GROUP_NAME_LENGTH) {
+ mutableEditingName.value = value.substring(0, MAX_GROUP_NAME_LENGTH)
+ } else {
+ mutableEditingName.value = value
+ }
+ }
+
+ fun onEditNameConfirmClicked() {
+ val newName = mutableEditingName.value
+ if (newName != null) {
+ storage.setName(groupSessionId, newName.trim())
+ mutableEditingName.value = null
+ }
+ }
+
+ fun onDismissError() {
+ mutableError.value = null
+ }
+
+ @AssistedFactory
+ interface Factory {
+ fun create(groupSessionId: String): EditGroupViewModel
+ }
+}
+
+data class GroupMemberState(
+ val accountId: String,
+ val name: String,
+ val status: String,
+ val highlightStatus: Boolean,
+ val canResendInvite: Boolean,
+ val canResendPromotion: Boolean,
+ val canRemove: Boolean,
+ val canPromote: Boolean,
+) {
+ val canEdit: Boolean get() = canRemove || canPromote || canResendInvite || canResendPromotion
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyClosedGroupLoader.kt
similarity index 71%
rename from app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupLoader.kt
rename to app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyClosedGroupLoader.kt
index b1e0b5e1d8..3c34395c8b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupLoader.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyClosedGroupLoader.kt
@@ -4,13 +4,13 @@ import android.content.Context
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.AsyncLoader
-class EditClosedGroupLoader(context: Context, val groupID: String) : AsyncLoader(context) {
+class EditLegacyClosedGroupLoader(context: Context, val groupID: String) : AsyncLoader(context) {
- override fun loadInBackground(): EditClosedGroupActivity.GroupMembers {
+ override fun loadInBackground(): EditLegacyGroupActivity.GroupMembers {
val groupDatabase = DatabaseComponent.get(context).groupDatabase()
val members = groupDatabase.getGroupMembers(groupID, true)
val zombieMembers = groupDatabase.getGroupZombieMembers(groupID)
- return EditClosedGroupActivity.GroupMembers(
+ return EditLegacyGroupActivity.GroupMembers(
members.map {
it.address.toString()
},
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupActivity.kt
similarity index 91%
rename from app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt
rename to app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupActivity.kt
index 11dde4b93e..f76673feaa 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupActivity.kt
@@ -18,15 +18,12 @@ import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
+import com.bumptech.glide.Glide
import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint
import java.io.IOException
import javax.inject.Inject
import network.loki.messenger.R
-import nl.komponents.kovenant.Promise
-import nl.komponents.kovenant.task
-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
@@ -43,12 +40,11 @@ import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup
-import com.bumptech.glide.Glide
import org.thoughtcrime.securesms.util.fadeIn
import org.thoughtcrime.securesms.util.fadeOut
@AndroidEntryPoint
-class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
+class EditLegacyGroupActivity : PassphraseRequiredActionBarActivity() {
@Inject
lateinit var groupConfigFactory: ConfigFactory
@@ -80,9 +76,9 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
private val memberListAdapter by lazy {
if (isSelfAdmin)
- EditClosedGroupMembersAdapter(this, Glide.with(this), isSelfAdmin, this::onMemberClick)
+ EditLegacyGroupMembersAdapter(this, Glide.with(this), isSelfAdmin, this::onMemberClick)
else
- EditClosedGroupMembersAdapter(this, Glide.with(this), isSelfAdmin)
+ EditLegacyGroupMembersAdapter(this, Glide.with(this), isSelfAdmin)
}
private lateinit var mainContentContainer: LinearLayout
@@ -129,7 +125,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
findViewById(R.id.rvUserList).apply {
adapter = memberListAdapter
- layoutManager = LinearLayoutManager(this@EditClosedGroupActivity)
+ layoutManager = LinearLayoutManager(this@EditLegacyGroupActivity)
}
lblGroupNameDisplay.text = originalName
@@ -162,13 +158,13 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
LoaderManager.getInstance(this).initLoader(loaderID, null, object : LoaderManager.LoaderCallbacks {
override fun onCreateLoader(id: Int, bundle: Bundle?): Loader {
- return EditClosedGroupLoader(this@EditClosedGroupActivity, groupID)
+ return EditLegacyClosedGroupLoader(this@EditLegacyGroupActivity, groupID)
}
override fun onLoadFinished(loader: Loader, groupMembers: GroupMembers) {
// We no longer need any subsequent loading events
// (they will occur on every activity resume).
- LoaderManager.getInstance(this@EditClosedGroupActivity).destroyLoader(loaderID)
+ LoaderManager.getInstance(this@EditLegacyGroupActivity).destroyLoader(loaderID)
members.clear()
members.addAll(groupMembers.members.toHashSet())
@@ -192,7 +188,6 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
// endregion
// region Updating
- @Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
@@ -252,7 +247,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
}
private fun onAddMembersClick() {
- val intent = Intent(this@EditClosedGroupActivity, SelectContactsActivity::class.java)
+ val intent = Intent(this@EditLegacyGroupActivity, SelectContactsActivity::class.java)
intent.putExtra(SelectContactsActivity.usersToExcludeKey, allMembers.toTypedArray())
intent.putExtra(SelectContactsActivity.emptyStateTextKey, "No contacts to add")
startActivityForResult(intent, addUsersRequestCode)
@@ -320,10 +315,10 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
if (isClosedGroup) {
isLoading = true
loaderContainer.fadeIn()
- val promise: Promise = if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) {
- MessageSender.explicitLeave(groupPublicKey!!, false)
- } else {
- task {
+ try {
+ if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) {
+ MessageSender.explicitLeave(groupPublicKey!!, false)
+ } else {
if (hasNameChanged) {
MessageSender.explicitNameChange(groupPublicKey!!, name)
}
@@ -334,15 +329,13 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
if (removes.isNotEmpty()) MessageSender.explicitRemoveMembers(groupPublicKey!!, removes.map { it.address.serialize() })
}
}
- }
- promise.successUi {
loaderContainer.fadeOut()
isLoading = false
updateGroupConfig()
finish()
- }.failUi { exception ->
+ } catch (exception: Exception) {
val message = if (exception is MessageSender.Error) exception.description else "An error occurred"
- Toast.makeText(this@EditClosedGroupActivity, message, Toast.LENGTH_LONG).show()
+ Toast.makeText(this@EditLegacyGroupActivity, message, Toast.LENGTH_LONG).show()
loaderContainer.fadeOut()
isLoading = false
}
@@ -350,8 +343,6 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
}
private fun updateGroupConfig() {
- val latestRecipient = storage.getRecipientSettings(Address.fromSerialized(groupID))
- ?: return Log.w("Loki", "No recipient settings when trying to update group config")
val latestGroup = storage.getGroup(groupID)
?: return Log.w("Loki", "No group record when trying to update group config")
groupConfigFactory.updateLegacyGroup(latestGroup)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupMembersAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupMembersAdapter.kt
similarity index 95%
rename from app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupMembersAdapter.kt
rename to app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupMembersAdapter.kt
index 5127e3be72..248b858376 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupMembersAdapter.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditLegacyGroupMembersAdapter.kt
@@ -9,12 +9,12 @@ import com.bumptech.glide.RequestManager
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.TextSecurePreferences
-class EditClosedGroupMembersAdapter(
+class EditLegacyGroupMembersAdapter(
private val context: Context,
private val glide: RequestManager,
private val admin: Boolean,
private val memberClickListener: ((String) -> Unit)? = null
-) : RecyclerView.Adapter() {
+) : RecyclerView.Adapter() {
private val members = ArrayList()
private val zombieMembers = ArrayList()
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMemberSelection.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMemberSelection.kt
new file mode 100644
index 0000000000..0b18b4e7a7
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMemberSelection.kt
@@ -0,0 +1,2 @@
+package org.thoughtcrime.securesms.groups
+
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt
new file mode 100644
index 0000000000..68cce86e0a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt
@@ -0,0 +1,132 @@
+package org.thoughtcrime.securesms.groups
+
+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 kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.withContext
+import org.session.libsession.database.StorageProtocol
+import org.session.libsession.messaging.contacts.Contact
+import org.thoughtcrime.securesms.dependencies.ConfigFactory
+import org.thoughtcrime.securesms.home.search.getSearchName
+
+@OptIn(FlowPreview::class)
+@HiltViewModel(assistedFactory = SelectContactsViewModel.Factory::class)
+class SelectContactsViewModel @AssistedInject constructor(
+ private val storage: StorageProtocol,
+ private val configFactory: ConfigFactory,
+ @Assisted private val excludingAccountIDs: Set,
+ @Assisted private val scope: CoroutineScope
+) : ViewModel() {
+ // Input: The search query
+ private val mutableSearchQuery = MutableStateFlow("")
+
+ // Input: The selected contact account IDs
+ private val mutableSelectedContactAccountIDs = MutableStateFlow(emptySet())
+
+ // Output: The search query
+ val searchQuery: StateFlow get() = mutableSearchQuery
+
+ // Output: the contact items to display and select from
+ val contacts: StateFlow> = combine(
+ observeContacts(),
+ mutableSearchQuery.debounce(100L),
+ mutableSelectedContactAccountIDs,
+ ::filterContacts
+ ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
+
+ // Output
+ val currentSelected: Set
+ get() = contacts.value
+ .asSequence()
+ .filter { it.selected }
+ .map { it.contact }
+ .toSet()
+
+ override fun onCleared() {
+ super.onCleared()
+
+ scope.cancel()
+ }
+
+ private fun observeContacts() = (configFactory.configUpdateNotifications as Flow)
+ .debounce(100L)
+ .onStart { emit(Unit) }
+ .map {
+ withContext(Dispatchers.Default) {
+ val allContacts = storage.getAllContacts()
+
+ if (excludingAccountIDs.isEmpty()) {
+ allContacts
+ } else {
+ allContacts.filterNot { it.accountID in excludingAccountIDs }
+ }
+ }
+ }
+
+
+ private fun filterContacts(
+ contacts: Collection,
+ query: String,
+ selectedAccountIDs: Set
+ ): List {
+ return contacts
+ .asSequence()
+ .filter {
+ query.isBlank() ||
+ it.name?.contains(query, ignoreCase = true) == true ||
+ it.nickname?.contains(query, ignoreCase = true) == true
+ }
+ .map { contact ->
+ ContactItem(
+ contact = contact,
+ selected = selectedAccountIDs.contains(contact.accountID)
+ )
+ }
+ .toList()
+ }
+
+ fun onSearchQueryChanged(query: String) {
+ mutableSearchQuery.value = query
+ }
+
+ fun onContactItemClicked(accountID: String) {
+ val newSet = mutableSelectedContactAccountIDs.value.toHashSet()
+ if (!newSet.remove(accountID)) {
+ newSet.add(accountID)
+ }
+ mutableSelectedContactAccountIDs.value = newSet
+ }
+
+ @AssistedFactory
+ interface Factory {
+ fun create(
+ excludingAccountIDs: Set = emptySet(),
+ scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
+ ): SelectContactsViewModel
+ }
+}
+
+data class ContactItem(
+ val contact: Contact,
+ val selected: Boolean,
+) {
+ val accountID: String get() = contact.accountID
+ val name: String get() = contact.getSearchName()
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt
new file mode 100644
index 0000000000..16a596b047
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt
@@ -0,0 +1,168 @@
+package org.thoughtcrime.securesms.groups.compose
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CheckboxDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment.Companion.CenterVertically
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import network.loki.messenger.R
+import org.session.libsession.messaging.contacts.Contact
+import org.session.libsession.utilities.Address
+import org.session.libsession.utilities.recipients.Recipient
+import org.thoughtcrime.securesms.groups.ContactItem
+import org.thoughtcrime.securesms.ui.Avatar
+import org.thoughtcrime.securesms.ui.theme.LocalColors
+import org.thoughtcrime.securesms.ui.theme.LocalType
+import org.thoughtcrime.securesms.ui.theme.PreviewTheme
+
+
+@Composable
+fun GroupMinimumVersionBanner(modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(LocalColors.current.warning)
+ ) {
+ Text(
+ text = stringResource(R.string.groupInviteVersion),
+ color = LocalColors.current.textAlert,
+ style = LocalType.current.small,
+ maxLines = 2,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp)
+ )
+ }
+}
+
+fun LazyListScope.multiSelectMemberList(
+ contacts: List,
+ modifier: Modifier = Modifier,
+ onContactItemClicked: (accountId: String) -> Unit,
+ enabled: Boolean = true,
+) {
+ items(contacts) { contact ->
+ Column {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .toggleable(
+ enabled = enabled,
+ value = contact.selected,
+ onValueChange = { onContactItemClicked(contact.accountID) },
+ role = Role.Checkbox
+ )
+ .padding(vertical = 8.dp, horizontal = 24.dp),
+ verticalAlignment = CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ ContactPhoto(
+ contact.accountID,
+ )
+ MemberName(name = contact.name)
+ Checkbox(
+ checked = contact.selected,
+ onCheckedChange = null,
+ colors = CheckboxDefaults.colors(checkedColor = LocalColors.current.primary),
+ enabled = enabled,
+ )
+ }
+
+ HorizontalDivider(color = LocalColors.current.borders)
+ }
+ }
+}
+
+val MemberNameStyle = TextStyle(fontWeight = FontWeight.Bold)
+
+@Composable
+fun RowScope.MemberName(
+ name: String,
+ modifier: Modifier = Modifier
+) = Text(
+ text = name,
+ style = MemberNameStyle,
+ modifier = modifier
+ .weight(1f)
+ .align(CenterVertically)
+)
+
+
+@Composable
+fun RowScope.ContactPhoto(sessionId: String) {
+ return if (LocalInspectionMode.current) {
+ Image(
+ painterResource(id = R.drawable.ic_profile_default),
+ colorFilter = ColorFilter.tint(LocalColors.current.textSecondary),
+ contentScale = ContentScale.Inside,
+ contentDescription = null,
+ modifier = Modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .border(1.dp, LocalColors.current.borders, CircleShape)
+ )
+ } else {
+ val context = LocalContext.current
+ // Ideally we migrate to something that doesn't require recipient, or get contact photo another way
+ val recipient = remember(sessionId) {
+ Recipient.from(context, Address.fromSerialized(sessionId), false)
+ }
+ Avatar(recipient)
+ }
+}
+
+
+@Preview
+@Composable
+fun PreviewMemberList() {
+ val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
+
+ PreviewTheme {
+ LazyColumn {
+ multiSelectMemberList(
+ contacts = listOf(
+ ContactItem(
+ Contact(random, "Person"),
+ selected = false,
+ ),
+ ContactItem(
+ Contact(random, "Cow"),
+ selected = true,
+ )
+ ),
+ onContactItemClicked = {}
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt
new file mode 100644
index 0000000000..733f390086
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt
@@ -0,0 +1,169 @@
+package org.thoughtcrime.securesms.groups.compose
+
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import network.loki.messenger.R
+import org.session.libsession.messaging.contacts.Contact
+import org.thoughtcrime.securesms.groups.ContactItem
+import org.thoughtcrime.securesms.groups.CreateGroupEvent
+import org.thoughtcrime.securesms.groups.CreateGroupViewModel
+import org.thoughtcrime.securesms.ui.CloseIcon
+import org.thoughtcrime.securesms.ui.LoadingArcOr
+import org.thoughtcrime.securesms.ui.NavigationBar
+import org.thoughtcrime.securesms.ui.SearchBar
+import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
+import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField
+import org.thoughtcrime.securesms.ui.theme.LocalColors
+import org.thoughtcrime.securesms.ui.theme.LocalDimensions
+import org.thoughtcrime.securesms.ui.theme.LocalType
+import org.thoughtcrime.securesms.ui.theme.PreviewTheme
+
+
+@Composable
+fun CreateGroupScreen(
+ onNavigateToConversationScreen: (threadID: Long) -> Unit,
+ onBack: () -> Unit,
+ onClose: () -> Unit,
+) {
+ val viewModel: CreateGroupViewModel = hiltViewModel()
+ val context = LocalContext.current
+
+ LaunchedEffect(viewModel) {
+ viewModel.events.collect { event ->
+ when (event) {
+ is CreateGroupEvent.NavigateToConversation -> {
+ onClose()
+ onNavigateToConversationScreen(event.threadID)
+ }
+
+ is CreateGroupEvent.Error -> {
+ Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+
+ CreateGroup(
+ groupName = viewModel.groupName.collectAsState().value,
+ onGroupNameChanged = viewModel::onGroupNameChanged,
+ groupNameError = viewModel.groupNameError.collectAsState().value,
+ contactSearchQuery = viewModel.selectContactsViewModel.searchQuery.collectAsState().value,
+ onContactSearchQueryChanged = viewModel.selectContactsViewModel::onSearchQueryChanged,
+ onContactItemClicked = viewModel.selectContactsViewModel::onContactItemClicked,
+ showLoading = viewModel.isLoading.collectAsState().value,
+ items = viewModel.selectContactsViewModel.contacts.collectAsState().value,
+ onCreateClicked = viewModel::onCreateClicked,
+ onBack = onBack,
+ onClose = onClose,
+ )
+}
+
+@Composable
+fun CreateGroup(
+ groupName: String,
+ onGroupNameChanged: (String) -> Unit,
+ groupNameError: String,
+ contactSearchQuery: String,
+ onContactSearchQueryChanged: (String) -> Unit,
+ onContactItemClicked: (accountID: String) -> Unit,
+ showLoading: Boolean,
+ items: List,
+ onCreateClicked: () -> Unit,
+ onBack: () -> Unit,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val focusManager = LocalFocusManager.current
+
+ Column(
+ modifier = modifier.padding(bottom = LocalDimensions.current.mediumSpacing),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ NavigationBar(
+ title = stringResource(id = R.string.groupCreate),
+ onBack = onBack,
+ actionElement = { CloseIcon(onClose) }
+ )
+
+ SessionOutlinedTextField(
+ text = groupName,
+ onChange = onGroupNameChanged,
+ placeholder = stringResource(R.string.groupNameEnter),
+ textStyle = LocalType.current.base,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ error = groupNameError.takeIf { it.isNotBlank() },
+ enabled = !showLoading,
+ onContinue = focusManager::clearFocus
+ )
+
+ SearchBar(
+ query = contactSearchQuery,
+ onValueChanged = onContactSearchQueryChanged,
+ placeholder = stringResource(R.string.searchContacts),
+ modifier = Modifier.padding(horizontal = 16.dp),
+ enabled = !showLoading
+ )
+
+ LazyColumn(modifier = Modifier.weight(1f)) {
+ multiSelectMemberList(
+ contacts = items,
+ onContactItemClicked = onContactItemClicked,
+ enabled = !showLoading
+ )
+ }
+
+ PrimaryOutlineButton(onClick = onCreateClicked, modifier = Modifier.widthIn(min = 120.dp)) {
+ LoadingArcOr(loading = showLoading) {
+ Text(stringResource(R.string.create))
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun CreateGroupPreview(
+) {
+ val random = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
+ val previewMembers = listOf(
+ ContactItem(Contact(random, name = "Alice"), false),
+ ContactItem(Contact(random, name = "Bob"), true),
+ )
+
+ PreviewTheme {
+ CreateGroup(
+ modifier = Modifier.background(LocalColors.current.backgroundSecondary),
+ groupName = "Group Name",
+ onGroupNameChanged = {},
+ contactSearchQuery = "",
+ onContactSearchQueryChanged = {},
+ onContactItemClicked = {},
+ items = previewMembers,
+ onBack = {},
+ onClose = {},
+ onCreateClicked = {},
+ showLoading = false,
+ groupNameError = "",
+ )
+ }
+
+}
+
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt
new file mode 100644
index 0000000000..0e1ebb75e4
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt
@@ -0,0 +1,504 @@
+package org.thoughtcrime.securesms.groups.compose
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SheetState
+import androidx.compose.material3.Snackbar
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Alignment.Companion.CenterVertically
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import com.squareup.phrase.Phrase
+import kotlinx.serialization.Serializable
+import network.loki.messenger.R
+import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY
+import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
+import org.thoughtcrime.securesms.groups.EditGroupViewModel
+import org.thoughtcrime.securesms.groups.GroupMemberState
+import org.thoughtcrime.securesms.ui.AlertDialog
+import org.thoughtcrime.securesms.ui.DialogButtonModel
+import org.thoughtcrime.securesms.ui.GetString
+import org.thoughtcrime.securesms.ui.NavigationBar
+import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
+import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField
+import org.thoughtcrime.securesms.ui.theme.LocalColors
+import org.thoughtcrime.securesms.ui.theme.LocalType
+import org.thoughtcrime.securesms.ui.theme.PreviewTheme
+import org.thoughtcrime.securesms.ui.theme.bold
+
+@Composable
+fun EditGroupScreen(
+ groupSessionId: String,
+ onFinish: () -> Unit,
+) {
+ val navController = rememberNavController()
+ val viewModel = hiltViewModel { factory ->
+ factory.create(groupSessionId)
+ }
+
+ NavHost(navController = navController, startDestination = RouteEditGroup) {
+ composable {
+ EditGroup(
+ onBackClick = onFinish,
+ onAddMemberClick = { navController.navigate(RouteSelectContacts) },
+ onResendInviteClick = viewModel::onResendInviteClicked,
+ onPromoteClick = viewModel::onPromoteContact,
+ onRemoveClick = viewModel::onRemoveContact,
+ onEditNameClicked = viewModel::onEditNameClicked,
+ onEditNameCancelClicked = viewModel::onCancelEditingNameClicked,
+ onEditNameConfirmed = viewModel::onEditNameConfirmClicked,
+ onEditingNameValueChanged = viewModel::onEditingNameChanged,
+ editingName = viewModel.editingName.collectAsState().value,
+ members = viewModel.members.collectAsState().value,
+ groupName = viewModel.groupName.collectAsState().value,
+ showAddMembers = viewModel.showAddMembers.collectAsState().value,
+ canEditName = viewModel.canEditGroupName.collectAsState().value,
+ onResendPromotionClick = viewModel::onResendPromotionClicked,
+ showingError = viewModel.error.collectAsState().value,
+ onErrorDismissed = viewModel::onDismissError,
+ )
+ }
+
+ composable {
+ SelectContactsScreen(
+ excludingAccountIDs = viewModel.excludingAccountIDsFromContactSelection,
+ onDoneClicked = {
+ viewModel.onContactSelected(it)
+ navController.popBackStack()
+ },
+ onBackClicked = { navController.popBackStack() },
+ )
+ }
+ }
+
+}
+
+@Serializable
+private object RouteEditGroup
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EditGroup(
+ onBackClick: () -> Unit,
+ onAddMemberClick: () -> Unit,
+ onResendInviteClick: (accountId: String) -> Unit,
+ onResendPromotionClick: (accountId: String) -> Unit,
+ onPromoteClick: (accountId: String) -> Unit,
+ onRemoveClick: (accountId: String, removeMessages: Boolean) -> Unit,
+ onEditingNameValueChanged: (String) -> Unit,
+ editingName: String?,
+ onEditNameClicked: () -> Unit,
+ onEditNameConfirmed: () -> Unit,
+ onEditNameCancelClicked: () -> Unit,
+ canEditName: Boolean,
+ groupName: String,
+ members: List,
+ showAddMembers: Boolean,
+ showingError: String?,
+ onErrorDismissed: () -> Unit,
+) {
+ val sheetState = rememberModalBottomSheetState()
+
+ val (showingBottomModelForMember, setShowingBottomModelForMember) = remember {
+ mutableStateOf(null)
+ }
+
+ val (showingConfirmRemovingMember, setShowingConfirmRemovingMember) = remember {
+ mutableStateOf(null)
+ }
+
+ Scaffold(
+ topBar = {
+ NavigationBar(
+ title = stringResource(id = R.string.groupEdit),
+ onBack = onBackClick,
+ actionElement = {
+ TextButton(onClick = onBackClick) {
+ Text(
+ text = stringResource(id = R.string.done),
+ color = LocalColors.current.text,
+ style = LocalType.current.large.bold()
+ )
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ Column(modifier = Modifier.padding(paddingValues)) {
+
+ GroupMinimumVersionBanner()
+
+ // Group name title
+ Row(
+ modifier = Modifier
+ .animateContentSize()
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
+ verticalAlignment = CenterVertically,
+ ) {
+ if (editingName != null) {
+ IconButton(onClick = onEditNameCancelClicked) {
+ Icon(
+ painter = painterResource(R.drawable.ic_x),
+ contentDescription = stringResource(R.string.AccessibilityId_cancel),
+ tint = LocalColors.current.text,
+ )
+ }
+
+ SessionOutlinedTextField(
+ modifier = Modifier.width(180.dp),
+ text = editingName,
+ onChange = onEditingNameValueChanged,
+ textStyle = LocalType.current.large
+ )
+
+ IconButton(onClick = onEditNameConfirmed) {
+ Icon(
+ painter = painterResource(R.drawable.check),
+ contentDescription = stringResource(R.string.AccessibilityId_confirm),
+ tint = LocalColors.current.text,
+ )
+ }
+ } else {
+ Text(
+ text = groupName,
+ style = LocalType.current.h3,
+ textAlign = TextAlign.Center,
+ )
+
+ if (canEditName) {
+ IconButton(onClick = onEditNameClicked) {
+ Icon(
+ painterResource(R.drawable.ic_baseline_edit_24),
+ contentDescription = stringResource(R.string.groupName),
+ tint = LocalColors.current.text,
+ )
+ }
+ }
+ }
+ }
+
+ // Header & Add member button
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = CenterVertically
+ ) {
+ Text(
+ stringResource(R.string.groupMembers),
+ modifier = Modifier.weight(1f),
+ style = LocalType.current.large,
+ color = LocalColors.current.text
+ )
+
+ if (showAddMembers) {
+ PrimaryOutlineButton(
+ stringResource(R.string.membersInvite),
+ onClick = onAddMemberClick
+ )
+ }
+ }
+
+
+ // List of members
+ LazyColumn(modifier = Modifier) {
+ items(members) { member ->
+ // Each member's view
+ MemberItem(
+ modifier = Modifier.fillMaxWidth(),
+ member = member,
+ onClick = { setShowingBottomModelForMember(member) }
+ )
+ }
+ }
+ }
+ }
+
+ if (showingBottomModelForMember != null) {
+ MemberModalBottomSheetOptions(
+ onDismissRequest = { setShowingBottomModelForMember(null) },
+ sheetState = sheetState,
+ onRemove = {
+ setShowingConfirmRemovingMember(showingBottomModelForMember)
+ setShowingBottomModelForMember(null)
+ },
+ onPromote = {
+ setShowingBottomModelForMember(null)
+ onPromoteClick(showingBottomModelForMember.accountId)
+ },
+ onResendInvite = {
+ setShowingBottomModelForMember(null)
+ onResendInviteClick(showingBottomModelForMember.accountId)
+ },
+ onResendPromotion = {
+ setShowingBottomModelForMember(null)
+ onResendPromotionClick(showingBottomModelForMember.accountId)
+ },
+ member = showingBottomModelForMember,
+ )
+ }
+
+ if (showingConfirmRemovingMember != null) {
+ ConfirmRemovingMemberDialog(
+ onDismissRequest = {
+ setShowingConfirmRemovingMember(null)
+ },
+ onConfirmed = onRemoveClick,
+ member = showingConfirmRemovingMember,
+ groupName = groupName,
+ )
+ }
+
+ if (!showingError.isNullOrEmpty()) {
+ Snackbar(
+ dismissAction = {
+ TextButton(onClick = onErrorDismissed) {
+ Text(text = stringResource(id = R.string.dismiss))
+ }
+ },
+ content = {
+ Text(text = showingError)
+ }
+ )
+ }
+}
+
+@Composable
+private fun ConfirmRemovingMemberDialog(
+ onConfirmed: (accountId: String, removeMessages: Boolean) -> Unit,
+ onDismissRequest: () -> Unit,
+ member: GroupMemberState,
+ groupName: String,
+) {
+ val context = LocalContext.current
+ AlertDialog(
+ onDismissRequest = onDismissRequest,
+ text = Phrase.from(context, R.string.groupRemoveDescription)
+ .put(NAME_KEY, member.name)
+ .put(GROUP_NAME_KEY, groupName)
+ .format()
+ .toString(),
+ title = stringResource(R.string.remove),
+ buttons = listOf(
+ DialogButtonModel(
+ text = GetString(R.string.remove),
+ color = LocalColors.current.danger,
+ onClick = { onConfirmed(member.accountId, false) }
+ ),
+ DialogButtonModel(
+ text = GetString(R.string.groupRemoveMessages),
+ color = LocalColors.current.danger,
+ onClick = { onConfirmed(member.accountId, true) }
+ ),
+ DialogButtonModel(
+ text = GetString(R.string.cancel),
+ onClick = onDismissRequest,
+ )
+ )
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun MemberModalBottomSheetOptions(
+ member: GroupMemberState,
+ onRemove: () -> Unit,
+ onPromote: () -> Unit,
+ onResendInvite: () -> Unit,
+ onResendPromotion: () -> Unit,
+ onDismissRequest: () -> Unit,
+ sheetState: SheetState,
+) {
+ ModalBottomSheet(
+ onDismissRequest = onDismissRequest,
+ sheetState = sheetState,
+ ) {
+ if (member.canRemove) {
+ val context = LocalContext.current
+ MemberModalBottomSheetOptionItem(
+ onClick = onRemove,
+ text = context.resources.getQuantityString(R.plurals.groupRemoveUserOnly, 1)
+ )
+ }
+
+ if (member.canPromote) {
+ MemberModalBottomSheetOptionItem(
+ onClick = onPromote,
+ text = stringResource(R.string.adminPromoteToAdmin)
+ )
+ }
+
+ if (member.canResendInvite) {
+ MemberModalBottomSheetOptionItem(onClick = onResendInvite, text = "Resend invite")
+ }
+
+ if (member.canResendPromotion) {
+ MemberModalBottomSheetOptionItem(onClick = onResendPromotion, text = "Resend promotion")
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+ }
+}
+
+@Composable
+private fun MemberModalBottomSheetOptionItem(
+ text: String,
+ onClick: () -> Unit
+) {
+ Text(
+ modifier = Modifier
+ .clickable(onClick = onClick)
+ .padding(16.dp)
+ .fillMaxWidth(),
+ style = LocalType.current.base,
+ text = text,
+ color = LocalColors.current.text,
+ )
+}
+
+@Composable
+private fun MemberItem(
+ onClick: (accountId: String) -> Unit,
+ member: GroupMemberState,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = CenterVertically,
+ ) {
+ ContactPhoto(member.accountId)
+
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+
+ Text(
+ style = LocalType.current.large,
+ text = member.name,
+ color = LocalColors.current.text
+ )
+
+ if (member.status.isNotEmpty()) {
+ Text(
+ text = member.status,
+ style = LocalType.current.small,
+ color = if (member.highlightStatus) {
+ LocalColors.current.danger
+ } else {
+ LocalColors.current.textSecondary
+ },
+ )
+ }
+ }
+
+ if (member.canEdit) {
+ IconButton(onClick = { onClick(member.accountId) }) {
+ Icon(
+ painter = painterResource(R.drawable.ic_circle_dot_dot_dot),
+ contentDescription = stringResource(R.string.AccessibilityId_sessionSettings)
+ )
+ }
+ }
+ }
+}
+
+
+@Preview
+@Composable
+private fun EditGroupPreview() {
+ PreviewTheme {
+ val oneMember = GroupMemberState(
+ accountId = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
+ name = "Test User",
+ status = "Invited",
+ highlightStatus = false,
+ canPromote = true,
+ canRemove = true,
+ canResendInvite = false,
+ canResendPromotion = false,
+ )
+ val twoMember = GroupMemberState(
+ accountId = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235",
+ name = "Test User 2",
+ status = "Promote failed",
+ highlightStatus = true,
+ canPromote = true,
+ canRemove = true,
+ canResendInvite = false,
+ canResendPromotion = false,
+ )
+ val threeMember = GroupMemberState(
+ accountId = "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236",
+ name = "Test User 3",
+ status = "",
+ highlightStatus = false,
+ canPromote = true,
+ canRemove = true,
+ canResendInvite = false,
+ canResendPromotion = false,
+ )
+
+ val (editingName, setEditingName) = remember { mutableStateOf(null) }
+
+ EditGroup(
+ onBackClick = {},
+ onAddMemberClick = {},
+ onResendInviteClick = {},
+ onPromoteClick = {},
+ onRemoveClick = { _, _ -> },
+ onEditNameCancelClicked = {
+ setEditingName(null)
+ },
+ onEditNameConfirmed = {
+ setEditingName(null)
+ },
+ onEditNameClicked = {
+ setEditingName("Test Group")
+ },
+ editingName = editingName,
+ onEditingNameValueChanged = setEditingName,
+ members = listOf(oneMember, twoMember, threeMember),
+ canEditName = true,
+ groupName = "Test",
+ showAddMembers = true,
+ onResendPromotionClick = {},
+ showingError = "Error",
+ onErrorDismissed = {}
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContactsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContactsScreen.kt
new file mode 100644
index 0000000000..edfbdf2ce4
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/SelectContactsScreen.kt
@@ -0,0 +1,146 @@
+package org.thoughtcrime.securesms.groups.compose
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush.Companion.verticalGradient
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import kotlinx.serialization.Serializable
+import network.loki.messenger.R
+import org.session.libsession.messaging.contacts.Contact
+import org.thoughtcrime.securesms.groups.ContactItem
+import org.thoughtcrime.securesms.groups.SelectContactsViewModel
+import org.thoughtcrime.securesms.ui.CloseIcon
+import org.thoughtcrime.securesms.ui.NavigationBar
+import org.thoughtcrime.securesms.ui.SearchBar
+import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
+import org.thoughtcrime.securesms.ui.theme.LocalColors
+import org.thoughtcrime.securesms.ui.theme.PreviewTheme
+
+
+@Serializable
+object RouteSelectContacts
+
+@Composable
+fun SelectContactsScreen(
+ excludingAccountIDs: Set = emptySet(),
+ onDoneClicked: (selectedContacts: Set) -> Unit,
+ onBackClicked: () -> Unit,
+) {
+ val viewModel = hiltViewModel { factory ->
+ factory.create(excludingAccountIDs)
+ }
+
+ SelectContacts(
+ contacts = viewModel.contacts.collectAsState().value,
+ onContactItemClicked = viewModel::onContactItemClicked,
+ searchQuery = viewModel.searchQuery.collectAsState().value,
+ onSearchQueryChanged = viewModel::onSearchQueryChanged,
+ onDoneClicked = { onDoneClicked(viewModel.currentSelected) },
+ onBack = onBackClicked,
+ )
+}
+
+@Composable
+fun SelectContacts(
+ contacts: List,
+ onContactItemClicked: (accountId: String) -> Unit,
+ searchQuery: String,
+ onSearchQueryChanged: (String) -> Unit,
+ onDoneClicked: () -> Unit,
+ onBack: () -> Unit,
+ onClose: (() -> Unit)? = null,
+ @StringRes okButtonResId: Int = R.string.ok
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ NavigationBar(
+ title = stringResource(id = R.string.contactSelect),
+ onBack = onBack,
+ actionElement = {
+ if (onClose != null) {
+ CloseIcon(onClose)
+ }
+ }
+ )
+
+ GroupMinimumVersionBanner()
+ SearchBar(
+ query = searchQuery,
+ onValueChanged = onSearchQueryChanged,
+ placeholder = stringResource(R.string.searchContacts),
+ modifier = Modifier.padding(horizontal = 16.dp),
+ backgroundColor = LocalColors.current.backgroundSecondary,
+ )
+
+ LazyColumn(modifier = Modifier.weight(1f)) {
+ multiSelectMemberList(
+ contacts = contacts,
+ onContactItemClicked = onContactItemClicked,
+ )
+ }
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ verticalGradient(
+ 0f to Color.Transparent,
+ 0.2f to LocalColors.current.background,
+ )
+ )
+ ) {
+ PrimaryOutlineButton(
+ onClick = onDoneClicked,
+ modifier = Modifier
+ .padding(horizontal = 12.dp, vertical = 4.dp)
+ .defaultMinSize(minWidth = 128.dp),
+ ) {
+ Text(
+ stringResource(id = okButtonResId)
+ )
+ }
+ }
+ }
+
+}
+
+@Preview
+@Composable
+private fun PreviewSelectContacts() {
+ PreviewTheme {
+ SelectContacts(
+ contacts = listOf(
+ ContactItem(
+ contact = Contact(accountID = "123", name = "User 1"),
+ selected = false,
+ ),
+ ContactItem(
+ contact = Contact(accountID = "124", name = "User 2"),
+ selected = true,
+ ),
+ ),
+ onContactItemClicked = {},
+ searchQuery = "",
+ onSearchQueryChanged = {},
+ onDoneClicked = {},
+ onBack = {},
+ onClose = null
+ )
+ }
+}
+
diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt
index 82b9f16dcd..3f6fe57352 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt
@@ -9,6 +9,8 @@ import androidx.core.view.isVisible
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding
+import org.session.libsession.utilities.GroupRecord
+import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.getConversationUnread
@@ -21,7 +23,9 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
// is not the best idea. It doesn't survive configuration change.
// We should be dealing with IDs and all sorts of serializable data instead
// if we want to use dialog fragments properly.
+ lateinit var publicKey: String
lateinit var thread: ThreadRecord
+ var group: GroupRecord? = null
@Inject lateinit var configFactory: ConfigFactory
@@ -51,6 +55,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
binding.blockTextView -> onBlockTapped?.invoke()
binding.unblockTextView -> onUnblockTapped?.invoke()
binding.deleteTextView -> onDeleteTapped?.invoke()
+ binding.leaveTextView -> onDeleteTapped?.invoke()
binding.markAllAsReadTextView -> onMarkAllAsReadTapped?.invoke()
binding.notificationsTextView -> onNotificationTapped?.invoke()
binding.unMuteNotificationsTextView -> onSetMuteTapped?.invoke(false)
@@ -62,6 +67,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
super.onViewCreated(view, savedInstanceState)
if (!this::thread.isInitialized) { return dismiss() }
val recipient = thread.recipient
+ val isCurrentUserInGroup = group?.members?.map { it.toString() }?.contains(publicKey) ?: false
if (!recipient.isGroupRecipient && !recipient.isLocalNumber) {
binding.detailsTextView.visibility = View.VISIBLE
binding.unblockTextView.visibility = if (recipient.isBlocked) View.VISIBLE else View.GONE
@@ -82,7 +88,10 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
binding.muteNotificationsTextView.setOnClickListener(this)
binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted
binding.notificationsTextView.setOnClickListener(this)
+ binding.deleteTextView.isVisible = recipient.isContactRecipient || (recipient.isGroupRecipient && !isCurrentUserInGroup)
binding.deleteTextView.setOnClickListener(this)
+ binding.leaveTextView.isVisible = recipient.isGroupRecipient && isCurrentUserInGroup
+ binding.leaveTextView.setOnClickListener(this)
binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 || configFactory.convoVolatile?.getConversationUnread(thread) == true
binding.markAllAsReadTextView.setOnClickListener(this)
binding.pinTextView.isVisible = !thread.isPinned
diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt
index d87941fcc1..905f2d2dd1 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt
@@ -4,7 +4,6 @@ import android.content.Context
import android.content.res.Resources
import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
-import android.text.TextUtils
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
@@ -16,6 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewConversationBinding
import org.session.libsession.utilities.ThemeUtil
+import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions
import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL
@@ -50,6 +50,16 @@ class ConversationView : LinearLayout {
// region Updating
fun bind(thread: ThreadRecord, isTyping: Boolean) {
+ if (thread.isLeavingGroup) {
+ binding.conversationViewDisplayNameTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorSecondary))
+ binding.snippetTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorSecondary))
+ } else if (thread.isErrorLeavingGroup) {
+ binding.conversationViewDisplayNameTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary))
+ binding.snippetTextView.setTextColor(context.getColorFromAttr(R.attr.danger))
+ } else {
+ binding.conversationViewDisplayNameTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary))
+ binding.snippetTextView.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary))
+ }
this.thread = thread
if (thread.isPinned) {
binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(
diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt
index 5af7e7be52..d9cbd13cc5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt
@@ -36,6 +36,7 @@ import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.JobQueue
+import org.session.libsession.messaging.jobs.LibSessionGroupLeavingJob
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
@@ -43,6 +44,7 @@ import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.ProfilePictureModifiedEvent
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.ThreadUtils
import org.session.libsignal.utilities.toHexString
@@ -71,7 +73,6 @@ import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager
-import org.thoughtcrime.securesms.notifications.PushRegistry
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.SettingsActivity
import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity
@@ -116,7 +117,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
@Inject lateinit var groupDatabase: GroupDatabase
@Inject lateinit var textSecurePreferences: TextSecurePreferences
@Inject lateinit var configFactory: ConfigFactory
- @Inject lateinit var pushRegistry: PushRegistry
private val globalSearchViewModel by viewModels()
private val homeViewModel by viewModels()
@@ -140,9 +140,13 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey))
}
is GlobalSearchAdapter.Model.Contact -> push {
- putExtra(ConversationActivityV2.ADDRESS, model.contact.accountID.let(Address::fromSerialized))
+ putExtra(
+ ConversationActivityV2.ADDRESS,
+ model.contact.accountID.let(Address::fromSerialized)
+ )
}
- is GlobalSearchAdapter.Model.GroupConversation -> model.groupRecord.encodedId
+
+ is GlobalSearchAdapter.Model.LegacyGroupConversation -> model.groupRecord.encodedId
.let { Recipient.from(this, Address.fromSerialized(it), false) }
.let(threadDb::getThreadIdIfExistsFor)
.takeIf { it >= 0 }
@@ -238,7 +242,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
(applicationContext as ApplicationContext).startPollingIfNeeded()
// update things based on TextSecurePrefs (profile info etc)
// Set up remaining components if needed
- pushRegistry.refresh(false)
if (textSecurePreferences.getLocalNumber() != null) {
OpenGroupManager.startPolling()
JobQueue.shared.resumePendingJobs()
@@ -330,7 +333,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private val GlobalSearchResult.contactAndGroupList: List get() =
contacts.map { GlobalSearchAdapter.Model.Contact(it, it.nickname ?: it.name, it.accountID == publicKey) } +
- threads.map(GlobalSearchAdapter.Model::GroupConversation)
+ threads.map(GlobalSearchAdapter.Model::LegacyGroupConversation)
private val GlobalSearchResult.messageResults: List get() {
val unreadThreadMap = messages
@@ -428,7 +431,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
override fun onLongConversationClick(thread: ThreadRecord) {
val bottomSheet = ConversationOptionsBottomSheet(this)
+ bottomSheet.publicKey = publicKey
bottomSheet.thread = thread
+ bottomSheet.group = groupDatabase.getGroup(thread.recipient.address.toString()).orNull()
bottomSheet.onViewDetailsTapped = {
bottomSheet.dismiss()
val userDetailsBottomSheet = UserDetailsBottomSheet()
@@ -588,14 +593,18 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
val group = groupDatabase.getGroup(recipient.address.toString()).orNull()
// If you are an admin of this group you can delete it
- if (group != null && group.admins.map { it.toString() }.contains(textSecurePreferences.getLocalNumber())) {
+ if (group != null && group.admins.map { it.toString() }
+ .contains(textSecurePreferences.getLocalNumber())) {
title = getString(R.string.groupDelete)
message = Phrase.from(this.applicationContext, R.string.groupDeleteDescription)
.put(GROUP_NAME_KEY, group.title)
.format()
} else {
// Otherwise this is either a community, or it's a group you're not an admin of
- title = if (recipient.isCommunityRecipient) getString(R.string.communityLeave) else getString(R.string.groupLeave)
+ title =
+ if (recipient.isCommunityRecipient) getString(R.string.communityLeave) else getString(
+ R.string.groupLeave
+ )
message = Phrase.from(this.applicationContext, R.string.groupLeaveDescription)
.put(GROUP_NAME_KEY, group.title)
.format()
@@ -622,25 +631,34 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
lifecycleScope.launch(Dispatchers.Main) {
val context = this@HomeActivity
// Cancel any outstanding jobs
- DatabaseComponent.get(context).sessionJobDatabase().cancelPendingMessageSendJobs(threadID)
+ DatabaseComponent.get(context).sessionJobDatabase()
+ .cancelPendingMessageSendJobs(threadID)
// Send a leave group message if this is an active closed group
- if (recipient.address.isClosedGroup && DatabaseComponent.get(context).groupDatabase().isActive(recipient.address.toGroupString())) {
+ if (recipient.address.isLegacyClosedGroup && DatabaseComponent.get(context)
+ .groupDatabase().isActive(recipient.address.toGroupString())
+ ) {
try {
- GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString()
+ GroupUtil.doubleDecodeGroupID(recipient.address.toString())
+ .toHexString()
.takeIf(DatabaseComponent.get(context).lokiAPIDatabase()::isClosedGroup)
- ?.let { MessageSender.explicitLeave(it, false) }
+ ?.let { MessageSender.explicitLeave(it, true, deleteThread = true) }
} catch (ioe: IOException) {
- Log.w(TAG, "Got an IOException while sending leave group message")
+ Log.w(TAG, "Got an IOException while sending leave group message", ioe)
}
}
+ if (recipient.address.isClosedGroupV2) {
+ val groupLeave = LibSessionGroupLeavingJob(AccountId(recipient.address.serialize()), true)
+ JobQueue.shared.add(groupLeave)
+ }
// Delete the conversation
- val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID)
+ val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase()
+ .getOpenGroupChat(threadID)
if (v2OpenGroup != null) {
- v2OpenGroup.apply { OpenGroupManager.delete(server, room, context) }
- } else {
- lifecycleScope.launch(Dispatchers.IO) {
- threadDb.deleteConversation(threadID)
- }
+ OpenGroupManager.delete(
+ v2OpenGroup.server,
+ v2OpenGroup.room,
+ context
+ )
}
// Update the badge count
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt
index dd6d24cd00..884df78c9a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt
@@ -80,7 +80,7 @@ class HomeViewModel @Inject constructor(
).flowOn(Dispatchers.IO)
private fun unapprovedConversationCount() = reloadTriggersAndContentChanges()
- .map { threadDb.unapprovedConversationCount }
+ .map { threadDb.unapprovedConversationList.use { cursor -> cursor.count } }
private fun latestUnapprovedConversationTimestamp() = reloadTriggersAndContentChanges()
.map { threadDb.latestUnapprovedConversationTimestamp }
diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt
index 71c2c62506..70af080b76 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt
@@ -11,7 +11,7 @@ import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding
import network.loki.messenger.databinding.ViewGlobalSearchResultBinding
import network.loki.messenger.databinding.ViewGlobalSearchSubheaderBinding
import org.session.libsession.utilities.GroupRecord
-import org.session.libsession.utilities.recipients.Recipient
+import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.search.model.MessageResult
import org.thoughtcrime.securesms.ui.GetString
import java.security.InvalidParameterException
@@ -116,7 +116,7 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie
fun bind(query: String, model: Model) {
binding.searchResultProfilePicture.recycle()
when (model) {
- is Model.GroupConversation -> bindModel(query, model)
+ is Model.LegacyGroupConversation -> bindModel(query, model)
is Model.Contact -> bindModel(query, model)
is Model.Message -> bindModel(query, model)
is Model.SavedMessages -> bindModel(model)
@@ -136,8 +136,9 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie
constructor(title: String): this(GetString(title))
}
data class SavedMessages(val currentUserPublicKey: String): Model()
- data class Contact(val contact: ContactModel, val name: String?, val isSelf: Boolean): Model()
- data class GroupConversation(val groupRecord: GroupRecord): Model()
- data class Message(val messageResult: MessageResult, val unread: Int, val isSelf: Boolean): Model()
+ data class Contact(val contact: ContactModel, val name: String?, val isSelf: Boolean) : Model()
+ data class LegacyGroupConversation(val groupRecord: GroupRecord) : Model()
+ data class ClosedGroupConversation(val sessionId: AccountId)
+ data class Message(val messageResult: MessageResult, val unread: Int, val isSelf: Boolean) : Model()
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt
index 947edc3d8e..9d466d7463 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt
@@ -14,7 +14,7 @@ import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.truncateIdForDisplay
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Contact as ContactModel
-import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation
+import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.LegacyGroupConversation
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages
@@ -66,7 +66,7 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) {
binding.searchResultSubtitle.isVisible = true
binding.searchResultTitle.text = model.messageResult.conversationRecipient.getSearchName()
}
- is GroupConversation -> {
+ is LegacyGroupConversation -> {
binding.searchResultTitle.text = getHighlight(
query,
model.groupRecord.title
@@ -87,9 +87,9 @@ private fun getHighlight(query: String?, toSearch: String): Spannable? {
return SearchUtil.getHighlightedSpan(Locale.getDefault(), BoldStyleFactory, toSearch, query)
}
-fun ContentView.bindModel(query: String?, model: GroupConversation) {
+fun ContentView.bindModel(query: String?, model: LegacyGroupConversation) {
binding.searchResultProfilePicture.isVisible = true
- binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup
+ binding.searchResultSubtitle.isVisible = model.groupRecord.isLegacyClosedGroup
binding.searchResultTimestamp.isVisible = false
val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false)
binding.searchResultProfilePicture.update(threadRecipient)
@@ -99,7 +99,7 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) {
val groupRecipients = model.groupRecord.members.map { Recipient.from(binding.root.context, it, false) }
val membersString = groupRecipients.joinToString(transform = Recipient::getSearchName)
- if (model.groupRecord.isClosedGroup) {
+ if (model.groupRecord.isLegacyClosedGroup) {
binding.searchResultSubtitle.text = getHighlight(query, membersString)
}
}
@@ -127,13 +127,6 @@ fun ContentView.bindModel(model: SavedMessages) {
fun ContentView.bindModel(query: String?, model: Message) = binding.apply {
searchResultProfilePicture.isVisible = true
searchResultTimestamp.isVisible = true
-
-// val hasUnreads = model.unread > 0
-// unreadCountIndicator.isVisible = hasUnreads
-// if (hasUnreads) {
-// unreadCountTextView.text = model.unread.toString()
-// }
-
searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(root.context, Locale.getDefault(), model.messageResult.sentTimestampMs)
searchResultProfilePicture.update(model.messageResult.conversationRecipient)
val textSpannable = SpannableStringBuilder()
diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt
index 93f79d2b16..0e9865c01e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt
@@ -16,6 +16,8 @@ import kotlinx.coroutines.launch
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityMessageRequestsBinding
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
+import org.session.libsession.utilities.Address
+import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.ThreadDatabase
@@ -80,7 +82,10 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
override fun onBlockConversationClick(thread: ThreadRecord) {
fun doBlock() {
- viewModel.blockMessageRequest(thread)
+ val recipient = thread.invitingAdminId?.let {
+ Recipient.from(this, Address.fromSerialized(it), false)
+ } ?: thread.recipient
+ viewModel.blockMessageRequest(thread, recipient)
LoaderManager.getInstance(this).restartLoader(0, null, this)
}
@@ -108,7 +113,11 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
showSessionDialog {
title(R.string.delete)
text(resources.getString(R.string.messageRequestsDelete))
- button(R.string.delete) { doDecline() }
+ if (thread.recipient.isClosedGroupV2Recipient) {
+ dangerButton(R.string.delete, contentDescriptionRes = R.string.delete) { doDecline() }
+ } else {
+ dangerButton(R.string.decline, contentDescriptionRes = R.string.decline) { doDecline() }
+ }
button(R.string.cancel)
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt
index cb352d83b7..59ab267587 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt
@@ -31,7 +31,9 @@ class MessageRequestsAdapter(
val view = MessageRequestView(context)
view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } }
view.setOnLongClickListener {
- view.thread?.let { showPopupMenu(view) }
+ view.thread?.let { thread ->
+ showPopupMenu(view, thread.recipient.isGroupRecipient, thread.invitingAdminId)
+ }
true
}
return ViewHolder(view)
@@ -47,10 +49,14 @@ class MessageRequestsAdapter(
holder?.view?.recycle()
}
- private fun showPopupMenu(view: MessageRequestView) {
+ private fun showPopupMenu(view: MessageRequestView, groupRecipient: Boolean, invitingAdmin: String?) {
val popupMenu = PopupMenu(ContextThemeWrapper(context, R.style.PopupMenu_MessageRequests), view)
- popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu)
- popupMenu.menu.findItem(R.id.menu_block_message_request)?.isVisible = !view.thread!!.recipient.isOpenGroupInboxRecipient
+ // still show the block option if we have an inviting admin for the group
+ if ((groupRecipient && invitingAdmin == null) || view.thread!!.recipient.isOpenGroupInboxRecipient) {
+ popupMenu.menuInflater.inflate(R.menu.menu_group_request, popupMenu.menu)
+ } else {
+ popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu)
+ }
popupMenu.setOnMenuItemClickListener { menuItem ->
if (menuItem.itemId == R.id.menu_delete_message_request) {
listener.onDeleteConversationClick(view.thread!!)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt
index a3a7caf8d2..d9003d005a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
+import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.repository.ConversationRepository
import javax.inject.Inject
@@ -13,12 +14,10 @@ class MessageRequestsViewModel @Inject constructor(
private val repository: ConversationRepository
) : ViewModel() {
- fun blockMessageRequest(thread: ThreadRecord) = viewModelScope.launch {
- val recipient = thread.recipient
- if (recipient.isContactRecipient) {
- repository.setBlocked(recipient, true)
- deleteMessageRequest(thread)
- }
+ // We assume thread.recipient is a contact or thread.invitingAdmin is not null
+ fun blockMessageRequest(thread: ThreadRecord, blockRecipient: Recipient) = viewModelScope.launch {
+ repository.setBlocked(thread.threadId, blockRecipient, true)
+ deleteMessageRequest(thread)
}
fun deleteMessageRequest(thread: ThreadRecord) = viewModelScope.launch {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt
index 63f6d07da1..25ff4c8a10 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt
@@ -7,20 +7,24 @@ import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
-import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
+import kotlinx.coroutines.GlobalScope
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.all
import nl.komponents.kovenant.functional.bind
+import org.session.libsession.database.userAuth
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
import org.session.libsession.messaging.jobs.MessageReceiveParameters
-import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
+import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2
import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller
import org.session.libsession.snode.SnodeAPI
+import org.session.libsession.snode.utilities.asyncPromise
+import org.session.libsession.snode.utilities.await
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.recover
@@ -108,20 +112,23 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor
var dmsPromise: Promise = Promise.ofSuccess(Unit)
if (requestTargets.contains(Targets.DMS)) {
- val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
- dmsPromise = SnodeAPI.getMessages(userPublicKey).bind { envelopes ->
+ val userAuth = requireNotNull(MessagingModuleConfiguration.shared.storage.userAuth)
+ dmsPromise = SnodeAPI.getMessages(userAuth).bind { envelopes ->
val params = envelopes.map { (envelope, serverHash) ->
// FIXME: Using a job here seems like a bad idea...
MessageReceiveParameters(envelope.toByteArray(), serverHash, null)
}
- BatchMessageReceiveJob(params).executeAsync("background")
+
+ GlobalScope.asyncPromise {
+ BatchMessageReceiveJob(params).executeAsync("background")
+ }
}
promises.add(dmsPromise)
}
// Closed groups
if (requestTargets.contains(Targets.CLOSED_GROUPS)) {
- val closedGroupPoller = ClosedGroupPollerV2() // Intentionally don't use shared
+ val closedGroupPoller = LegacyClosedGroupPollerV2() // Intentionally don't use shared
val storage = MessagingModuleConfiguration.shared.storage
val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys()
allGroupPublicKeys.iterator().forEach { closedGroupPoller.poll(it) }
diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt
index cbf53e6a8b..7f6bae5e17 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt
@@ -43,11 +43,9 @@ import kotlin.concurrent.Volatile
import me.leolin.shortcutbadger.ShortcutBadger
import network.loki.messenger.R
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier
-import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities.blindedKeyPair
import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.ServiceUtil
-import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY
import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber
import org.session.libsession.utilities.TextSecurePreferences.Companion.getNotificationPrivacy
@@ -56,6 +54,7 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.hasHidde
import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotificationsEnabled
import org.session.libsession.utilities.TextSecurePreferences.Companion.removeHasHiddenMessageRequests
import org.session.libsession.utilities.recipients.Recipient
+import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Util
diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt
index 59681c1f8a..505e8f0ec3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt
@@ -6,13 +6,13 @@ import android.content.Context
import android.content.Intent
import android.os.AsyncTask
import androidx.core.app.NotificationManagerCompat
+import org.session.libsession.database.userAuth
import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared
import org.session.libsession.messaging.messages.control.ReadReceipt
import org.session.libsession.messaging.sending_receiving.MessageSender.send
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.snode.SnodeAPI.nowWithOffset
import org.session.libsession.utilities.SSKEnvironment
-import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled
import org.session.libsession.utilities.associateByNotNull
import org.session.libsession.utilities.recipients.Recipient
@@ -102,7 +102,7 @@ class MarkReadReceiver : BroadcastReceiver() {
SnodeAPI.alterTtl(
messageHashes = hashes,
newExpiry = nowWithOffset + expiresIn,
- publicKey = TextSecurePreferences.getLocalNumber(context)!!,
+ auth = checkNotNull(shared.storage.userAuth) { "No authorized user" },
shorten = true
)
}
@@ -130,7 +130,7 @@ class MarkReadReceiver : BroadcastReceiver() {
hashToMessage: Map
) {
@Suppress("UNCHECKED_CAST")
- val expiries = SnodeAPI.getExpiries(hashToMessage.keys.toList(), TextSecurePreferences.getLocalNumber(context)!!).get()["expiries"] as Map
+ val expiries = SnodeAPI.getExpiries(hashToMessage.keys.toList(), shared.storage.userAuth!!).get()["expiries"] as Map
hashToMessage.forEach { (hash, info) -> expiries[hash]?.let { scheduleDeletion(context, info.expirationInfo, it - info.expirationInfo.expireStarted) } }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt
index 8eaca4000b..d592836440 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt
@@ -1,54 +1,110 @@
package org.thoughtcrime.securesms.notifications
import android.content.Context
+import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat.getString
import com.goterl.lazysodium.interfaces.AEAD
import com.goterl.lazysodium.utils.Key
import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import network.loki.messenger.R
+import network.loki.messenger.libsession_util.util.GroupInfo
import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageReceiveParameters
+import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationMetadata
import org.session.libsession.messaging.utilities.MessageWrapper
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.messaging.utilities.SodiumUtilities.sodium
+import org.session.libsession.snode.GroupSubAccountSwarmAuth
+import org.session.libsession.snode.OwnedSwarmAuth
import org.session.libsession.utilities.bencode.Bencode
import org.session.libsession.utilities.bencode.BencodeList
import org.session.libsession.utilities.bencode.BencodeString
+import org.session.libsession.utilities.withGroupConfigsOrNull
+import org.session.libsignal.protos.SignalServiceProtos
+import org.session.libsignal.protos.SignalServiceProtos.Envelope
+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.thoughtcrime.securesms.crypto.IdentityKeyUtil
+import org.thoughtcrime.securesms.dependencies.ConfigFactory
import javax.inject.Inject
private const val TAG = "PushHandler"
-class PushReceiver @Inject constructor(@ApplicationContext val context: Context) {
+class PushReceiver @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val configFactory: ConfigFactory
+) {
private val json = Json { ignoreUnknownKeys = true }
fun onPush(dataMap: Map?) {
- onPush(dataMap?.asByteArray())
- }
-
- fun onPush(data: ByteArray?) {
+ val result = dataMap?.decodeAndDecrypt()
+ val data = result?.first
if (data == null) {
onPush()
return
}
+ handlePushData(data = data, metadata = result.second)
+ }
+
+ private fun handlePushData(data: ByteArray, metadata: PushNotificationMetadata?) {
try {
- val envelopeAsData = MessageWrapper.unwrap(data).toByteArray()
- val job = BatchMessageReceiveJob(listOf(MessageReceiveParameters(envelopeAsData)), null)
- JobQueue.shared.add(job)
+ val params = when {
+ metadata?.namespace == Namespace.CLOSED_GROUP_MESSAGES() -> {
+ val groupId = AccountId(requireNotNull(metadata.account) {
+ "Received a closed group message push notification without an account ID"
+ })
+
+ val envelop = checkNotNull(tryDecryptGroupMessage(groupId, data)) {
+ "Unable to decrypt closed group message"
+ }
+
+ MessageReceiveParameters(
+ data = envelop.toByteArray(),
+ serverHash = metadata.msg_hash,
+ closedGroup = Destination.ClosedGroup(groupId.hexString)
+ )
+ }
+
+ metadata?.namespace == 0 || metadata == null -> {
+ MessageReceiveParameters(
+ data = MessageWrapper.unwrap(data).toByteArray(),
+ )
+ }
+
+ else -> {
+ Log.w(TAG, "Received a push notification with an unknown namespace: ${metadata.namespace}")
+ return
+ }
+ }
+
+ JobQueue.shared.add(BatchMessageReceiveJob(listOf(params), null))
} catch (e: Exception) {
Log.d(TAG, "Failed to unwrap data for message due to error.", e)
}
}
+ private fun tryDecryptGroupMessage(groupId: AccountId, data: ByteArray): Envelope? {
+ return configFactory.withGroupConfigsOrNull(groupId) { _, _, keys ->
+ val (envelopBytes, sender) = checkNotNull(keys.decrypt(data)) {
+ "Failed to decrypt group message"
+ }
+
+ Log.d(TAG, "Successfully decrypted group message from ${sender.hexString}")
+ Envelope.parseFrom(envelopBytes)
+ .toBuilder()
+ .setSource(sender.hexString)
+ .build()
+ }
+ }
+
private fun onPush() {
Log.d(TAG, "Failed to decode data for message.")
val builder = NotificationCompat.Builder(context, NotificationChannels.OTHER)
@@ -61,10 +117,13 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
- NotificationManagerCompat.from(context).notify(11111, builder.build())
+
+ if (context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
+ NotificationManagerCompat.from(context).notify(11111, builder.build())
+ }
}
- private fun Map.asByteArray() =
+ private fun Map.decodeAndDecrypt() =
when {
// this is a v2 push notification
containsKey("spns") -> {
@@ -76,18 +135,20 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context)
}
}
// old v1 push notification; we still need this for receiving legacy closed group notifications
- else -> this["ENCRYPTED_DATA"]?.let(Base64::decode)
+ else -> this["ENCRYPTED_DATA"]?.let { Base64.decode(it) to null }
}
- private fun decrypt(encPayload: ByteArray): ByteArray? {
+ private fun decrypt(encPayload: ByteArray): Pair {
Log.d(TAG, "decrypt() called")
val encKey = getOrCreateNotificationKey()
- val nonce = encPayload.take(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray()
- val payload = encPayload.drop(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray()
+ val nonce = encPayload.sliceArray(0 until AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES)
+ val payload =
+ encPayload.sliceArray(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES until encPayload.size)
val padded = SodiumUtilities.decrypt(payload, encKey.asBytes, nonce)
?: error("Failed to decrypt push notification")
- val decrypted = padded.dropLastWhile { it.toInt() == 0 }.toByteArray()
+ val contentEndedAt = padded.indexOfLast { it.toInt() != 0 }
+ val decrypted = if (contentEndedAt >= 0) padded.sliceArray(0..contentEndedAt) else padded
val bencoded = Bencode.Decoder(decrypted)
val expectedList = (bencoded.decode() as? BencodeList)?.values
?: error("Failed to decode bencoded list from payload")
@@ -99,20 +160,18 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context)
// null content is valid only if we got a "data_too_long" flag
it?.let { check(metadata.data_len == it.size) { "wrong message data size" } }
?: check(metadata.data_too_long) { "missing message data, but no too-long flag" }
- }
+ } to metadata
}
fun getOrCreateNotificationKey(): Key {
- if (IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY) == null) {
- // generate the key and store it
- val key = sodium.keygen(AEAD.Method.XCHACHA20_POLY1305_IETF)
- IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString)
+ val keyHex = IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY)
+ if (keyHex != null) {
+ return Key.fromHexString(keyHex)
}
- return Key.fromHexString(
- IdentityKeyUtil.retrieve(
- context,
- IdentityKeyUtil.NOTIFICATION_KEY
- )
- )
+
+ // generate the key and store it
+ val key = sodium.keygen(AEAD.Method.XCHACHA20_POLY1305_IETF)
+ IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString)
+ return key
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt
new file mode 100644
index 0000000000..052d285ff4
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt
@@ -0,0 +1,235 @@
+package org.thoughtcrime.securesms.notifications
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.scan
+import kotlinx.coroutines.launch
+import network.loki.messenger.libsession_util.GroupInfoConfig
+import network.loki.messenger.libsession_util.GroupKeysConfig
+import network.loki.messenger.libsession_util.GroupMembersConfig
+import org.session.libsession.database.userAuth
+import org.session.libsession.messaging.notifications.TokenFetcher
+import org.session.libsession.snode.GroupSubAccountSwarmAuth
+import org.session.libsession.snode.OwnedSwarmAuth
+import org.session.libsession.snode.SwarmAuth
+import org.session.libsession.utilities.TextSecurePreferences
+import org.session.libsession.utilities.withGroupConfigsOrNull
+import org.session.libsignal.utilities.AccountId
+import org.session.libsignal.utilities.Log
+import org.session.libsignal.utilities.Namespace
+import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
+import org.thoughtcrime.securesms.database.Storage
+import org.thoughtcrime.securesms.dependencies.ConfigFactory
+import javax.inject.Inject
+
+private const val TAG = "PushRegistrationHandler"
+
+/**
+ * A class that listens to the config, user's preference, token changes and
+ * register/unregister push notification accordingly.
+ *
+ * This class DOES NOT handle the legacy groups push notification.
+ */
+class PushRegistrationHandler
+@Inject
+constructor(
+ private val pushRegistry: PushRegistryV2,
+ private val configFactory: ConfigFactory,
+ private val preferences: TextSecurePreferences,
+ private val storage: Storage,
+ private val tokenFetcher: TokenFetcher,
+) {
+ @OptIn(DelicateCoroutinesApi::class)
+ private val scope: CoroutineScope = GlobalScope
+
+ private var job: Job? = null
+
+ @OptIn(FlowPreview::class)
+ fun run() {
+ require(job == null) { "Job is already running" }
+
+ job = scope.launch(Dispatchers.Default) {
+ combine(
+ configFactory.configUpdateNotifications
+ .debounce(500L)
+ .onStart { emit(Unit) },
+ IdentityKeyUtil.CHANGES.onStart { emit(Unit) },
+ preferences.pushEnabled,
+ tokenFetcher.token,
+ ) { _, _, enabled, token ->
+ if (!enabled || token.isNullOrEmpty()) {
+ return@combine emptyMap()
+ }
+
+ val userAuth =
+ storage.userAuth ?: return@combine emptyMap()
+ getGroupSubscriptions(
+ token = token,
+ userSecretKey = userAuth.ed25519PrivateKey
+ ) + mapOf(
+ SubscriptionKey(userAuth.accountId, token) to OwnedSubscription(
+ userAuth,
+ 0
+ )
+ )
+ }
+ .scan